Compare commits

..

7 Commits

Author SHA1 Message Date
Rhet Turnbull
675371f0d7 Updated README.md [skip ci] 2021-07-04 08:41:58 -07:00
Rhet Turnbull
7e2d09bf12 Added --preview, #470 2021-07-04 08:39:06 -07:00
Rhet Turnbull
28c681aa96 Refactored export2, #485, #486 2021-07-03 22:50:03 -07:00
Rhet Turnbull
5d39aa92df Update README.md 2021-07-02 19:07:02 -07:00
Rhet Turnbull
b4dbad5e74 Fixed path_derivatives to always return jpeg if photo is a photo 2021-07-02 18:59:01 -07:00
Rhet Turnbull
b1b099257f Updated README.md [skip ci] 2021-07-02 13:27:22 -07:00
Rhet Turnbull
63e8410841 Updated CHANGELOG.md [skip ci] 2021-07-02 13:23:48 -07:00
9 changed files with 559 additions and 273 deletions

View File

@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.42.54](https://github.com/RhetTbull/osxphotos/compare/v0.42.52...v0.42.54)
> 2 July 2021
- Removed _applescript, #461 [`1d26ac9`](https://github.com/RhetTbull/osxphotos/commit/1d26ac9630dd0a414c01cc4f89a080e4efd7fd97)
- Removed _applescript, #461 [`03b4f59`](https://github.com/RhetTbull/osxphotos/commit/03b4f59549de54da91c36feba613d69f9e86e47b)
- Added get_selected() to REPL [`2e1c91c`](https://github.com/RhetTbull/osxphotos/commit/2e1c91cd672eefe84063933437e5d691f5ad1db1)
#### [v0.42.52](https://github.com/RhetTbull/osxphotos/compare/v0.42.51...v0.42.52)
> 2 July 2021

View File

@@ -3,9 +3,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![tests](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)
![PyPI - Python Version](https://img.shields.io/pypi/pyversions/osxphotos)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-25-orange.svg?style=flat)](#contributors)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
OSXPhotos provides the ability to interact with and query Apple's Photos.app library on macOS. You can query the Photos library database — for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
@@ -891,6 +889,19 @@ Options:
best quality, a value of 0.0 specifies maximum
compression. Defaults to 1.0
--preview Export preview image generated by Photos. This
is a lower-resolution image used by Photos to
quickly preview the image.
--preview-suffix SUFFIX Optional suffix template for naming preview
photos. Default name for preview photos is in
form 'photoname_preview.ext'. For example,
with '--preview-suffix _low_res', the preview
photo would be named 'photoname_low_res.ext'.
The default suffix is '_preview'. Multi-value
templates (see Templating System) are not
permitted with --preview-suffix.
--download-missing Attempt to download missing photos from
iCloud. The current implementation uses
Applescript to interact with Photos to export
@@ -1756,7 +1767,7 @@ Substitution Description
{lf} A line feed: '\n', alias for {newline}
{cr} A carriage return: '\r'
{crlf} a carriage return + line feed: '\r\n'
{osxphotos_version} The osxphotos version, e.g. '0.42.52'
{osxphotos_version} The osxphotos version, e.g. '0.42.57'
{osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for
@@ -2670,6 +2681,9 @@ Returns the path to the live video component of a [live photo](#live_photo). If
**Note**: will also return None if the live video component is missing on disk. It's possible that the original photo may be on disk ([ismissing](#ismissing)==False) but the video component is missing, likely because it has not been downloaded from iCloud.
#### `path_edited_live_photo`
Returns the path to the edited live video component of an edited [live photo](#live_photo). If photo is not a live photo or not edited, returns None.
#### `portrait`
Returns True if photo was taken in iPhone portrait mode, otherwise False.
@@ -2781,11 +2795,11 @@ Returns a JSON representation of all photo info.
Returns a dictionary representation of all photo info.
#### `export()`
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
`export(dest, filename=None, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised).
- *filename (optional): name of picture as str; if not provided, will use current filename. **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will happily export the photo using the incorrect file extension. e.g. to get the extension of the edited photo, look at [PhotoInfo.path_edited](#path_edited).
- filename (optional): name of picture as str; if not provided, will use current filename. **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will happily export the photo using the incorrect file extension. e.g. to get the extension of the edited photo, look at [PhotoInfo.path_edited](#path_edited).
- edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version)
- export_as_hardlink: boolean; if True (default=False), will hardlink files instead of copying them
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
@@ -3578,7 +3592,7 @@ The following template field substitutions are availabe for use the templating s
|{lf}|A line feed: '\n', alias for {newline}|
|{cr}|A carriage return: '\r'|
|{crlf}|a carriage return + line feed: '\r\n'|
|{osxphotos_version}|The osxphotos version, e.g. '0.42.52'|
|{osxphotos_version}|The osxphotos version, e.g. '0.42.57'|
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|{album}|Album(s) photo is contained in|
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|

View File

@@ -210,6 +210,9 @@ DEFAULT_EDITED_SUFFIX = "_edited"
# Default suffix to add to original images
DEFAULT_ORIGINAL_SUFFIX = ""
# Default suffix to add to preview images
DEFAULT_PREVIEW_SUFFIX = "_preview"
# Colors for print CLI messages
CLI_COLOR_ERROR = "red"
CLI_COLOR_WARNING = "yellow"

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.42.54"
__version__ = "0.42.57"

View File

@@ -17,6 +17,7 @@ import bitmath
import click
import osxmetadata
import photoscript
import rich.traceback
import yaml
from rich import pretty
@@ -32,6 +33,7 @@ from ._constants import (
DEFAULT_EDITED_SUFFIX,
DEFAULT_JPEG_QUALITY,
DEFAULT_ORIGINAL_SUFFIX,
DEFAULT_PREVIEW_SUFFIX,
EXTENDED_ATTRIBUTE_NAMES,
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
OSXPHOTOS_EXPORT_DB,
@@ -65,6 +67,8 @@ from .utils import expand_and_validate_filepath, load_function
# set via --verbose/-V
VERBOSE = False
rich.traceback.install()
def verbose_(*args, **kwargs):
"""print output if verbose flag set"""
@@ -701,6 +705,20 @@ def cli(ctx, db, json_, debug):
"a value of 0.0 specifies maximum compression. "
f"Defaults to {DEFAULT_JPEG_QUALITY}",
)
@click.option(
"--preview",
is_flag=True,
help="Export preview image generated by Photos. "
"This is a lower-resolution image used by Photos to quickly preview the image.",
)
@click.option(
"--preview-suffix",
metavar="SUFFIX",
help="Optional suffix template for naming preview photos. Default name for preview photos is in form "
f"'photoname{DEFAULT_PREVIEW_SUFFIX}.ext'. For example, with '--preview-suffix _low_res', the preview photo "
f"would be named 'photoname_low_res.ext'. The default suffix is '{DEFAULT_PREVIEW_SUFFIX}'. "
"Multi-value templates (see Templating System) are not permitted with --preview-suffix.",
)
@click.option(
"--download-missing",
is_flag=True,
@@ -1160,6 +1178,8 @@ def export(
duplicate,
post_command,
post_function,
preview,
preview_suffix,
):
"""Export photos from the Photos database.
Export path DEST is required.
@@ -1320,6 +1340,8 @@ def export(
duplicate = cfg.duplicate
post_command = cfg.post_command
post_function = cfg.post_function
preview = cfg.preview
preview_suffix = cfg.preview_suffix
# config file might have changed verbose
VERBOSE = bool(verbose)
@@ -1409,6 +1431,9 @@ def export(
original_suffix = (
DEFAULT_ORIGINAL_SUFFIX if original_suffix is None else original_suffix
)
preview_suffix = (
DEFAULT_PREVIEW_SUFFIX if preview_suffix is None else preview_suffix
)
retry = 0 if not retry else retry
if not os.path.isdir(dest):
@@ -1726,6 +1751,8 @@ def export(
replace_keywords=replace_keywords,
retry=retry,
export_dir=dest,
export_preview=preview,
preview_suffix=preview_suffix,
)
if post_function:
@@ -2380,6 +2407,8 @@ def export_photo(
replace_keywords=False,
retry=0,
export_dir=None,
export_preview=False,
preview_suffix=None,
):
"""Helper function for export that does the actual export
@@ -2421,6 +2450,8 @@ def export_photo(
replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
retry: retry up to retry # of times if there's an error
export_dir: top-level export directory for {export_dir} template
export_preview: export the preview image generated by Photos
preview_suffix: str, template to use as suffix for preview images
Returns:
list of path(s) of exported photo or None if photo was missing
@@ -2481,27 +2512,12 @@ def export_photo(
if "exiftool" in sidecar:
sidecar_flags |= SIDECAR_EXIFTOOL
rendered_suffix = ""
if original_suffix:
try:
options = RenderOptions(filename=True, strip=strip, export_dir=dest)
rendered_suffix, unmatched = photo.render_template(original_suffix, options)
except ValueError as e:
raise click.BadOptionUsage(
"original_suffix",
f"Invalid template for --original-suffix '{original_suffix}': {e}",
)
if not rendered_suffix or unmatched:
raise click.BadOptionUsage(
"original_suffix",
f"Invalid template for --original-suffix '{original_suffix}': results={rendered_suffix} unknown field={unmatched}",
)
if len(rendered_suffix) > 1:
raise click.BadOptionUsage(
"original_suffix",
f"Invalid template for --original-suffix: may not use multi-valued templates: '{original_suffix}': results={rendered_suffix}",
)
rendered_suffix = rendered_suffix[0]
rendered_suffix = _render_suffix_template(
original_suffix, "original_suffix", "--original-suffix", strip, dest, photo
)
rendered_preview_suffix = _render_suffix_template(
preview_suffix, "preview_suffix", "--preview-suffix", strip, dest, photo
)
# if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos
@@ -2580,6 +2596,8 @@ def export_photo(
replace_keywords=replace_keywords,
retry=retry,
export_dir=export_dir,
export_preview=export_preview,
preview_suffix=rendered_preview_suffix,
)
if export_edited and photo.hasadjustments:
@@ -2611,35 +2629,12 @@ def export_photo(
):
edited_ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
if edited_suffix:
try:
options = RenderOptions(
filename=True,
strip=strip,
export_dir=dest,
)
rendered_suffix, unmatched = photo.render_template(
edited_suffix, options
)
except ValueError as e:
raise click.BadOptionUsage(
"edited_suffix",
f"Invalid template for --edited-suffix '{edited_suffix}': {e}",
)
if not rendered_suffix or unmatched:
raise click.BadOptionUsage(
"edited_suffix",
f"Invalid template for --edited-suffix '{edited_suffix}': unknown field={unmatched}",
)
if len(rendered_suffix) > 1:
raise click.BadOptionUsage(
"edited_suffix",
f"Invalid template for --edited-suffix: may not use multi-valued templates: '{edited_suffix}': results={rendered_suffix}",
)
rendered_suffix = rendered_suffix[0]
edited_filename = f"{edited_filename.stem}{rendered_suffix}{edited_ext}"
else:
edited_filename = f"{edited_filename.stem}{edited_ext}"
rendered_edited_suffix = _render_suffix_template(
edited_suffix, "edited_suffix", "--edited-suffix", strip, dest, photo
)
edited_filename = (
f"{edited_filename.stem}{rendered_edited_suffix}{edited_ext}"
)
verbose_(
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
@@ -2655,13 +2650,13 @@ def export_photo(
dest=dest,
dry_run=dry_run,
strip=strip,
export_original=export_original,
export_original=False,
missing=missing_edited,
verbose=verbose,
sidecar_flags=sidecar_flags,
sidecar_flags=sidecar_flags if not export_original else 0,
sidecar_drop_ext=sidecar_drop_ext,
export_live=export_live,
export_raw=export_raw,
export_raw=not export_original and export_raw,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
exiftool=exiftool,
@@ -2685,11 +2680,43 @@ def export_photo(
replace_keywords=replace_keywords,
retry=retry,
export_dir=export_dir,
export_preview=not export_original and export_preview,
preview_suffix=rendered_preview_suffix,
)
return results
def _render_suffix_template(suffix_template, var_name, option_name, strip, dest, photo):
"""render suffix template
Returns:
rendered template
"""
if not suffix_template:
return ""
try:
options = RenderOptions(filename=True, strip=strip, export_dir=dest)
rendered_suffix, unmatched = photo.render_template(suffix_template, options)
except ValueError as e:
raise click.BadOptionUsage(
var_name,
f"Invalid template for {option_name} '{suffix_template}': {e}",
)
if not rendered_suffix or unmatched:
raise click.BadOptionUsage(
var_name,
f"Invalid template for {option_name} '{suffix_template}': results={rendered_suffix} unknown field={unmatched}",
)
if len(rendered_suffix) > 1:
raise click.BadOptionUsage(
var_name,
f"Invalid template for {option_name}: may not use multi-valued templates: '{suffix_template}': results={rendered_suffix}",
)
return rendered_suffix[0]
def export_photo_with_template(
photo,
filename,
@@ -2730,6 +2757,8 @@ def export_photo_with_template(
replace_keywords,
retry,
export_dir,
export_preview,
preview_suffix,
):
"""Evaluate directory template then export photo to each directory"""
@@ -2789,8 +2818,10 @@ def export_photo_with_template(
try:
export_results = photo.export2(
dest_path,
filename,
original_filename=filename,
edited=edited,
original=export_original,
edited_filename=filename,
sidecar=sidecar_flags,
sidecar_drop_ext=sidecar_drop_ext,
live_photo=export_live,
@@ -2820,6 +2851,8 @@ def export_photo_with_template(
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
render_options=render_options,
preview=export_preview,
preview_suffix=preview_suffix,
)
for warning_ in export_results.exiftool_warning:
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
@@ -3949,7 +3982,9 @@ def repl(ctx, cli_obj, db):
)
print(f"\nThe following functions may be helpful:")
print(f"- get_photo(uuid): return a PhotoInfo object for photo with uuid")
print(f"- get_selected(): return list of PhotoInfo objects for photos selected in Photos")
print(
f"- get_selected(): return list of PhotoInfo objects for photos selected in Photos"
)
print(f"- show(photo): open a photo object in the default viewer")
print(
f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)"

View File

@@ -37,6 +37,7 @@ from .._constants import (
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
_XMP_TEMPLATE_NAME_BETA,
DEFAULT_PREVIEW_SUFFIX,
LIVE_VIDEO_EXTENSIONS,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
@@ -379,7 +380,7 @@ def rename_jpeg_files(files, jpeg_ext, fileutil):
def export(
self,
dest,
*filename,
filename=None,
edited=False,
live_photo=False,
raw_photo=False,
@@ -410,12 +411,12 @@ def export(
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
edited: (boolean, default=False); if True will export the edited version of the photo, otherwise exports the original version
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
overwrite: (boolean, default=False); if True will overwrite files if they already exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: if set will write a json sidecar with data in format readable by exiftool
@@ -449,10 +450,25 @@ def export(
if sidecar_xmp:
sidecar |= SIDECAR_XMP
if not filename:
if not edited:
filename = self.original_filename
else:
original_name = pathlib.Path(self.original_filename)
if self.path_edited:
ext = pathlib.Path(self.path_edited).suffix
else:
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
ext = "." + ext
filename = original_name.stem + "_edited" + ext
results = self.export2(
dest,
*filename,
original=not edited,
original_filename=filename,
edited=edited,
edited_filename=filename,
live_photo=live_photo,
raw_photo=raw_photo,
export_as_hardlink=export_as_hardlink,
@@ -466,7 +482,7 @@ def export(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
render_options = render_options,
render_options=render_options,
)
return results.exported
@@ -475,8 +491,10 @@ def export(
def export2(
self,
dest,
*filename,
original=True,
original_filename=None,
edited=False,
edited_filename=None,
live_photo=False,
raw_photo=False,
export_as_hardlink=False,
@@ -509,7 +527,9 @@ def export2(
persons=True,
location=True,
replace_keywords=False,
render_options: Optional[RenderOptions] = None
preview=False,
preview_suffix=DEFAULT_PREVIEW_SUFFIX,
render_options: Optional[RenderOptions] = None,
):
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -521,8 +541,8 @@ def export2(
in which case export will use the extension provided by Photos upon export.
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
original: (boolean, default=True); if True, will export the original version of the photo
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
@@ -565,6 +585,8 @@ def export2(
persons: if True, include persons in exported metadata
location: if True, include location in exported metadata
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
preview: if True, also exports preview image
preview_suffix: optional string to append to end of filename for preview images
render_options: optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
Returns: ExportResults class
@@ -609,206 +631,280 @@ def export2(
self._render_options = render_options or RenderOptions()
# suffix to add to edited files
# e.g. name will be filename_edited.jpg
edited_identifier = "_edited"
# check edited and raise exception trying to export edited version of
# photo that hasn't been edited
export_original = original
export_edited = edited
if edited and not self.hasadjustments:
raise ValueError(
"Photo does not have adjustments, cannot export edited version"
)
# check arguments and get destination path and filename (if provided)
if filename and len(filename) > 2:
raise TypeError(
"Too many positional arguments. Should be at most two: destination, filename."
)
# verify destination is a valid path
if dest is None:
raise ValueError("Destination must not be None")
raise ValueError("dest must not be None")
elif not dry_run and not os.path.isdir(dest):
raise FileNotFoundError("Invalid path passed to export")
if filename and len(filename) == 1:
# if filename passed, use it
fname = filename[0]
else:
# no filename provided so use the default
# if edited file requested, use filename but add _edited
# need to use file extension from edited file as Photos saves a jpeg once edited
if edited and not use_photos_export:
# verify we have a valid path_edited and use that to get filename
if not self.path_edited:
raise FileNotFoundError(
"edited=True but path_edited is none; hasadjustments: "
f" {self.hasadjustments}"
)
edited_name = pathlib.Path(self.path_edited).name
edited_suffix = pathlib.Path(edited_name).suffix
fname = (
pathlib.Path(self.original_filename).stem
+ edited_identifier
+ edited_suffix
)
original_filename = original_filename or self.original_filename
dest_original = pathlib.Path(dest) / original_filename
if not edited_filename:
if not edited:
edited_filename = self.original_filename
else:
fname = self.original_filename
original_name = pathlib.Path(self.original_filename)
if self.path_edited:
ext = pathlib.Path(self.path_edited).suffix
else:
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
ext = "." + ext
edited_filename = original_name.stem + "_edited" + ext
dest_edited = pathlib.Path(dest) / edited_filename
uti = self.uti if edited else self.uti_original
if convert_to_jpeg and self.isphoto and uti != "public.jpeg":
# not a jpeg but will convert to jpeg upon export so fix file extension
fname_new = pathlib.Path(fname)
if convert_to_jpeg and self.isphoto:
something_to_convert = False
ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
fname = str(fname_new.parent / f"{fname_new.stem}{ext}")
if export_original and self.uti_original != "public.jpeg":
# not a jpeg but will convert to jpeg upon export so fix file extension
something_to_convert = True
dest_original = dest_original.parent / f"{dest_original.stem}{ext}"
if export_edited and self.uti != "public.jpeg":
# in Big Sur+, edited HEICs are HEIC
something_to_convert = True
dest_edited = dest_edited.parent / f"{dest_edited.stem}{ext}"
convert_to_jpeg = something_to_convert
else:
# nothing to convert
convert_to_jpeg = False
# check destination path
dest = pathlib.Path(dest)
fname = pathlib.Path(fname)
dest = dest / fname
# check to see if file exists and if so, add (1), (2), etc until we find one that works
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
# e.g. exporting sidecar for file1.png and file1.jpeg
# if file1.png exists and exporting file1.jpeg,
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
count = 0
if not update and increment and not overwrite:
count = 1
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
dest_files = findfiles(f"{dest_original.stem}*", str(dest_original.parent))
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = dest.stem
dest_new = dest_original.stem
while dest_new.lower() in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
dest_new = f"{dest_original.stem} ({count})"
dest_original = dest_original.parent / f"{dest_new}{dest_original.suffix}"
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not update and not overwrite and not increment:
if (
dest_original.exists()
and export_original
and not update
and not overwrite
and not increment
):
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
f"destination exists ({dest_original}); overwrite={overwrite}, increment={increment}"
)
self._render_options.filepath = str(dest)
if export_edited:
if not update and increment and not overwrite:
dest_files = findfiles(f"{dest_edited.stem}*", str(dest_edited.parent))
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = dest_edited.stem
if count:
# incremented above when checking original destination
dest_new = f"{dest_new} ({count})"
while dest_new.lower() in dest_files:
count += 1
dest_new = f"{dest.stem} ({count})"
dest_edited = dest_edited.parent / f"{dest_new}{dest_edited.suffix}"
# if overwrite==False and #increment==False, export should fail if file exists
if dest_edited.exists() and not update and not overwrite and not increment:
raise FileExistsError(
f"destination exists ({dest_edited}); overwrite={overwrite}, increment={increment}"
)
self._render_options.filepath = (
str(dest_original) if export_original else str(dest_edited)
)
all_results = ExportResults()
if not use_photos_export:
if use_photos_export:
# TODO: collapse these into a single call (refactor _export_photo_with_photos_export)
if original:
self._export_photo_with_photos_export(
dest_original,
all_results,
fileutil,
export_db,
use_photokit=use_photokit,
dry_run=dry_run,
timeout=timeout,
jpeg_ext=jpeg_ext,
touch_file=touch_file,
update=update,
overwrite=overwrite,
live_photo=live_photo,
edited=False,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
)
if edited:
self._export_photo_with_photos_export(
dest_edited,
all_results,
fileutil,
export_db,
use_photokit=use_photokit,
dry_run=dry_run,
timeout=timeout,
jpeg_ext=jpeg_ext,
touch_file=touch_file,
update=update,
overwrite=overwrite,
live_photo=live_photo,
edited=True,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
)
else:
# find the source file on disk and export
# get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
export_src_dest = []
if edited:
if self.path_edited is not None:
src = self.path_edited
export_src_dest.append((self.path_edited, dest_edited))
else:
raise FileNotFoundError(
f"Cannot export edited photo if path_edited is None"
)
else:
if self.path is not None:
src = self.path
export_src_dest.append((self.path, dest_original))
else:
raise FileNotFoundError("Cannot export photo if path is None")
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
for src, dest in export_src_dest:
if not pathlib.Path(src).is_file():
raise FileNotFoundError(f"{src} does not appear to exist")
# found source now try to find right destination
if update and dest.exists():
# destination exists, check to see if destination is the right UUID
dest_uuid = export_db.get_uuid_for_file(dest)
if dest_uuid is None and fileutil.cmp(src, dest):
# might be exporting into a pre-ExportDB folder or the DB got deleted
dest_uuid = self.uuid
export_db.set_data(
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
if dest_uuid != self.uuid:
# not the right file, find the right one
count = 1
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
dest_files = glob.glob(glob_str)
found_match = False
for file_ in dest_files:
dest_uuid = export_db.get_uuid_for_file(file_)
if dest_uuid == self.uuid:
dest = pathlib.Path(file_)
found_match = True
break
elif dest_uuid is None and fileutil.cmp(src, file_):
# files match, update the UUID
dest = pathlib.Path(file_)
found_match = True
export_db.set_data(
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
break
if not found_match:
# increment the destination file
# found source now try to find right destination
if update and dest.exists():
# destination exists, check to see if destination is the right UUID
dest_uuid = export_db.get_uuid_for_file(dest)
if dest_uuid is None and fileutil.cmp(src, dest):
# might be exporting into a pre-ExportDB folder or the DB got deleted
dest_uuid = self.uuid
export_db.set_data(
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
if dest_uuid != self.uuid:
# not the right file, find the right one
count = 1
glob_str = str(dest.parent / f"{dest.stem}*")
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
dest_files = glob.glob(glob_str)
dest_files = [pathlib.Path(f).stem for f in dest_files]
dest_new = dest.stem
while dest_new in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
found_match = False
for file_ in dest_files:
dest_uuid = export_db.get_uuid_for_file(file_)
if dest_uuid == self.uuid:
dest = pathlib.Path(file_)
found_match = True
break
elif dest_uuid is None and fileutil.cmp(src, file_):
# files match, update the UUID
dest = pathlib.Path(file_)
found_match = True
export_db.set_data(
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
break
# export the dest file
results = self._export_photo(
src,
dest,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
convert_to_jpeg,
fileutil=fileutil,
edited=edited,
jpeg_quality=jpeg_quality,
ignore_signature=ignore_signature,
)
all_results += results
if not found_match:
# increment the destination file
count = 1
glob_str = str(dest.parent / f"{dest.stem}*")
dest_files = glob.glob(glob_str)
dest_files = [pathlib.Path(f).stem for f in dest_files]
dest_new = dest.stem
while dest_new in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
# export the dest file
results = self._export_photo(
src,
dest,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
convert_to_jpeg,
fileutil=fileutil,
edited=edited,
jpeg_quality=jpeg_quality,
ignore_signature=ignore_signature,
)
all_results += results
dest = dest_original if export_original else dest_edited
# copy live photo associated .mov if requested
if live_photo and self.live_photo:
if export_original and live_photo and self.live_photo and self.path_live_photo:
live_name = dest.parent / f"{dest.stem}.mov"
src_live = self.path_live_photo
results = self._export_photo(
src_live,
live_name,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
False,
fileutil=fileutil,
ignore_signature=ignore_signature,
)
all_results += results
if src_live is not None:
results = self._export_photo(
src_live,
live_name,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
False,
fileutil=fileutil,
ignore_signature=ignore_signature,
)
all_results += results
if (
export_edited
and live_photo
and self.live_photo
and self.path_edited_live_photo
):
live_name = dest.parent / f"{dest_edited.stem}.mov"
src_live = self.path_edited_live_photo
results = self._export_photo(
src_live,
live_name,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
False,
fileutil=fileutil,
ignore_signature=ignore_signature,
)
all_results += results
# copy associated RAW image if requested
if raw_photo and self.has_raw:
@@ -831,26 +927,30 @@ def export2(
ignore_signature=ignore_signature,
)
all_results += results
else:
self._export_photo_with_photos_export(
dest,
filename,
all_results,
fileutil,
export_db,
use_photokit=use_photokit,
dry_run=dry_run,
timeout=timeout,
jpeg_ext=jpeg_ext,
touch_file=touch_file,
update=update,
overwrite=overwrite,
live_photo=live_photo,
edited=edited,
edited_identifier=edited_identifier,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
)
# copy preview image if requested
if preview and self.path_derivatives:
# Photos keeps multiple different derivatives and path_derivatives returns list of them
# first derivative is the largest so export that one
preview_path = pathlib.Path(self.path_derivatives[0])
preview_ext = preview_path.suffix
preview_name = dest.parent / f"{dest.stem}{preview_suffix}{preview_ext}"
if preview_path is not None:
results = self._export_photo(
preview_path,
preview_name,
update,
export_db,
overwrite,
export_as_hardlink,
exiftool,
touch_file,
convert_to_jpeg,
fileutil=fileutil,
jpeg_quality=jpeg_quality,
ignore_signature=ignore_signature,
)
all_results += results
# export metadata
sidecars = []
@@ -861,6 +961,7 @@ def export2(
sidecar_xmp_files_skipped = []
sidecar_xmp_files_written = []
dest = dest_original if export_original else dest_edited
dest_suffix = "" if sidecar_drop_ext else dest.suffix
if sidecar & SIDECAR_JSON:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
@@ -1111,7 +1212,6 @@ def export2(
def _export_photo_with_photos_export(
self,
dest,
filename,
all_results,
fileutil,
export_db,
@@ -1124,7 +1224,6 @@ def _export_photo_with_photos_export(
overwrite=None,
live_photo=None,
edited=None,
edited_identifier=None,
convert_to_jpeg=None,
jpeg_quality=1.0,
):
@@ -1137,15 +1236,10 @@ def _export_photo_with_photos_export(
# shared photos (in shared albums) show up as not having adjustments (not edited)
# but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud
# so tell Photos to export the current version in this case
if filename:
# use filename stem provided
filestem = dest.stem
else:
# didn't get passed a filename, add _edited
filestem = f"{dest.stem}{edited_identifier}"
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
dest = dest.parent / f"{filestem}{ext}"
# didn't get passed a filename, add _edited
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
dest = dest.parent / f"{dest.stem}.{ext}"
if use_photokit:
photolib = PhotoLibrary()
@@ -1190,7 +1284,7 @@ def _export_photo_with_photos_export(
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
filestem=dest.stem,
original=False,
edited=True,
live_photo=live_photo,
@@ -1204,7 +1298,6 @@ def _export_photo_with_photos_export(
all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
else:
# export original version and not edited
filestem = dest.stem
if use_photokit:
photolib = PhotoLibrary()
photo = None
@@ -1241,7 +1334,7 @@ def _export_photo_with_photos_export(
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
filestem=dest.stem,
original=True,
edited=False,
live_photo=live_photo,
@@ -1608,7 +1701,9 @@ def _exiftool_dict(
)
if description_template is not None:
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
options = dataclasses.replace(
self._render_options, expand_inplace=True, inplace_sep=", "
)
rendered = self.render_template(description_template, options)[0]
description = " ".join(rendered) if rendered else ""
exif["EXIF:ImageDescription"] = description
@@ -1647,7 +1742,9 @@ def _exiftool_dict(
if keyword_template:
rendered_keywords = []
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
options = dataclasses.replace(
self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
for template_str in keyword_template:
rendered, unmatched = self.render_template(template_str, options)
if unmatched:
@@ -1925,7 +2022,9 @@ def _xmp_sidecar(
extension = extension.suffix[1:] if extension.suffix else None
if description_template is not None:
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
options = dataclasses.replace(
self._render_options, expand_inplace=True, inplace_sep=", "
)
rendered = self.render_template(description_template, options)[0]
description = " ".join(rendered) if rendered else ""
else:
@@ -1958,7 +2057,9 @@ def _xmp_sidecar(
if keyword_template:
rendered_keywords = []
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
options = dataclasses.replace(
self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
for template_str in keyword_template:
rendered, unmatched = self.render_template(template_str, options)
if unmatched:

View File

@@ -342,6 +342,37 @@ class PhotoInfo:
return photopath
@property
def path_edited_live_photo(self):
"""return path to edited version of live photo movie; only valid for Photos 5+"""
if self._db._db_version < _PHOTOS_5_VERSION:
return None
try:
return self._path_edited_live_photo
except AttributeError:
self._path_edited_live_photo = self._path_edited_5_live_photo()
return self._path_edited_live_photo
def _path_edited_5_live_photo(self):
"""return path_edited_live_photo for Photos >= 5"""
if self._db._db_version < _PHOTOS_5_VERSION:
raise RuntimeError("Wrong database format!")
if self.live_photo and self._info["hasAdjustments"]:
library = self._db._library_path
directory = self._uuid[0] # first char of uuid
filename = f"{self._uuid}_2_100_a.mov"
photopath = os.path.join(
library, "resources", "renders", directory, filename
)
if not os.path.isfile(photopath):
photopath = None
else:
photopath = None
return photopath
@property
def path_raw(self):
"""absolute path of associated RAW image or None if there is not one"""
@@ -852,20 +883,35 @@ class PhotoInfo:
@property
def path_derivatives(self):
"""Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return self._path_derivatives_4()
try:
return self._path_derivatives
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
self._path_derivatives = self._path_derivatives_4()
return self._path_derivatives
directory = self._uuid[0] # first char of uuid
derivative_path = (
pathlib.Path(self._db._library_path)
/ "resources"
/ "derivatives"
/ directory
)
files = derivative_path.glob(f"{self.uuid}*.*")
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
return [str(filename) for filename in files if filename.suffix != ".THM"]
directory = self._uuid[0] # first char of uuid
derivative_path = (
pathlib.Path(self._db._library_path)
/ "resources"
/ "derivatives"
/ directory
)
files = derivative_path.glob(f"{self.uuid}*.*")
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
derivatives = [
str(filename) for filename in files if filename.suffix != ".THM"
]
if (
self.isphoto
and len(derivatives) > 1
and derivatives[0].endswith(".mov")
):
derivatives[1], derivatives[0] = derivatives[0], derivatives[1]
self._path_derivatives = derivatives
return self._path_derivatives
def _path_derivatives_4(self):
"""Return paths to all derivative (preview) files for Photos <= 4"""

View File

@@ -127,6 +127,8 @@ UUID_DICT_LOCAL = {
"burst_not_selected": "89E235DD-B9AC-4E8D-BDA2-986981CA7582", # IMG_9813.JPG
"burst_default": "F5E6BD24-B493-44E9-BDA2-7AD9D2CC8C9D", # IMG_9816.JPG
"burst_not_default": "75154738-83AA-4DCD-A913-632D5D1C0FEE", # IMG_9814.JPG
"live_edited": "54A01B04-16D7-4FDE-8860-19F2A641E433", # IMG_3203.HEIC
"live": "8EC216A2-0032-4934-BD3F-04C6259B3304", # IMG_3259.HEIC
}
UUID_PUMPKIN_FARM = [
@@ -1273,6 +1275,20 @@ def test_burst_default_pic(photosdb_local):
assert not photo.burst_default_pick
@pytest.mark.skipif(SKIP_TEST, reason="Skip if not running on author's local machine.")
def test_path_edited_live_photo(photosdb_local):
"""test path_edited_live_photo (needs image from local library)"""
photo = photosdb_local.get_photo(UUID_DICT_LOCAL["live_edited"])
assert photo.path_edited_live_photo is not None
@pytest.mark.skipif(SKIP_TEST, reason="Skip if not running on author's local machine.")
def test_path_edited_live_photo_not_edited(photosdb_local):
"""test path_edited_live_photo for a live photo that's not edited (needs image from local library)"""
photo = photosdb_local.get_photo(UUID_DICT_LOCAL["live"])
assert photo.path_edited_live_photo is None
def test_is_reference(photosdb):
"""test isreference"""
@@ -1377,7 +1393,7 @@ def test_duplicates_2(photosdb):
def test_compound_query(photosdb):
""" test photos() with multiple query terms """
"""test photos() with multiple query terms"""
photos = photosdb.photos(persons=["Katie", "Maria"], albums=["Multi Keyword"])
assert len(photos) == 2
@@ -1386,21 +1402,21 @@ def test_compound_query(photosdb):
def test_multi_keyword(photosdb):
""" test photos() with multiple keywords """
"""test photos() with multiple keywords"""
photos = photosdb.photos(keywords=["Kids", "wedding"])
assert len(photos) == 6
def test_multi_album(photosdb):
""" test photos() with multiple albums """
"""test photos() with multiple albums"""
photos = photosdb.photos(albums=["Pumpkin Farm", "Test Album"])
assert len(photos) == 3
def test_multi_uuid(photosdb):
""" test photos() with multiple uuids """
"""test photos() with multiple uuids"""
photos = photosdb.photos(uuid=[UUID_DICT["favorite"], UUID_DICT["not_favorite"]])
assert len(photos) == 2

View File

@@ -134,6 +134,7 @@ CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
CLI_EXPORT_EDITED_SUFFIX_TEMPLATE = "{edited?_edited,}"
CLI_EXPORT_ORIGINAL_SUFFIX = "_original"
CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE = "{edited?_original,}"
CLI_EXPORT_PREVIEW_SUFFIX = "_lowres"
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"Pumkins1.jpg",
@@ -430,6 +431,8 @@ CLI_EXPORT_UUID_KEYWORD_PATHSEP = "7783E8E6-9CAC-40F3-BE22-81FB7051C266"
CLI_EXPORT_UUID_LONG_DESCRIPTION = "8846E3E6-8AC8-4857-8448-E3D025784410"
CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg"
CLI_EXPORT_UUID_FILENAME_PREVIEW = "Pumkins2_preview.jpeg"
CLI_EXPORT_UUID_FILENAME_PREVIEW_TEMPLATE = "Pumkins2_lowres.jpeg"
CLI_EXPORT_BY_DATE_TOUCH_UUID = [
"1EB2B765-0765-43BA-A90C-0D0580E6172C", # Pumpkins3.jpg
@@ -1242,6 +1245,66 @@ def test_export_uuid_from_file():
assert sorted(files) == sorted(CLI_EXPORT_UUID_FROM_FILE_FILENAMES)
def test_export_preview():
"""test export with --preview"""
import glob
import os
import os.path
import osxphotos
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--preview",
"--uuid",
CLI_EXPORT_UUID,
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert CLI_EXPORT_UUID_FILENAME_PREVIEW in files
def test_export_preview_suffix():
"""test export with --preview and --preview-suffix"""
import glob
import os
import os.path
import osxphotos
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--preview",
"--preview-suffix",
CLI_EXPORT_PREVIEW_SUFFIX,
"--uuid",
CLI_EXPORT_UUID,
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert CLI_EXPORT_UUID_FILENAME_PREVIEW_TEMPLATE in files
def test_export_as_hardlink():
import glob
import os