Refactored PhotoTemplate to support pathlib templates

This commit is contained in:
Rhet Turnbull
2021-06-13 09:17:30 -07:00
parent 1a46cdf63c
commit 2cdec3fc78
12 changed files with 435 additions and 280 deletions

View File

@@ -1574,6 +1574,20 @@ Substitution Description
/blob/master/examples/template_function.py for an /blob/master/examples/template_function.py for an
example of how to implement a template function. example of how to implement a template function.
The following substitutions are 'path-like'. You can access various parts of the
path using the following modifiers:
{field.parent}: the parent directory
{field.name}: the name of the file or final sub-directory
{field.stem}: the name of the file without the extension
{field.suffix}: the suffix of the file including the leading '.'
For example, if the field {export_dir} is '/Shared/Backup/Photos',
{export_dir.parent} is '/Shared/Backup'
Substitution Description
{export_dir} The full path to the export directory
``` ```
<!-- OSXPHOTOS-EXPORT-USAGE:END --> <!-- OSXPHOTOS-EXPORT-USAGE:END -->

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.42.31" __version__ = "0.42.32"

View File

@@ -50,9 +50,10 @@ from .fileutil import FileUtil, FileUtilNoOp
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoinfo import ExportResults from .photoinfo import ExportResults
from .photokit import check_photokit_authorization, request_photokit_authorization from .photokit import check_photokit_authorization, request_photokit_authorization
from .photosalbum import PhotosAlbum
from .phototemplate import RenderOptions
from .queryoptions import QueryOptions from .queryoptions import QueryOptions
from .utils import get_preferred_uti_extension from .utils import get_preferred_uti_extension
from .photosalbum import PhotosAlbum
# global variable to control verbose output # global variable to control verbose output
# set via --verbose/-V # set via --verbose/-V
@@ -60,7 +61,7 @@ VERBOSE = False
def verbose_(*args, **kwargs): def verbose_(*args, **kwargs):
""" print output if verbose flag set """ """print output if verbose flag set"""
if VERBOSE: if VERBOSE:
styled_args = [] styled_args = []
for arg in args: for arg in args:
@@ -462,7 +463,7 @@ def QUERY_OPTIONS(f):
help="Search for photos with possible duplicates. osxphotos will compare signatures of photos, " help="Search for photos with possible duplicates. osxphotos will compare signatures of photos, "
"evaluating date created, size, height, width, and edited status to find *possible* duplicates. " "evaluating date created, size, height, width, and edited status to find *possible* duplicates. "
"This does not compare images byte-for-byte nor compare hashes but should find photos imported multiple " "This does not compare images byte-for-byte nor compare hashes but should find photos imported multiple "
"times or duplicated within Photos." "times or duplicated within Photos.",
), ),
o( o(
"--min-size", "--min-size",
@@ -1697,13 +1698,18 @@ def export(
exiftool_merge_keywords=exiftool_merge_keywords, exiftool_merge_keywords=exiftool_merge_keywords,
finder_tag_template=finder_tag_template, finder_tag_template=finder_tag_template,
strip=strip, strip=strip,
export_dir=dest,
) )
results.xattr_written.extend(tags_written) results.xattr_written.extend(tags_written)
results.xattr_skipped.extend(tags_skipped) results.xattr_skipped.extend(tags_skipped)
if xattr_template: if xattr_template:
xattr_written, xattr_skipped = write_extended_attributes( xattr_written, xattr_skipped = write_extended_attributes(
p, photo_files, xattr_template, strip=strip p,
photo_files,
xattr_template,
strip=strip,
export_dir=dest,
) )
results.xattr_written.extend(xattr_written) results.xattr_written.extend(xattr_written)
results.xattr_skipped.extend(xattr_skipped) results.xattr_skipped.extend(xattr_skipped)
@@ -1777,7 +1783,7 @@ def export(
@click.argument("topic", default=None, required=False, nargs=1) @click.argument("topic", default=None, required=False, nargs=1)
@click.pass_context @click.pass_context
def help(ctx, topic, **kw): def help(ctx, topic, **kw):
""" Print help; for help on commands: help <command>. """ """Print help; for help on commands: help <command>."""
if topic is None: if topic is None:
click.echo(ctx.parent.get_help()) click.echo(ctx.parent.get_help())
elif topic in cli.commands: elif topic in cli.commands:
@@ -2062,7 +2068,7 @@ def query(
max_size=max_size, max_size=max_size,
query_eval=query_eval, query_eval=query_eval,
regex=regex, regex=regex,
duplicate=duplicate duplicate=duplicate,
) )
try: try:
@@ -2348,9 +2354,8 @@ def export_photo(
rendered_suffix = "" rendered_suffix = ""
if original_suffix: if original_suffix:
try: try:
rendered_suffix, unmatched = photo.render_template( options = RenderOptions(filename=True, strip=strip, export_dir=dest)
original_suffix, filename=True, strip=strip rendered_suffix, unmatched = photo.render_template(original_suffix, options)
)
except ValueError as e: except ValueError as e:
raise click.BadOptionUsage( raise click.BadOptionUsage(
"original_suffix", "original_suffix",
@@ -2477,8 +2482,13 @@ def export_photo(
if edited_suffix: if edited_suffix:
try: try:
options = RenderOptions(
filename=True,
strip=strip,
export_dir=dest,
)
rendered_suffix, unmatched = photo.render_template( rendered_suffix, unmatched = photo.render_template(
edited_suffix, filename=True, strip=strip edited_suffix, options
) )
except ValueError as e: except ValueError as e:
raise click.BadOptionUsage( raise click.BadOptionUsage(
@@ -2588,7 +2598,7 @@ def export_photo_with_template(
replace_keywords, replace_keywords,
retry, retry,
): ):
""" Evaluate directory template then export photo to each directory """ """Evaluate directory template then export photo to each directory"""
results = ExportResults() results = ExportResults()
@@ -2735,7 +2745,11 @@ def export_photo_with_template(
def get_filenames_from_template( def get_filenames_from_template(
photo, filename_template, original_name, strip=False, edited=False photo,
filename_template,
original_name,
strip=False,
edited=False,
): ):
"""get list of export filenames for a photo """get list of export filenames for a photo
@@ -2755,13 +2769,13 @@ def get_filenames_from_template(
if filename_template: if filename_template:
photo_ext = pathlib.Path(photo.original_filename).suffix photo_ext = pathlib.Path(photo.original_filename).suffix
try: try:
filenames, unmatched = photo.render_template( options = RenderOptions(
filename_template,
path_sep="_", path_sep="_",
filename=True, filename=True,
strip=strip, strip=strip,
edited=edited, edited_version=edited,
) )
filenames, unmatched = photo.render_template(filename_template, options)
except ValueError as e: except ValueError as e:
raise click.BadOptionUsage( raise click.BadOptionUsage(
"filename_template", f"Invalid template '{filename_template}': {e}" "filename_template", f"Invalid template '{filename_template}': {e}"
@@ -2815,9 +2829,8 @@ def get_dirnames_from_template(
elif directory: elif directory:
# got a directory template, render it and check results are valid # got a directory template, render it and check results are valid
try: try:
dirnames, unmatched = photo.render_template( options = RenderOptions(dirname=True, strip=strip, edited_version=edited)
directory, dirname=True, strip=strip, edited=edited dirnames, unmatched = photo.render_template(directory, options)
)
except ValueError as e: except ValueError as e:
raise click.BadOptionUsage( raise click.BadOptionUsage(
"directory", f"Invalid template '{directory}': {e}" "directory", f"Invalid template '{directory}': {e}"
@@ -3098,6 +3111,7 @@ def write_finder_tags(
exiftool_merge_keywords=None, exiftool_merge_keywords=None,
finder_tag_template=None, finder_tag_template=None,
strip=False, strip=False,
export_dir=None,
): ):
"""Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written """Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written
@@ -3110,6 +3124,7 @@ def write_finder_tags(
person_keyword: if True, use person in image as keywords person_keyword: if True, use person in image as keywords
exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords
finder_tag_template: list of templates to evaluate for determining Finder tags finder_tag_template: list of templates to evaluate for determining Finder tags
export_dir: value to use for {export_dir} template
Returns: Returns:
(list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating) (list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating)
@@ -3136,12 +3151,13 @@ def write_finder_tags(
rendered_tags = [] rendered_tags = []
for template_str in finder_tag_template: for template_str in finder_tag_template:
try: try:
rendered, unmatched = photo.render_template( options = RenderOptions(
template_str,
none_str=_OSXPHOTOS_NONE_SENTINEL, none_str=_OSXPHOTOS_NONE_SENTINEL,
path_sep="/", path_sep="/",
strip=strip, strip=strip,
export_dir=export_dir,
) )
rendered, unmatched = photo.render_template(template_str, options)
except ValueError as e: except ValueError as e:
raise click.BadOptionUsage( raise click.BadOptionUsage(
"finder_tag_template", "finder_tag_template",
@@ -3178,13 +3194,16 @@ def write_finder_tags(
return (written, skipped) return (written, skipped)
def write_extended_attributes(photo, files, xattr_template, strip=False): def write_extended_attributes(
""" Writes extended attributes to exported files photo, files, xattr_template, strip=False, export_dir=None
):
"""Writes extended attributes to exported files
Args: Args:
photo: a PhotoInfo object photo: a PhotoInfo object
xattr_template: list of tuples: (attribute name, attribute template) strip: xattr_template: list of tuples: (attribute name, attribute template)
export_dir: value to use for {export_dir} template
Returns: Returns:
tuple(list of file paths that were updated with new attributes, list of file paths skipped because attributes didn't need updating) tuple(list of file paths that were updated with new attributes, list of file paths skipped because attributes didn't need updating)
""" """
@@ -3192,12 +3211,13 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
attributes = {} attributes = {}
for xattr, template_str in xattr_template: for xattr, template_str in xattr_template:
try: try:
rendered, unmatched = photo.render_template( options = RenderOptions(
template_str,
none_str=_OSXPHOTOS_NONE_SENTINEL, none_str=_OSXPHOTOS_NONE_SENTINEL,
path_sep="/", path_sep="/",
strip=strip, strip=strip,
export_dir=export_dir,
) )
rendered, unmatched = photo.render_template(template_str, options)
except ValueError as e: except ValueError as e:
raise click.BadOptionUsage( raise click.BadOptionUsage(
"xattr_template", "xattr_template",
@@ -3266,7 +3286,7 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose): def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
""" Print out debug info """ """Print out debug info"""
global VERBOSE global VERBOSE
VERBOSE = bool(verbose) VERBOSE = bool(verbose)
@@ -3337,7 +3357,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def keywords(ctx, cli_obj, db, json_, photos_library): def keywords(ctx, cli_obj, db, json_, photos_library):
""" Print out keywords found in the Photos library. """ """Print out keywords found in the Photos library."""
# below needed for to make CliRunner work for testing # below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None cli_db = cli_obj.db if cli_obj is not None else None
@@ -3363,7 +3383,7 @@ def keywords(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def albums(ctx, cli_obj, db, json_, photos_library): def albums(ctx, cli_obj, db, json_, photos_library):
""" Print out albums found in the Photos library. """ """Print out albums found in the Photos library."""
# below needed for to make CliRunner work for testing # below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None cli_db = cli_obj.db if cli_obj is not None else None
@@ -3392,7 +3412,7 @@ def albums(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def persons(ctx, cli_obj, db, json_, photos_library): def persons(ctx, cli_obj, db, json_, photos_library):
""" Print out persons (faces) found in the Photos library. """ """Print out persons (faces) found in the Photos library."""
# below needed for to make CliRunner work for testing # below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None cli_db = cli_obj.db if cli_obj is not None else None
@@ -3418,7 +3438,7 @@ def persons(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def labels(ctx, cli_obj, db, json_, photos_library): def labels(ctx, cli_obj, db, json_, photos_library):
""" Print out image classification labels found in the Photos library. """ """Print out image classification labels found in the Photos library."""
# below needed for to make CliRunner work for testing # below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None cli_db = cli_obj.db if cli_obj is not None else None
@@ -3444,7 +3464,7 @@ def labels(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def info(ctx, cli_obj, db, json_, photos_library): def info(ctx, cli_obj, db, json_, photos_library):
""" Print out descriptive info of the Photos library database. """ """Print out descriptive info of the Photos library database."""
db = get_photos_db(*photos_library, db, cli_obj.db) db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None: if db is None:
@@ -3504,7 +3524,7 @@ def info(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def places(ctx, cli_obj, db, json_, photos_library): def places(ctx, cli_obj, db, json_, photos_library):
""" Print out places found in the Photos library. """ """Print out places found in the Photos library."""
# below needed for to make CliRunner work for testing # below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None cli_db = cli_obj.db if cli_obj is not None else None
@@ -3555,7 +3575,7 @@ def places(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library): def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
""" Print list of all photos & associated info from the Photos library. """ """Print list of all photos & associated info from the Photos library."""
db = get_photos_db(*photos_library, db, cli_obj.db) db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None: if db is None:
@@ -3586,7 +3606,7 @@ def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def list_libraries(ctx, cli_obj, json_): def list_libraries(ctx, cli_obj, json_):
""" Print list of Photos libraries found on the system. """ """Print list of Photos libraries found on the system."""
# implemented in _list_libraries so it can be called by other CLI functions # implemented in _list_libraries so it can be called by other CLI functions
# without errors due to passing ctx and cli_obj # without errors due to passing ctx and cli_obj
@@ -3633,7 +3653,7 @@ def _list_libraries(json_=False, error=True):
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def about(ctx, cli_obj): def about(ctx, cli_obj):
""" Print information about osxphotos including license. """ """Print information about osxphotos including license."""
license = """ license = """
MIT License MIT License

View File

@@ -16,12 +16,13 @@ from ._constants import (
from .phototemplate import ( from .phototemplate import (
TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
TEMPLATE_SUBSTITUTIONS_PATHLIB,
get_template_help, get_template_help,
) )
class ExportCommand(click.Command): class ExportCommand(click.Command):
""" Custom click.Command that overrides get_help() to show additional help info for export """ """Custom click.Command that overrides get_help() to show additional help info for export"""
def get_help(self, ctx): def get_help(self, ctx):
help_text = super().get_help(ctx) help_text = super().get_help(ctx)
@@ -65,7 +66,9 @@ class ExportCommand(click.Command):
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database." + f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
) )
formatter.write("\n\n") formatter.write("\n\n")
formatter.write(rich_text("[bold]** Extended Attributes **[/bold]", width=formatter.width)) formatter.write(
rich_text("[bold]** Extended Attributes **[/bold]", width=formatter.width)
)
formatter.write("\n") formatter.write("\n")
formatter.write_text( formatter.write_text(
""" """
@@ -99,7 +102,9 @@ The following attributes may be used with '--xattr-template':
"For additional information on extended attributes see: https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys" "For additional information on extended attributes see: https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys"
) )
formatter.write("\n\n") formatter.write("\n\n")
formatter.write(rich_text("[bold]** Templating System **[/bold]", width=formatter.width)) formatter.write(
rich_text("[bold]** Templating System **[/bold]", width=formatter.width)
)
formatter.write("\n") formatter.write("\n")
formatter.write(template_help(width=formatter.width)) formatter.write(template_help(width=formatter.width))
formatter.write("\n") formatter.write("\n")
@@ -128,7 +133,11 @@ The following attributes may be used with '--xattr-template':
+ "an error and the script will abort." + "an error and the script will abort."
) )
formatter.write("\n") formatter.write("\n")
formatter.write(rich_text("[bold]** Template Substitutions **[/bold]", width=formatter.width)) formatter.write(
rich_text(
"[bold]** Template Substitutions **[/bold]", width=formatter.width
)
)
formatter.write("\n") formatter.write("\n")
templ_tuples = [("Substitution", "Description")] templ_tuples = [("Substitution", "Description")]
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items()) templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items())
@@ -151,12 +160,36 @@ The following attributes may be used with '--xattr-template':
) )
formatter.write_dl(templ_tuples) formatter.write_dl(templ_tuples)
formatter.write("\n")
formatter.write_text(
"The following substitutions are 'path-like'. "
+ "You can access various parts of the path using the following modifiers:"
)
formatter.write("\n")
formatter.write("{field.parent}: the parent directory\n")
formatter.write("{field.name}: the name of the file or final sub-directory\n")
formatter.write("{field.stem}: the name of the file without the extension\n")
formatter.write(
"{field.suffix}: the suffix of the file including the leading '.'\n"
)
formatter.write("\n")
formatter.write_text(
"For example, if the field {export_dir} is '/Shared/Backup/Photos', "
+ "{export_dir.parent} is '/Shared/Backup'"
)
formatter.write("\n")
templ_tuples = [("Substitution", "Description")]
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS_PATHLIB.items())
formatter.write_dl(templ_tuples)
help_text += formatter.getvalue() help_text += formatter.getvalue()
return help_text return help_text
def template_help(width=78): def template_help(width=78):
"""Return formatted string for template system """ """Return formatted string for template system"""
sio = io.StringIO() sio = io.StringIO()
console = Console(file=sio, force_terminal=True, width=width) console = Console(file=sio, force_terminal=True, width=width)
template_help_md = strip_md_links(get_template_help()) template_help_md = strip_md_links(get_template_help())
@@ -178,14 +211,14 @@ def rich_text(text, width=78):
def strip_md_links(md): def strip_md_links(md):
"""strip markdown links from markdown text md """strip markdown links from markdown text md
Args: Args:
md: str, markdown text md: str, markdown text
Returns: Returns:
str with markdown links removed str with markdown links removed
Note: This uses a very basic regex that likely fails on all sorts of edge cases Note: This uses a very basic regex that likely fails on all sorts of edge cases
but works for the links in the osxphotos docs but works for the links in the osxphotos docs
""" """
links = r"(?:[*#])|\[(.*?)\]\(.+?\)" links = r"(?:[*#])|\[(.*?)\]\(.+?\)"
@@ -194,4 +227,3 @@ def strip_md_links(md):
return match.group(1) return match.group(1)
return re.sub(links, subfn, md) return re.sub(links, subfn, md)

View File

@@ -7,4 +7,4 @@ PhotosDB.photos() returns a list of PhotoInfo objects
from ._photoinfo_exifinfo import ExifInfo from ._photoinfo_exifinfo import ExifInfo
from ._photoinfo_export import ExportResults from ._photoinfo_export import ExportResults
from ._photoinfo_scoreinfo import ScoreInfo from ._photoinfo_scoreinfo import ScoreInfo
from .photoinfo import PhotoInfo from .photoinfo import PhotoInfo, PhotoInfoNone

View File

@@ -36,10 +36,10 @@ from .._constants import (
_UNKNOWN_PERSON, _UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME, _XMP_TEMPLATE_NAME,
_XMP_TEMPLATE_NAME_BETA, _XMP_TEMPLATE_NAME_BETA,
LIVE_VIDEO_EXTENSIONS,
SIDECAR_EXIFTOOL, SIDECAR_EXIFTOOL,
SIDECAR_JSON, SIDECAR_JSON,
SIDECAR_XMP, SIDECAR_XMP,
LIVE_VIDEO_EXTENSIONS,
) )
from .._version import __version__ from .._version import __version__
from ..datetime_utils import datetime_tz_to_utc from ..datetime_utils import datetime_tz_to_utc
@@ -52,6 +52,7 @@ from ..photokit import (
PhotoKitFetchFailed, PhotoKitFetchFailed,
PhotoLibrary, PhotoLibrary,
) )
from ..phototemplate import RenderOptions
from ..utils import findfiles, get_preferred_uti_extension, lineno, noop from ..utils import findfiles, get_preferred_uti_extension, lineno, noop
# retry if use_photos_export fails the first time (which sometimes it does) # retry if use_photos_export fails the first time (which sometimes it does)
@@ -390,6 +391,7 @@ def export(
use_persons_as_keywords=False, use_persons_as_keywords=False,
keyword_template=None, keyword_template=None,
description_template=None, description_template=None,
export_dir=None,
): ):
"""export photo """export photo
dest: must be valid destination path (or exception raised) dest: must be valid destination path (or exception raised)
@@ -427,6 +429,7 @@ def export(
when exporting metadata with exiftool or sidecar when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description description_template: string; optional template string that will be rendered for use as photo description
export_dir: value to use for {export_dir} template
Returns: list of photos exported Returns: list of photos exported
""" """
@@ -458,6 +461,7 @@ def export(
use_persons_as_keywords=use_persons_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template, keyword_template=keyword_template,
description_template=description_template, description_template=description_template,
export_dir=export_dir,
) )
return results.exported return results.exported
@@ -500,6 +504,7 @@ def export2(
persons=True, persons=True,
location=True, location=True,
replace_keywords=False, replace_keywords=False,
export_dir=None,
): ):
"""export photo, like export but with update and dry_run options """export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised dest: must be valid destination path or exception raised
@@ -555,6 +560,7 @@ def export2(
persons: if True, include persons in exported metadata persons: if True, include persons in exported metadata
location: if True, include location in exported metadata location: if True, include location in exported metadata
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
export_dir: value to use for {export_dir} template
Returns: ExportResults class Returns: ExportResults class
ExportResults has attributes: ExportResults has attributes:
@@ -1161,7 +1167,10 @@ def _export_photo_with_photos_export(
else: else:
try: try:
exported = photo.export( exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT, overwrite=overwrite dest.parent,
dest.name,
version=PHOTOS_VERSION_CURRENT,
overwrite=overwrite,
) )
all_results.exported.extend(exported) all_results.exported.extend(exported)
except Exception as e: except Exception as e:
@@ -1205,7 +1214,10 @@ def _export_photo_with_photos_export(
if not dry_run: if not dry_run:
try: try:
exported = photo.export( exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL, overwrite=overwrite dest.parent,
dest.name,
version=PHOTOS_VERSION_ORIGINAL,
overwrite=overwrite,
) )
all_results.exported.extend(exported) all_results.exported.extend(exported)
except Exception as e: except Exception as e:
@@ -1586,9 +1598,8 @@ def _exiftool_dict(
) )
if description_template is not None: if description_template is not None:
rendered = self.render_template( options = RenderOptions(expand_inplace=True, inplace_sep=", ")
description_template, expand_inplace=True, inplace_sep=", " rendered = self.render_template(description_template, options)[0]
)[0]
description = " ".join(rendered) if rendered else "" description = " ".join(rendered) if rendered else ""
exif["EXIF:ImageDescription"] = description exif["EXIF:ImageDescription"] = description
exif["XMP:Description"] = description exif["XMP:Description"] = description
@@ -1626,10 +1637,9 @@ def _exiftool_dict(
if keyword_template: if keyword_template:
rendered_keywords = [] rendered_keywords = []
options = RenderOptions(none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
for template_str in keyword_template: for template_str in keyword_template:
rendered, unmatched = self.render_template( rendered, unmatched = self.render_template(template_str, options)
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
if unmatched: if unmatched:
logging.warning( logging.warning(
f"Unmatched template substitution for template: {template_str} {unmatched}" f"Unmatched template substitution for template: {template_str} {unmatched}"
@@ -1905,9 +1915,8 @@ def _xmp_sidecar(
extension = extension.suffix[1:] if extension.suffix else None extension = extension.suffix[1:] if extension.suffix else None
if description_template is not None: if description_template is not None:
rendered = self.render_template( options = RenderOptions(expand_inplace=True, inplace_sep=", ")
description_template, expand_inplace=True, inplace_sep=", " rendered = self.render_template(description_template, options)[0]
)[0]
description = " ".join(rendered) if rendered else "" description = " ".join(rendered) if rendered else ""
else: else:
description = self.description if self.description is not None else "" description = self.description if self.description is not None else ""
@@ -1939,10 +1948,9 @@ def _xmp_sidecar(
if keyword_template: if keyword_template:
rendered_keywords = [] rendered_keywords = []
options = RenderOptions(none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
for template_str in keyword_template: for template_str in keyword_template:
rendered, unmatched = self.render_template( rendered, unmatched = self.render_template(template_str, options)
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
if unmatched: if unmatched:
logging.warning( logging.warning(
f"Unmatched template substitution for template: {template_str} {unmatched}" f"Unmatched template substitution for template: {template_str} {unmatched}"

View File

@@ -3,7 +3,6 @@ PhotoInfo class
Represents a single photo in the Photos library and provides access to the photo's attributes Represents a single photo in the Photos library and provides access to the photo's attributes
PhotosDB.photos() returns a list of PhotoInfo objects PhotosDB.photos() returns a list of PhotoInfo objects
""" """
import dataclasses import dataclasses
import datetime import datetime
import json import json
@@ -12,6 +11,7 @@ import os
import os.path import os.path
import pathlib import pathlib
from datetime import timedelta, timezone from datetime import timedelta, timezone
from typing import Optional
import yaml import yaml
@@ -34,7 +34,7 @@ from .._constants import (
from ..adjustmentsinfo import AdjustmentsInfo from ..adjustmentsinfo import AdjustmentsInfo
from ..albuminfo import AlbumInfo, ImportInfo from ..albuminfo import AlbumInfo, ImportInfo
from ..personinfo import FaceInfo, PersonInfo from ..personinfo import FaceInfo, PersonInfo
from ..phototemplate import PhotoTemplate from ..phototemplate import PhotoTemplate, RenderOptions
from ..placeinfo import PlaceInfo4, PlaceInfo5 from ..placeinfo import PlaceInfo4, PlaceInfo5
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
@@ -46,31 +46,31 @@ class PhotoInfo:
""" """
# import additional methods # import additional methods
from ._photoinfo_searchinfo import ( from ._photoinfo_comments import comments, likes
search_info, from ._photoinfo_exifinfo import ExifInfo, exif_info
search_info_normalized,
labels,
labels_normalized,
SearchInfo,
)
from ._photoinfo_exifinfo import exif_info, ExifInfo
from ._photoinfo_exiftool import exiftool from ._photoinfo_exiftool import exiftool
from ._photoinfo_export import ( from ._photoinfo_export import (
export, ExportResults,
export2,
_export_photo,
_exiftool_dict, _exiftool_dict,
_exiftool_json_sidecar, _exiftool_json_sidecar,
_export_photo,
_export_photo_with_photos_export, _export_photo_with_photos_export,
_get_exif_keywords, _get_exif_keywords,
_get_exif_persons, _get_exif_persons,
_write_exif_data, _write_exif_data,
_write_sidecar, _write_sidecar,
_xmp_sidecar, _xmp_sidecar,
ExportResults, export,
export2,
)
from ._photoinfo_scoreinfo import ScoreInfo, score
from ._photoinfo_searchinfo import (
SearchInfo,
labels,
labels_normalized,
search_info,
search_info_normalized,
) )
from ._photoinfo_scoreinfo import score, ScoreInfo
from ._photoinfo_comments import comments, likes
def __init__(self, db=None, uuid=None, info=None): def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid self._uuid = uuid
@@ -1015,48 +1015,20 @@ class PhotoInfo:
return duplicates return duplicates
def render_template( def render_template(
self, self, template_str: str, options: Optional[RenderOptions] = None
template_str,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
strip=False,
edited=False,
): ):
"""Renders a template string for PhotoInfo instance using PhotoTemplate """Renders a template string for PhotoInfo instance using PhotoTemplate
Args: Args:
template_str: a template string with fields to render template_str: a template string with fields to render
none_str: a str to use if template field renders to None, default is "_". options: a RenderOptions instance
path_sep: a single character str to use as path separator when joining
fields like folder_album; if not provided, defaults to os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
strip: if True, strips leading/trailing white space from resulting template
edited: if True, sets {edited_version} field to True, otherwise it gets set to False; set if you want template evaluated for edited version
Returns: Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values ([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
""" """
options = options or RenderOptions()
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path) template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
return template.render( return template.render(template_str, options)
template_str,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
strip=strip,
edited_version=edited,
)
@property @property
def _longitude(self): def _longitude(self):
@@ -1269,3 +1241,13 @@ class PhotoInfo:
def __hash__(self): def __hash__(self):
"""Make PhotoInfo hashable""" """Make PhotoInfo hashable"""
return hash(self.uuid) return hash(self.uuid)
class PhotoInfoNone:
"""mock class that returns None for all attributes"""
def __init__(self):
pass
def __getattribute__(self, name):
return None

View File

@@ -44,6 +44,7 @@ from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..fileutil import FileUtil from ..fileutil import FileUtil
from ..personinfo import PersonInfo from ..personinfo import PersonInfo
from ..photoinfo import PhotoInfo from ..photoinfo import PhotoInfo
from ..phototemplate import RenderOptions
from ..queryoptions import QueryOptions from ..queryoptions import QueryOptions
from ..utils import ( from ..utils import (
_check_file_exists, _check_file_exists,
@@ -3207,11 +3208,12 @@ class PhotosDB:
if options.regex: if options.regex:
flags = re.IGNORECASE if options.ignore_case else 0 flags = re.IGNORECASE if options.ignore_case else 0
render_options = RenderOptions(none_str="")
for regex, template in options.regex: for regex, template in options.regex:
regex = re.compile(regex, flags) regex = re.compile(regex, flags)
photo_list = [] photo_list = []
for p in photos: for p in photos:
rendered, _ = p.render_template(template, none_str="") rendered, _ = p.render_template(template, render_options)
for value in rendered: for value in rendered:
if regex.search(value): if regex.search(value):
photo_list.append(p) photo_list.append(p)

View File

@@ -14,6 +14,10 @@ from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifToolCaching from .exiftool import ExifToolCaching
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
from .utils import load_function from .utils import load_function
from dataclasses import dataclass
from typing import Optional
# TODO: a lot of values are passed from function to function like path_sep--make these all class properties
# ensure locale set to user's locale # ensure locale set to user's locale
locale.setlocale(locale.LC_ALL, "") locale.setlocale(locale.LC_ALL, "")
@@ -137,7 +141,11 @@ TEMPLATE_SUBSTITUTIONS = {
"{cr}": r"A carriage return: '\r'", "{cr}": r"A carriage return: '\r'",
"{crlf}": r"a carriage return + line feed: '\r\n'", "{crlf}": r"a carriage return + line feed: '\r\n'",
"{osxphotos_version}": f"The osxphotos version, e.g. '{__version__}'", "{osxphotos_version}": f"The osxphotos version, e.g. '{__version__}'",
"{osxphotos_cmd_line}": "The full command line used to run osxphotos" "{osxphotos_cmd_line}": "The full command line used to run osxphotos",
}
TEMPLATE_SUBSTITUTIONS_PATHLIB = {
"{export_dir}": "The full path to the export directory",
} }
# Permitted multi-value substitutions (each of these returns None or 1 or more values) # Permitted multi-value substitutions (each of these returns None or 1 or more values)
@@ -179,7 +187,7 @@ FILTER_VALUES = {
"braces": "Enclose value in curly braces, e.g. 'value => '{value}'.", "braces": "Enclose value in curly braces, e.g. 'value => '{value}'.",
"parens": "Enclose value in parentheses, e.g. 'value' => '(value')", "parens": "Enclose value in parentheses, e.g. 'value' => '(value')",
"brackets": "Enclose value in brackets, e.g. 'value' => '[value]'", "brackets": "Enclose value in brackets, e.g. 'value' => '[value]'",
"function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py" "function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py",
} }
# Just the substitutions without the braces # Just the substitutions without the braces
@@ -187,13 +195,18 @@ SINGLE_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS
] ]
# Just the multi-valued substitution names without the braces PATHLIB_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS_PATHLIB
]
MULTI_VALUE_SUBSTITUTIONS = [ MULTI_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "") field.replace("{", "").replace("}", "")
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
] ]
FIELD_NAMES = SINGLE_VALUE_SUBSTITUTIONS + MULTI_VALUE_SUBSTITUTIONS FIELD_NAMES = (
SINGLE_VALUE_SUBSTITUTIONS + MULTI_VALUE_SUBSTITUTIONS + PATHLIB_SUBSTITUTIONS
)
# default values for string manipulation template options # default values for string manipulation template options
INPLACE_DEFAULT = "," INPLACE_DEFAULT = ","
@@ -217,20 +230,51 @@ PUNCTUATION = {
} }
@dataclass
class RenderOptions:
"""Options for PhotoTemplate.render
template: str template
none_str: str to use default for None values, default is '_'
path_sep: optional string to use as path separator, default is os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
strip: if True, strips leading/trailing whitespace from rendered templates
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
export_dir: set to the export directory if you want to evalute {export_dir} template
filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template
"""
none_str: str = "_"
path_sep: Optional[str] = PATH_SEP_DEFAULT
expand_inplace: bool = False
inplace_sep: Optional[str] = INPLACE_DEFAULT
filename: bool = False
dirname: bool = False
strip: bool = False
edited_version: bool = False
export_dir: Optional[str] = None
filepath: Optional[str] = None
class PhotoTemplateParser: class PhotoTemplateParser:
"""Parser for PhotoTemplate """ """Parser for PhotoTemplate"""
# implemented as Singleton # implemented as Singleton
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
""" create new object or return instance of already created singleton """ """create new object or return instance of already created singleton"""
if not hasattr(cls, "instance") or not cls.instance: if not hasattr(cls, "instance") or not cls.instance:
cls.instance = super().__new__(cls) cls.instance = super().__new__(cls)
return cls.instance return cls.instance
def __init__(self): def __init__(self):
""" return existing singleton or create a new one """ """return existing singleton or create a new one"""
if hasattr(self, "metamodel"): if hasattr(self, "metamodel"):
return return
@@ -238,15 +282,15 @@ class PhotoTemplateParser:
self.metamodel = metamodel_from_file(OTL_GRAMMAR_MODEL, skipws=False) self.metamodel = metamodel_from_file(OTL_GRAMMAR_MODEL, skipws=False)
def parse(self, template_statement): def parse(self, template_statement):
"""Parse a template_statement string """ """Parse a template_statement string"""
return self.metamodel.model_from_str(template_statement) return self.metamodel.model_from_str(template_statement)
class PhotoTemplate: class PhotoTemplate:
""" PhotoTemplate class to render a template string from a PhotoInfo object """ """PhotoTemplate class to render a template string from a PhotoInfo object"""
def __init__(self, photo, exiftool_path=None): def __init__(self, photo, exiftool_path=None):
""" Inits PhotoTemplate class with photo """Inits PhotoTemplate class with photo
Args: Args:
photo: a PhotoInfo instance. photo: a PhotoInfo instance.
@@ -262,49 +306,49 @@ class PhotoTemplate:
# get parser singleton # get parser singleton
self.parser = PhotoTemplateParser() self.parser = PhotoTemplateParser()
# should {edited_version} render True? # initialize render options
self.edited_version = False # this will be done in render() but for testing, some of the lookup functions are called directly
options = RenderOptions()
self.path_sep = options.path_sep
self.inplace_sep = options.inplace_sep
self.edited_version = options.edited_version
self.none_str = options.none_str
self.expand_inplace = options.expand_inplace
self.filename = options.filename
self.dirname = options.dirname
self.strip = options.strip
self.export_dir = options.export_dir
self.filepath = options.filepath
def render( def render(
self, self,
template, template: str,
none_str="_", options: RenderOptions,
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
strip=False,
edited_version=False,
): ):
""" Render a filename or directory template """Render a filename or directory template
Args: Args:
template: str template template: str template
none_str: str to use default for None values, default is '_' options: a RenderOptions instance
path_sep: optional string to use as path separator, default is os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
strip: if True, strips leading/trailing whitespace from rendered templates
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
Returns: Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values ([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
""" """
if path_sep is None:
path_sep = PATH_SEP_DEFAULT
if inplace_sep is None:
inplace_sep = INPLACE_DEFAULT
if type(template) is not str: if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}") raise TypeError(f"template must be type str, not {type(template)}")
self.path_sep = options.path_sep
self.inplace_sep = options.inplace_sep
self.edited_version = options.edited_version
self.none_str = options.none_str
self.expand_inplace = options.expand_inplace
self.filename = options.filename
self.dirname = options.dirname
self.strip = options.strip
self.export_dir = options.export_dir
self.filepath = options.filepath
try: try:
model = self.parser.parse(template) model = self.parser.parse(template)
except TextXSyntaxError as e: except TextXSyntaxError as e:
@@ -314,53 +358,29 @@ class PhotoTemplate:
# empty string # empty string
return [], [] return [], []
self.edited_version = edited_version return self._render_statement(model)
return self._render_statement(
model,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
strip=strip,
)
def _render_statement( def _render_statement(
self, self,
statement, statement,
none_str="_",
path_sep=None, path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
strip=False,
): ):
path_sep = path_sep or self.path_sep
results = [] results = []
unmatched = [] unmatched = []
for ts in statement.template_strings: for ts in statement.template_strings:
results, unmatched = self._render_template_string( results, unmatched = self._render_template_string(
ts, ts, results=results, unmatched=unmatched, path_sep=path_sep
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
results=results,
unmatched=unmatched,
) )
rendered_strings = results rendered_strings = results
if filename: if self.filename:
rendered_strings = [ rendered_strings = [
sanitize_filename(rendered_str) for rendered_str in rendered_strings sanitize_filename(rendered_str) for rendered_str in rendered_strings
] ]
if strip: if self.strip:
rendered_strings = [ rendered_strings = [
rendered_str.strip() for rendered_str in rendered_strings rendered_str.strip() for rendered_str in rendered_strings
] ]
@@ -370,16 +390,11 @@ class PhotoTemplate:
def _render_template_string( def _render_template_string(
self, self,
ts, ts,
none_str="_", path_sep,
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
results=None, results=None,
unmatched=None, unmatched=None,
): ):
"""Render a TemplateString object """ """Render a TemplateString object"""
results = results or [""] results = results or [""]
unmatched = unmatched or [] unmatched = unmatched or []
@@ -387,7 +402,8 @@ class PhotoTemplate:
if ts.template: if ts.template:
# have a template field to process # have a template field to process
field = ts.template.field field = ts.template.field
if field not in FIELD_NAMES and not field.startswith("photo"): field_part = field.split(".")[0]
if field not in FIELD_NAMES and field_part not in FIELD_NAMES:
unmatched.append(field) unmatched.append(field)
return [], unmatched return [], unmatched
@@ -414,12 +430,7 @@ class PhotoTemplate:
if ts.template.bool.value is not None: if ts.template.bool.value is not None:
bool_val, u = self._render_statement( bool_val, u = self._render_statement(
ts.template.bool.value, ts.template.bool.value,
none_str=none_str,
path_sep=path_sep, path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
) )
unmatched.extend(u) unmatched.extend(u)
else: else:
@@ -435,12 +446,7 @@ class PhotoTemplate:
if ts.template.default.value is not None: if ts.template.default.value is not None:
default, u = self._render_statement( default, u = self._render_statement(
ts.template.default.value, ts.template.default.value,
none_str=none_str,
path_sep=path_sep, path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
) )
unmatched.extend(u) unmatched.extend(u)
else: else:
@@ -457,12 +463,7 @@ class PhotoTemplate:
# conditional value is also a TemplateString # conditional value is also a TemplateString
conditional_value, u = self._render_statement( conditional_value, u = self._render_statement(
ts.template.conditional.value, ts.template.conditional.value,
none_str=none_str,
path_sep=path_sep, path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
) )
unmatched.extend(u) unmatched.extend(u)
else: else:
@@ -478,10 +479,8 @@ class PhotoTemplate:
vals = self.get_template_value( vals = self.get_template_value(
field, field,
default=default, default=default,
delim=delim or inplace_sep, # delim=delim or self.inplace_sep,
path_sep=path_sep, # path_sep=path_sep,
filename=filename,
dirname=dirname,
) )
elif field == "exiftool": elif field == "exiftool":
if subfield is None: if subfield is None:
@@ -489,7 +488,7 @@ class PhotoTemplate:
"SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'" "SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'"
) )
vals = self.get_template_value_exiftool( vals = self.get_template_value_exiftool(
subfield, filename=filename, dirname=dirname subfield,
) )
elif field == "function": elif field == "function":
if subfield is None: if subfield is None:
@@ -497,11 +496,16 @@ class PhotoTemplate:
"SyntaxError: filename and function must not be null with {function::filename.py:function_name}" "SyntaxError: filename and function must not be null with {function::filename.py:function_name}"
) )
vals = self.get_template_value_function( vals = self.get_template_value_function(
subfield, filename=filename, dirname=dirname subfield,
) )
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"): elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
vals = self.get_template_value_multi( vals = self.get_template_value_multi(
field, path_sep=path_sep, filename=filename, dirname=dirname field,
path_sep=path_sep,
)
elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS:
vals = self.get_template_value_pathlib(
field,
) )
else: else:
unmatched.append(field) unmatched.append(field)
@@ -509,8 +513,8 @@ class PhotoTemplate:
vals = [val for val in vals if val is not None] vals = [val for val in vals if val is not None]
if expand_inplace or delim is not None: if self.expand_inplace or delim is not None:
sep = delim if delim is not None else inplace_sep sep = delim if delim is not None else self.inplace_sep
vals = [sep.join(sorted(vals))] vals = [sep.join(sorted(vals))]
for filter_ in filters: for filter_ in filters:
@@ -531,7 +535,7 @@ class PhotoTemplate:
# have a conditional operator # have a conditional operator
def string_test(test_function): def string_test(test_function):
""" Perform string comparison using test_function; closure to capture conditional_value, vals, negation """ """Perform string comparison using test_function; closure to capture conditional_value, vals, negation"""
match = False match = False
for c in conditional_value: for c in conditional_value:
for v in vals: for v in vals:
@@ -546,7 +550,7 @@ class PhotoTemplate:
return [] return []
def comparison_test(test_function): def comparison_test(test_function):
""" Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation """ """Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation"""
if len(vals) != 1 or len(conditional_value) != 1: if len(vals) != 1 or len(conditional_value) != 1:
raise ValueError( raise ValueError(
f"comparison operators may only be used with a single value: {vals} {conditional_value}" f"comparison operators may only be used with a single value: {vals} {conditional_value}"
@@ -607,7 +611,7 @@ class PhotoTemplate:
if is_bool: if is_bool:
vals = default if not vals else bool_val vals = default if not vals else bool_val
elif not vals: elif not vals:
vals = default or [none_str] vals = default or [self.none_str]
pre = ts.pre or "" pre = ts.pre or ""
post = ts.post or "" post = ts.post or ""
@@ -632,29 +636,29 @@ class PhotoTemplate:
self, self,
field, field,
default, default,
bool_val=None, # bool_val=None,
delim=None, # delim=None,
path_sep=None, # path_sep=None,
filename=False,
dirname=False,
): ):
"""lookup value for template field (single-value template substitutions) """lookup value for template field (single-value template substitutions)
Args: Args:
field: template field to find value for. field: template field to find value for.
default: the default value provided by the user default: the default value provided by the user
bool_val: True value if expression is boolean bool_val: True value if expression is boolean
delim: delimiter for expand in place delim: delimiter for expand in place
path_sep: path separator for fields that are path-like path_sep: path separator for fields that are path-like
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
Returns: Returns:
The matching template value (which may be None). The matching template value (which may be None).
Raises: Raises:
ValueError if no rule exists for field. ValueError if no rule exists for field.
""" """
if self.photo.uuid is None:
return []
if field not in FIELD_NAMES: if field not in FIELD_NAMES:
raise ValueError(f"SyntaxError: Unknown field: {field}") raise ValueError(f"SyntaxError: Unknown field: {field}")
@@ -920,9 +924,36 @@ class PhotoTemplate:
# if here, didn't get a match # if here, didn't get a match
raise ValueError(f"Unhandled template value: {field}") raise ValueError(f"Unhandled template value: {field}")
if filename: if self.filename:
value = sanitize_pathpart(value) value = sanitize_pathpart(value)
elif dirname: elif self.dirname:
value = sanitize_dirname(value)
return [value]
def get_template_value_pathlib(
self,
field,
):
"""lookup value for template pathlib template fields
Args:
field: template field to find value for.
Returns:
The matching template value (which may be None).
Raises:
ValueError if no rule exists for field.
"""
if field.split(".")[0] not in PATHLIB_SUBSTITUTIONS:
raise ValueError(f"SyntaxError: Unknown field: {field}")
value = _get_pathlib_value(field, self.export_dir)
if self.filename:
value = sanitize_pathpart(value)
elif self.dirname:
value = sanitize_dirname(value) value = sanitize_dirname(value)
return [value] return [value]
@@ -974,14 +1005,17 @@ class PhotoTemplate:
value = [] value = []
return value return value
def get_template_value_multi(self, field, path_sep, filename=False, dirname=False): def get_template_value_multi(
self,
field,
path_sep,
):
"""lookup value for template field (multi-value template substitutions) """lookup value for template field (multi-value template substitutions)
Args: Args:
field: template field to find value for. field: template field to find value for.
path_sep: path separator to use for folder_album field path_sep: path separator to use for folder_album field
dirname: if True, values will be sanitized to be valid directory names; default = False
Returns: Returns:
List of the matching template values or []. List of the matching template values or [].
@@ -990,6 +1024,10 @@ class PhotoTemplate:
""" """
""" return list of values for a multi-valued template field """ """ return list of values for a multi-valued template field """
if self.photo.uuid is None:
return []
values = [] values = []
if field == "album": if field == "album":
values = self.photo.burst_albums if self.photo.burst else self.photo.albums values = self.photo.burst_albums if self.photo.burst else self.photo.albums
@@ -1013,7 +1051,7 @@ class PhotoTemplate:
for album in album_info: for album in album_info:
if album.folder_names: if album.folder_names:
# album in folder # album in folder
if dirname: if self.dirname:
# being used as a filepath so sanitize each part # being used as a filepath so sanitize each part
folder = path_sep.join( folder = path_sep.join(
sanitize_dirname(f) for f in album.folder_names sanitize_dirname(f) for f in album.folder_names
@@ -1025,7 +1063,7 @@ class PhotoTemplate:
values.append(folder) values.append(folder)
else: else:
# album not in folder # album not in folder
if dirname: if self.dirname:
values.append(sanitize_dirname(album.title)) values.append(sanitize_dirname(album.title))
else: else:
values.append(album.title) values.append(album.title)
@@ -1073,9 +1111,9 @@ class PhotoTemplate:
raise ValueError(f"Unhandled template value: {field}") raise ValueError(f"Unhandled template value: {field}")
# sanitize directory names if needed, folder_album handled differently above # sanitize directory names if needed, folder_album handled differently above
if filename: if self.filename:
values = [sanitize_pathpart(value) for value in values] values = [sanitize_pathpart(value) for value in values]
elif dirname and field != "folder_album": elif self.dirname and field != "folder_album":
# skip folder_album because it would have been handled above # skip folder_album because it would have been handled above
values = [sanitize_dirname(value) for value in values] values = [sanitize_dirname(value) for value in values]
@@ -1083,9 +1121,15 @@ class PhotoTemplate:
values = values or [] values = values or []
return values return values
def get_template_value_exiftool(self, subfield, filename=None, dirname=None): def get_template_value_exiftool(
self,
subfield,
):
"""Get template value for format "{exiftool:EXIF:Model}" """ """Get template value for format "{exiftool:EXIF:Model}" """
if self.photo is None:
return []
if not self.photo.path: if not self.photo.path:
return [] return []
@@ -1098,17 +1142,20 @@ class PhotoTemplate:
values = [str(v) for v in values] values = [str(v) for v in values]
# sanitize directory names if needed # sanitize directory names if needed
if filename: if self.filename:
values = [sanitize_pathpart(value) for value in values] values = [sanitize_pathpart(value) for value in values]
elif dirname: elif self.dirname:
values = [sanitize_dirname(value) for value in values] values = [sanitize_dirname(value) for value in values]
else: else:
values = [] values = []
return values return values
def get_template_value_function(self, subfield, filename=None, dirname=None): def get_template_value_function(
"""Get template value from external function """ self,
subfield,
):
"""Get template value from external function"""
if "::" not in subfield: if "::" not in subfield:
raise ValueError( raise ValueError(
@@ -1131,17 +1178,17 @@ class PhotoTemplate:
values = [values] values = [values]
# sanitize directory names if needed # sanitize directory names if needed
if filename: if self.filename:
values = [sanitize_pathpart(value) for value in values] values = [sanitize_pathpart(value) for value in values]
elif dirname: elif self.dirname:
values = [sanitize_dirname(value) for value in values] values = [sanitize_dirname(value) for value in values]
return values return values
def get_template_value_filter_function(self, filter_, values): def get_template_value_filter_function(self, filter_, values):
"""Filter template value from external function """ """Filter template value from external function"""
filter_ = filter_.replace("function:","") filter_ = filter_.replace("function:", "")
if "::" not in filter_: if "::" not in filter_:
raise ValueError( raise ValueError(
@@ -1166,9 +1213,8 @@ class PhotoTemplate:
return values return values
def get_photo_video_type(self, default): def get_photo_video_type(self, default):
""" return media type, e.g. photo or video """ """return media type, e.g. photo or video"""
default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS) default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS)
if self.photo.isphoto: if self.photo.isphoto:
return default_dict["photo"] return default_dict["photo"]
@@ -1176,7 +1222,7 @@ class PhotoTemplate:
return default_dict["video"] return default_dict["video"]
def get_media_type(self, default): def get_media_type(self, default):
""" return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type """ """return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type"""
default_dict = parse_default_kv(default, MEDIA_TYPE_DEFAULTS) default_dict = parse_default_kv(default, MEDIA_TYPE_DEFAULTS)
p = self.photo p = self.photo
if p.selfie: if p.selfie:
@@ -1210,7 +1256,7 @@ class PhotoTemplate:
def parse_default_kv(default, default_dict): def parse_default_kv(default, default_dict):
""" parse a string in form key1=value1;key2=value2,... as used for some template fields """parse a string in form key1=value1;key2=value2,... as used for some template fields
Args: Args:
default: str, in form 'photo=foto;video=vidéo' default: str, in form 'photo=foto;video=vidéo'
@@ -1235,9 +1281,29 @@ def parse_default_kv(default, default_dict):
def get_template_help(): def get_template_help():
"""Return help for template system as markdown string """ """Return help for template system as markdown string"""
# TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this # TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this
help_file = pathlib.Path(__file__).parent / "phototemplate.md" help_file = pathlib.Path(__file__).parent / "phototemplate.md"
with open(help_file, "r") as fd: with open(help_file, "r") as fd:
md = fd.read() md = fd.read()
return md return md
def _get_pathlib_value(field, value):
"""Get the value for a pathlib.Path type template"""
parts = field.split(".")
if len(parts) == 1:
return value
if len(parts) > 2:
raise ValueError(f"Illegal value for path template: {field}")
path = parts[0]
attribute = parts[1]
path = pathlib.Path(value)
try:
val = getattr(path, attribute)
return str(val)
except AttributeError:
raise ValueError("Illegal value for path template: {attribute}")

View File

@@ -8,6 +8,7 @@ class PhotoInfoMock(PhotoInfo):
self._photo = photo self._photo = photo
self._db = photo._db self._db = photo._db
self._info = photo._info self._info = photo._info
self._uuid = photo.uuid
for kw in kwargs: for kw in kwargs:
if hasattr(photo, kw): if hasattr(photo, kw):

View File

@@ -1,9 +1,18 @@
""" Test template.py """ """ Test template.py """
import os
import re
import pytest import pytest
from photoinfo_mock import PhotoInfoMock
import osxphotos import osxphotos
from osxphotos.exiftool import get_exiftool_path from osxphotos.exiftool import get_exiftool_path
from osxphotos.phototemplate import (
from photoinfo_mock import PhotoInfoMock TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
PhotoTemplate,
RenderOptions,
)
try: try:
exiftool = get_exiftool_path() exiftool = get_exiftool_path()
@@ -357,8 +366,6 @@ def photosdb_cloud():
def test_lookup(photosdb_places): def test_lookup(photosdb_places):
"""Test that a lookup is returned for every possible value""" """Test that a lookup is returned for every possible value"""
import re
from osxphotos.phototemplate import TEMPLATE_SUBSTITUTIONS, PhotoTemplate
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = PhotoTemplate(photo) template = PhotoTemplate(photo)
@@ -371,13 +378,6 @@ def test_lookup(photosdb_places):
def test_lookup_multi(photosdb_places): def test_lookup_multi(photosdb_places):
"""Test that a lookup is returned for every possible value""" """Test that a lookup is returned for every possible value"""
import os
import re
from osxphotos.phototemplate import (
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
PhotoTemplate,
)
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = PhotoTemplate(photo) template = PhotoTemplate(photo)
@@ -464,7 +464,6 @@ def test_subst_locale_2(photosdb_places):
def test_subst_default_val(photosdb_places): def test_subst_default_val(photosdb_places):
"""Test substitution with default value specified""" """Test substitution with default value specified"""
import locale import locale
import osxphotos
locale.setlocale(locale.LC_ALL, "en_US") locale.setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
@@ -526,8 +525,6 @@ def test_subst_unknown_val_with_default(photosdb_places):
def test_subst_multi_1_1_2(photosdb): def test_subst_multi_1_1_2(photosdb):
"""Test that substitutions are correct""" """Test that substitutions are correct"""
# one album, one keyword, two persons # one album, one keyword, two persons
import osxphotos
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
template = "{created.year}/{album}/{keyword}/{person}" template = "{created.year}/{album}/{keyword}/{person}"
@@ -608,7 +605,6 @@ def test_subst_multi_0_2_0_default_val(photosdb):
def test_subst_multi_0_2_0_default_val_unknown_val(photosdb): def test_subst_multi_0_2_0_default_val_unknown_val(photosdb):
"""Test that substitutions are correct""" """Test that substitutions are correct"""
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template # 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
import osxphotos
# one album, one keyword, two persons # one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
@@ -726,7 +722,6 @@ def test_subst_multi_folder_albums_3(photosdb_14_6):
def test_subst_multi_folder_albums_3_path_sep(photosdb_14_6): def test_subst_multi_folder_albums_3_path_sep(photosdb_14_6):
"""Test substitutions for folder_album on < Photos 5 with custom PATH_SEP""" """Test substitutions for folder_album on < Photos 5 with custom PATH_SEP"""
import osxphotos
# photo in an album in a folder # photo in an album in a folder
photo = photosdb_14_6.photos(uuid=[UUID_DICT["mojave_album_1"]])[0] photo = photosdb_14_6.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
@@ -739,7 +734,6 @@ def test_subst_multi_folder_albums_3_path_sep(photosdb_14_6):
def test_subst_multi_folder_albums_4_path_sep_lower(photosdb_14_6): def test_subst_multi_folder_albums_4_path_sep_lower(photosdb_14_6):
"""Test substitutions for folder_album on < Photos 5 with custom PATH_SEP""" """Test substitutions for folder_album on < Photos 5 with custom PATH_SEP"""
import osxphotos
# photo in an album in a folder # photo in an album in a folder
photo = photosdb_14_6.photos(uuid=[UUID_DICT["mojave_album_1"]])[0] photo = photosdb_14_6.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
@@ -753,7 +747,6 @@ def test_subst_multi_folder_albums_4_path_sep_lower(photosdb_14_6):
def test_subst_strftime(photosdb_places): def test_subst_strftime(photosdb_places):
"""Test that strftime substitutions are correct""" """Test that strftime substitutions are correct"""
import locale import locale
import osxphotos
locale.setlocale(locale.LC_ALL, "en_US") locale.setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
@@ -773,7 +766,8 @@ def test_subst_expand_inplace_1(photosdb):
template = "{person}" template = "{person}"
expected = ["Katie,Suzy"] expected = ["Katie,Suzy"]
rendered, unknown = photo.render_template(template, expand_inplace=True) options = RenderOptions(expand_inplace=True)
rendered, unknown = photo.render_template(template, options)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
@@ -784,7 +778,8 @@ def test_subst_expand_inplace_2(photosdb):
template = "{person}-{keyword}" template = "{person}-{keyword}"
expected = ["Katie,Suzy-Kids"] expected = ["Katie,Suzy-Kids"]
rendered, unknown = photo.render_template(template, expand_inplace=True) options = RenderOptions(expand_inplace=True)
rendered, unknown = photo.render_template(template, options)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
@@ -795,9 +790,9 @@ def test_subst_expand_inplace_3(photosdb):
template = "{person}-{keyword}" template = "{person}-{keyword}"
expected = ["Katie; Suzy-Kids"] expected = ["Katie; Suzy-Kids"]
rendered, unknown = photo.render_template(
template, expand_inplace=True, inplace_sep="; " options = RenderOptions(expand_inplace=True, inplace_sep="; ")
) rendered, unknown = photo.render_template(template, options)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
@@ -837,8 +832,9 @@ def test_bool_values(photosdb_cloud):
if uuid is not None: if uuid is not None:
photo = photosdb_cloud.get_photo(uuid) photo = photosdb_cloud.get_photo(uuid)
edited = field == "edited_version" edited = field == "edited_version"
options = RenderOptions(edited_version=edited)
rendered, _ = photo.render_template( rendered, _ = photo.render_template(
"{" + f"{field}" + "?True,False}", edited=edited "{" + f"{field}" + "?True,False}", options
) )
assert rendered[0] == "True" assert rendered[0] == "True"
@@ -1019,3 +1015,29 @@ def test_function_filter_bad(photosdb):
rendered, _ = photo.render_template( rendered, _ = photo.render_template(
"{photo.original_filename|function:tests/template_filter.py::foobar}" "{photo.original_filename|function:tests/template_filter.py::foobar}"
) )
def test_export_dir():
"""Test {export_dir} template"""
from osxphotos.photoinfo import PhotoInfoNone
from osxphotos.phototemplate import PhotoTemplate
options = RenderOptions(export_dir="/foo/bar")
template = PhotoTemplate(PhotoInfoNone())
rendered, _ = template.render("{export_dir}", options)
assert rendered[0] == "/foo/bar"
rendered, _ = template.render("{export_dir.name}", options)
assert rendered[0] == "bar"
rendered, _ = template.render("{export_dir.parent}", options)
assert rendered[0] == "/foo"
rendered, _ = template.render("{export_dir.stem}", options)
assert rendered[0] == "bar"
rendered, _ = template.render("{export_dir.suffix}", options)
assert rendered[0] == ""
with pytest.raises(ValueError):
rendered, _ = template.render("{export_dir.foo}", options)

View File

@@ -2,6 +2,9 @@ import datetime
import pytest import pytest
import osxphotos
from osxphotos.phototemplate import RenderOptions
PHOTOS_DB_PLACES = ( PHOTOS_DB_PLACES = (
"./tests/Test-Places-Catalina-10_15_1.photoslibrary/database/photos.db" "./tests/Test-Places-Catalina-10_15_1.photoslibrary/database/photos.db"
) )
@@ -35,35 +38,40 @@ TODAY_VALUES = {
} }
def test_subst_today(): @pytest.fixture(scope="module")
""" Test that substitutions are correct for {today.x}""" def photosdb():
return osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
def test_subst_today(photosdb):
"""Test that substitutions are correct for {today.x}"""
import locale import locale
import osxphotos
locale.setlocale(locale.LC_ALL, "en_US") locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
photo_template = osxphotos.PhotoTemplate(photo) photo_template = osxphotos.PhotoTemplate(photo)
photo_template.today = DATETIME_TODAY photo_template.today = DATETIME_TODAY
options = RenderOptions()
for template in TODAY_VALUES: for template in TODAY_VALUES:
rendered, _ = photo_template.render(template) rendered, _ = photo_template.render(template, options)
assert rendered[0] == TODAY_VALUES[template] assert rendered[0] == TODAY_VALUES[template]
def test_subst_strftime_today(): def test_subst_strftime_today(photosdb):
""" Test that strftime substitutions are correct for {today.strftime}""" """Test that strftime substitutions are correct for {today.strftime}"""
import locale import locale
import osxphotos
locale.setlocale(locale.LC_ALL, "en_US") locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
photo_template = osxphotos.PhotoTemplate(photo) photo_template = osxphotos.PhotoTemplate(photo)
photo_template.today = DATETIME_TODAY photo_template.today = DATETIME_TODAY
rendered, unmatched = photo_template.render("{today.strftime,%Y-%m-%d-%H%M%S}") options = RenderOptions()
rendered, unmatched = photo_template.render(
"{today.strftime,%Y-%m-%d-%H%M%S}", options
)
assert rendered[0] == "2020-06-21-130000" assert rendered[0] == "2020-06-21-130000"
rendered, unmatched = photo.render_template("{today.strftime}") rendered, unmatched = photo.render_template("{today.strftime}")