Refactored PhotoTemplate to support pathlib templates
This commit is contained in:
parent
1a46cdf63c
commit
2cdec3fc78
14
README.md
14
README.md
@ -1574,6 +1574,20 @@ Substitution Description
|
||||
/blob/master/examples/template_function.py for an
|
||||
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 -->
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.42.31"
|
||||
__version__ = "0.42.32"
|
||||
|
||||
@ -50,9 +50,10 @@ from .fileutil import FileUtil, FileUtilNoOp
|
||||
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
||||
from .photoinfo import ExportResults
|
||||
from .photokit import check_photokit_authorization, request_photokit_authorization
|
||||
from .photosalbum import PhotosAlbum
|
||||
from .phototemplate import RenderOptions
|
||||
from .queryoptions import QueryOptions
|
||||
from .utils import get_preferred_uti_extension
|
||||
from .photosalbum import PhotosAlbum
|
||||
|
||||
# global variable to control verbose output
|
||||
# set via --verbose/-V
|
||||
@ -60,7 +61,7 @@ VERBOSE = False
|
||||
|
||||
|
||||
def verbose_(*args, **kwargs):
|
||||
""" print output if verbose flag set """
|
||||
"""print output if verbose flag set"""
|
||||
if VERBOSE:
|
||||
styled_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, "
|
||||
"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 "
|
||||
"times or duplicated within Photos."
|
||||
"times or duplicated within Photos.",
|
||||
),
|
||||
o(
|
||||
"--min-size",
|
||||
@ -1697,13 +1698,18 @@ def export(
|
||||
exiftool_merge_keywords=exiftool_merge_keywords,
|
||||
finder_tag_template=finder_tag_template,
|
||||
strip=strip,
|
||||
export_dir=dest,
|
||||
)
|
||||
results.xattr_written.extend(tags_written)
|
||||
results.xattr_skipped.extend(tags_skipped)
|
||||
|
||||
if xattr_template:
|
||||
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_skipped.extend(xattr_skipped)
|
||||
@ -1777,7 +1783,7 @@ def export(
|
||||
@click.argument("topic", default=None, required=False, nargs=1)
|
||||
@click.pass_context
|
||||
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:
|
||||
click.echo(ctx.parent.get_help())
|
||||
elif topic in cli.commands:
|
||||
@ -2062,7 +2068,7 @@ def query(
|
||||
max_size=max_size,
|
||||
query_eval=query_eval,
|
||||
regex=regex,
|
||||
duplicate=duplicate
|
||||
duplicate=duplicate,
|
||||
)
|
||||
|
||||
try:
|
||||
@ -2348,9 +2354,8 @@ def export_photo(
|
||||
rendered_suffix = ""
|
||||
if original_suffix:
|
||||
try:
|
||||
rendered_suffix, unmatched = photo.render_template(
|
||||
original_suffix, filename=True, strip=strip
|
||||
)
|
||||
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",
|
||||
@ -2477,8 +2482,13 @@ def export_photo(
|
||||
|
||||
if edited_suffix:
|
||||
try:
|
||||
options = RenderOptions(
|
||||
filename=True,
|
||||
strip=strip,
|
||||
export_dir=dest,
|
||||
)
|
||||
rendered_suffix, unmatched = photo.render_template(
|
||||
edited_suffix, filename=True, strip=strip
|
||||
edited_suffix, options
|
||||
)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
@ -2588,7 +2598,7 @@ def export_photo_with_template(
|
||||
replace_keywords,
|
||||
retry,
|
||||
):
|
||||
""" Evaluate directory template then export photo to each directory """
|
||||
"""Evaluate directory template then export photo to each directory"""
|
||||
|
||||
results = ExportResults()
|
||||
|
||||
@ -2735,7 +2745,11 @@ def export_photo_with_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
|
||||
|
||||
@ -2755,13 +2769,13 @@ def get_filenames_from_template(
|
||||
if filename_template:
|
||||
photo_ext = pathlib.Path(photo.original_filename).suffix
|
||||
try:
|
||||
filenames, unmatched = photo.render_template(
|
||||
filename_template,
|
||||
options = RenderOptions(
|
||||
path_sep="_",
|
||||
filename=True,
|
||||
strip=strip,
|
||||
edited=edited,
|
||||
edited_version=edited,
|
||||
)
|
||||
filenames, unmatched = photo.render_template(filename_template, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"filename_template", f"Invalid template '{filename_template}': {e}"
|
||||
@ -2815,9 +2829,8 @@ def get_dirnames_from_template(
|
||||
elif directory:
|
||||
# got a directory template, render it and check results are valid
|
||||
try:
|
||||
dirnames, unmatched = photo.render_template(
|
||||
directory, dirname=True, strip=strip, edited=edited
|
||||
)
|
||||
options = RenderOptions(dirname=True, strip=strip, edited_version=edited)
|
||||
dirnames, unmatched = photo.render_template(directory, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"directory", f"Invalid template '{directory}': {e}"
|
||||
@ -3098,6 +3111,7 @@ def write_finder_tags(
|
||||
exiftool_merge_keywords=None,
|
||||
finder_tag_template=None,
|
||||
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
|
||||
|
||||
@ -3110,6 +3124,7 @@ def write_finder_tags(
|
||||
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
|
||||
finder_tag_template: list of templates to evaluate for determining Finder tags
|
||||
export_dir: value to use for {export_dir} template
|
||||
|
||||
Returns:
|
||||
(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 = []
|
||||
for template_str in finder_tag_template:
|
||||
try:
|
||||
rendered, unmatched = photo.render_template(
|
||||
template_str,
|
||||
options = RenderOptions(
|
||||
none_str=_OSXPHOTOS_NONE_SENTINEL,
|
||||
path_sep="/",
|
||||
strip=strip,
|
||||
export_dir=export_dir,
|
||||
)
|
||||
rendered, unmatched = photo.render_template(template_str, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"finder_tag_template",
|
||||
@ -3178,12 +3194,15 @@ def write_finder_tags(
|
||||
return (written, skipped)
|
||||
|
||||
|
||||
def write_extended_attributes(photo, files, xattr_template, strip=False):
|
||||
""" Writes extended attributes to exported files
|
||||
def write_extended_attributes(
|
||||
photo, files, xattr_template, strip=False, export_dir=None
|
||||
):
|
||||
"""Writes extended attributes to exported files
|
||||
|
||||
Args:
|
||||
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:
|
||||
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 = {}
|
||||
for xattr, template_str in xattr_template:
|
||||
try:
|
||||
rendered, unmatched = photo.render_template(
|
||||
template_str,
|
||||
options = RenderOptions(
|
||||
none_str=_OSXPHOTOS_NONE_SENTINEL,
|
||||
path_sep="/",
|
||||
strip=strip,
|
||||
export_dir=export_dir,
|
||||
)
|
||||
rendered, unmatched = photo.render_template(template_str, options)
|
||||
except ValueError as e:
|
||||
raise click.BadOptionUsage(
|
||||
"xattr_template",
|
||||
@ -3266,7 +3286,7 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
|
||||
""" Print out debug info """
|
||||
"""Print out debug info"""
|
||||
|
||||
global 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_context
|
||||
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
|
||||
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_context
|
||||
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
|
||||
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_context
|
||||
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
|
||||
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_context
|
||||
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
|
||||
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_context
|
||||
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)
|
||||
if db is None:
|
||||
@ -3504,7 +3524,7 @@ def info(ctx, cli_obj, db, json_, photos_library):
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
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
|
||||
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_context
|
||||
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)
|
||||
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_context
|
||||
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
|
||||
# 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_context
|
||||
def about(ctx, cli_obj):
|
||||
""" Print information about osxphotos including license. """
|
||||
"""Print information about osxphotos including license."""
|
||||
license = """
|
||||
MIT License
|
||||
|
||||
|
||||
@ -16,12 +16,13 @@ from ._constants import (
|
||||
from .phototemplate import (
|
||||
TEMPLATE_SUBSTITUTIONS,
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||
TEMPLATE_SUBSTITUTIONS_PATHLIB,
|
||||
get_template_help,
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
help_text = super().get_help(ctx)
|
||||
@ -65,7 +66,9 @@ class ExportCommand(click.Command):
|
||||
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
|
||||
)
|
||||
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_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"
|
||||
)
|
||||
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(template_help(width=formatter.width))
|
||||
formatter.write("\n")
|
||||
@ -128,7 +133,11 @@ The following attributes may be used with '--xattr-template':
|
||||
+ "an error and the script will abort."
|
||||
)
|
||||
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")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
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("\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()
|
||||
return help_text
|
||||
|
||||
|
||||
def template_help(width=78):
|
||||
"""Return formatted string for template system """
|
||||
"""Return formatted string for template system"""
|
||||
sio = io.StringIO()
|
||||
console = Console(file=sio, force_terminal=True, width=width)
|
||||
template_help_md = strip_md_links(get_template_help())
|
||||
@ -194,4 +227,3 @@ def strip_md_links(md):
|
||||
return match.group(1)
|
||||
|
||||
return re.sub(links, subfn, md)
|
||||
|
||||
|
||||
@ -7,4 +7,4 @@ PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
from ._photoinfo_exifinfo import ExifInfo
|
||||
from ._photoinfo_export import ExportResults
|
||||
from ._photoinfo_scoreinfo import ScoreInfo
|
||||
from .photoinfo import PhotoInfo
|
||||
from .photoinfo import PhotoInfo, PhotoInfoNone
|
||||
@ -36,10 +36,10 @@ from .._constants import (
|
||||
_UNKNOWN_PERSON,
|
||||
_XMP_TEMPLATE_NAME,
|
||||
_XMP_TEMPLATE_NAME_BETA,
|
||||
LIVE_VIDEO_EXTENSIONS,
|
||||
SIDECAR_EXIFTOOL,
|
||||
SIDECAR_JSON,
|
||||
SIDECAR_XMP,
|
||||
LIVE_VIDEO_EXTENSIONS,
|
||||
)
|
||||
from .._version import __version__
|
||||
from ..datetime_utils import datetime_tz_to_utc
|
||||
@ -52,6 +52,7 @@ from ..photokit import (
|
||||
PhotoKitFetchFailed,
|
||||
PhotoLibrary,
|
||||
)
|
||||
from ..phototemplate import RenderOptions
|
||||
from ..utils import findfiles, get_preferred_uti_extension, lineno, noop
|
||||
|
||||
# retry if use_photos_export fails the first time (which sometimes it does)
|
||||
@ -390,6 +391,7 @@ def export(
|
||||
use_persons_as_keywords=False,
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
export_dir=None,
|
||||
):
|
||||
"""export photo
|
||||
dest: must be valid destination path (or exception raised)
|
||||
@ -427,6 +429,7 @@ def export(
|
||||
when exporting metadata with exiftool or sidecar
|
||||
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
|
||||
export_dir: value to use for {export_dir} template
|
||||
|
||||
Returns: list of photos exported
|
||||
"""
|
||||
@ -458,6 +461,7 @@ def export(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
export_dir=export_dir,
|
||||
)
|
||||
|
||||
return results.exported
|
||||
@ -500,6 +504,7 @@ def export2(
|
||||
persons=True,
|
||||
location=True,
|
||||
replace_keywords=False,
|
||||
export_dir=None,
|
||||
):
|
||||
"""export photo, like export but with update and dry_run options
|
||||
dest: must be valid destination path or exception raised
|
||||
@ -555,6 +560,7 @@ 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
|
||||
export_dir: value to use for {export_dir} template
|
||||
|
||||
Returns: ExportResults class
|
||||
ExportResults has attributes:
|
||||
@ -1161,7 +1167,10 @@ def _export_photo_with_photos_export(
|
||||
else:
|
||||
try:
|
||||
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)
|
||||
except Exception as e:
|
||||
@ -1205,7 +1214,10 @@ def _export_photo_with_photos_export(
|
||||
if not dry_run:
|
||||
try:
|
||||
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)
|
||||
except Exception as e:
|
||||
@ -1586,9 +1598,8 @@ def _exiftool_dict(
|
||||
)
|
||||
|
||||
if description_template is not None:
|
||||
rendered = self.render_template(
|
||||
description_template, expand_inplace=True, inplace_sep=", "
|
||||
)[0]
|
||||
options = RenderOptions(expand_inplace=True, inplace_sep=", ")
|
||||
rendered = self.render_template(description_template, options)[0]
|
||||
description = " ".join(rendered) if rendered else ""
|
||||
exif["EXIF:ImageDescription"] = description
|
||||
exif["XMP:Description"] = description
|
||||
@ -1626,10 +1637,9 @@ def _exiftool_dict(
|
||||
|
||||
if keyword_template:
|
||||
rendered_keywords = []
|
||||
options = RenderOptions(none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
|
||||
for template_str in keyword_template:
|
||||
rendered, unmatched = self.render_template(
|
||||
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
|
||||
)
|
||||
rendered, unmatched = self.render_template(template_str, options)
|
||||
if unmatched:
|
||||
logging.warning(
|
||||
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
|
||||
|
||||
if description_template is not None:
|
||||
rendered = self.render_template(
|
||||
description_template, expand_inplace=True, inplace_sep=", "
|
||||
)[0]
|
||||
options = RenderOptions(expand_inplace=True, inplace_sep=", ")
|
||||
rendered = self.render_template(description_template, options)[0]
|
||||
description = " ".join(rendered) if rendered else ""
|
||||
else:
|
||||
description = self.description if self.description is not None else ""
|
||||
@ -1939,10 +1948,9 @@ def _xmp_sidecar(
|
||||
|
||||
if keyword_template:
|
||||
rendered_keywords = []
|
||||
options = RenderOptions(none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
|
||||
for template_str in keyword_template:
|
||||
rendered, unmatched = self.render_template(
|
||||
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
|
||||
)
|
||||
rendered, unmatched = self.render_template(template_str, options)
|
||||
if unmatched:
|
||||
logging.warning(
|
||||
f"Unmatched template substitution for template: {template_str} {unmatched}"
|
||||
|
||||
@ -3,7 +3,6 @@ PhotoInfo class
|
||||
Represents a single photo in the Photos library and provides access to the photo's attributes
|
||||
PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
@ -12,6 +11,7 @@ import os
|
||||
import os.path
|
||||
import pathlib
|
||||
from datetime import timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
@ -34,7 +34,7 @@ from .._constants import (
|
||||
from ..adjustmentsinfo import AdjustmentsInfo
|
||||
from ..albuminfo import AlbumInfo, ImportInfo
|
||||
from ..personinfo import FaceInfo, PersonInfo
|
||||
from ..phototemplate import PhotoTemplate
|
||||
from ..phototemplate import PhotoTemplate, RenderOptions
|
||||
from ..placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
|
||||
|
||||
@ -46,31 +46,31 @@ class PhotoInfo:
|
||||
"""
|
||||
|
||||
# import additional methods
|
||||
from ._photoinfo_searchinfo import (
|
||||
search_info,
|
||||
search_info_normalized,
|
||||
labels,
|
||||
labels_normalized,
|
||||
SearchInfo,
|
||||
)
|
||||
from ._photoinfo_exifinfo import exif_info, ExifInfo
|
||||
from ._photoinfo_comments import comments, likes
|
||||
from ._photoinfo_exifinfo import ExifInfo, exif_info
|
||||
from ._photoinfo_exiftool import exiftool
|
||||
from ._photoinfo_export import (
|
||||
export,
|
||||
export2,
|
||||
_export_photo,
|
||||
ExportResults,
|
||||
_exiftool_dict,
|
||||
_exiftool_json_sidecar,
|
||||
_export_photo,
|
||||
_export_photo_with_photos_export,
|
||||
_get_exif_keywords,
|
||||
_get_exif_persons,
|
||||
_write_exif_data,
|
||||
_write_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):
|
||||
self._uuid = uuid
|
||||
@ -1015,48 +1015,20 @@ class PhotoInfo:
|
||||
return duplicates
|
||||
|
||||
def render_template(
|
||||
self,
|
||||
template_str,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
strip=False,
|
||||
edited=False,
|
||||
self, template_str: str, options: Optional[RenderOptions] = None
|
||||
):
|
||||
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
||||
|
||||
Args:
|
||||
template_str: a template string with fields to render
|
||||
none_str: a str to use if template field renders to None, default is "_".
|
||||
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
|
||||
options: a RenderOptions instance
|
||||
|
||||
Returns:
|
||||
([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)
|
||||
return template.render(
|
||||
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,
|
||||
)
|
||||
return template.render(template_str, options)
|
||||
|
||||
@property
|
||||
def _longitude(self):
|
||||
@ -1269,3 +1241,13 @@ class PhotoInfo:
|
||||
def __hash__(self):
|
||||
"""Make PhotoInfo hashable"""
|
||||
return hash(self.uuid)
|
||||
|
||||
|
||||
class PhotoInfoNone:
|
||||
"""mock class that returns None for all attributes"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def __getattribute__(self, name):
|
||||
return None
|
||||
|
||||
@ -44,6 +44,7 @@ from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
|
||||
from ..fileutil import FileUtil
|
||||
from ..personinfo import PersonInfo
|
||||
from ..photoinfo import PhotoInfo
|
||||
from ..phototemplate import RenderOptions
|
||||
from ..queryoptions import QueryOptions
|
||||
from ..utils import (
|
||||
_check_file_exists,
|
||||
@ -3207,11 +3208,12 @@ class PhotosDB:
|
||||
|
||||
if options.regex:
|
||||
flags = re.IGNORECASE if options.ignore_case else 0
|
||||
render_options = RenderOptions(none_str="")
|
||||
for regex, template in options.regex:
|
||||
regex = re.compile(regex, flags)
|
||||
photo_list = []
|
||||
for p in photos:
|
||||
rendered, _ = p.render_template(template, none_str="")
|
||||
rendered, _ = p.render_template(template, render_options)
|
||||
for value in rendered:
|
||||
if regex.search(value):
|
||||
photo_list.append(p)
|
||||
|
||||
@ -14,6 +14,10 @@ from .datetime_formatter import DateTimeFormatter
|
||||
from .exiftool import ExifToolCaching
|
||||
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
|
||||
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
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
@ -137,7 +141,11 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{cr}": r"A carriage return: '\r'",
|
||||
"{crlf}": r"a carriage return + line feed: '\r\n'",
|
||||
"{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)
|
||||
@ -179,7 +187,7 @@ FILTER_VALUES = {
|
||||
"braces": "Enclose value in curly braces, e.g. 'value => '{value}'.",
|
||||
"parens": "Enclose value in parentheses, 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
|
||||
@ -187,13 +195,18 @@ SINGLE_VALUE_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 = [
|
||||
field.replace("{", "").replace("}", "")
|
||||
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
|
||||
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:
|
||||
"""Parser for PhotoTemplate """
|
||||
"""Parser for PhotoTemplate"""
|
||||
|
||||
# implemented as Singleton
|
||||
|
||||
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:
|
||||
cls.instance = super().__new__(cls)
|
||||
|
||||
return cls.instance
|
||||
|
||||
def __init__(self):
|
||||
""" return existing singleton or create a new one """
|
||||
"""return existing singleton or create a new one"""
|
||||
|
||||
if hasattr(self, "metamodel"):
|
||||
return
|
||||
@ -238,15 +282,15 @@ class PhotoTemplateParser:
|
||||
self.metamodel = metamodel_from_file(OTL_GRAMMAR_MODEL, skipws=False)
|
||||
|
||||
def parse(self, template_statement):
|
||||
"""Parse a template_statement string """
|
||||
"""Parse a template_statement string"""
|
||||
return self.metamodel.model_from_str(template_statement)
|
||||
|
||||
|
||||
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):
|
||||
""" Inits PhotoTemplate class with photo
|
||||
"""Inits PhotoTemplate class with photo
|
||||
|
||||
Args:
|
||||
photo: a PhotoInfo instance.
|
||||
@ -262,49 +306,49 @@ class PhotoTemplate:
|
||||
# get parser singleton
|
||||
self.parser = PhotoTemplateParser()
|
||||
|
||||
# should {edited_version} render True?
|
||||
self.edited_version = False
|
||||
# initialize render options
|
||||
# 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(
|
||||
self,
|
||||
template,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
strip=False,
|
||||
edited_version=False,
|
||||
template: str,
|
||||
options: RenderOptions,
|
||||
):
|
||||
""" Render a filename or directory template
|
||||
"""Render a filename or directory template
|
||||
|
||||
Args:
|
||||
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)
|
||||
options: a RenderOptions instance
|
||||
|
||||
Returns:
|
||||
([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:
|
||||
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:
|
||||
model = self.parser.parse(template)
|
||||
except TextXSyntaxError as e:
|
||||
@ -314,53 +358,29 @@ class PhotoTemplate:
|
||||
# empty string
|
||||
return [], []
|
||||
|
||||
self.edited_version = edited_version
|
||||
|
||||
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,
|
||||
)
|
||||
return self._render_statement(model)
|
||||
|
||||
def _render_statement(
|
||||
self,
|
||||
statement,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
strip=False,
|
||||
):
|
||||
path_sep = path_sep or self.path_sep
|
||||
results = []
|
||||
unmatched = []
|
||||
for ts in statement.template_strings:
|
||||
results, unmatched = self._render_template_string(
|
||||
ts,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
results=results,
|
||||
unmatched=unmatched,
|
||||
ts, results=results, unmatched=unmatched, path_sep=path_sep
|
||||
)
|
||||
|
||||
rendered_strings = results
|
||||
|
||||
if filename:
|
||||
if self.filename:
|
||||
rendered_strings = [
|
||||
sanitize_filename(rendered_str) for rendered_str in rendered_strings
|
||||
]
|
||||
|
||||
if strip:
|
||||
if self.strip:
|
||||
rendered_strings = [
|
||||
rendered_str.strip() for rendered_str in rendered_strings
|
||||
]
|
||||
@ -370,16 +390,11 @@ class PhotoTemplate:
|
||||
def _render_template_string(
|
||||
self,
|
||||
ts,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
path_sep,
|
||||
results=None,
|
||||
unmatched=None,
|
||||
):
|
||||
"""Render a TemplateString object """
|
||||
"""Render a TemplateString object"""
|
||||
|
||||
results = results or [""]
|
||||
unmatched = unmatched or []
|
||||
@ -387,7 +402,8 @@ class PhotoTemplate:
|
||||
if ts.template:
|
||||
# have a template field to process
|
||||
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)
|
||||
return [], unmatched
|
||||
|
||||
@ -414,12 +430,7 @@ class PhotoTemplate:
|
||||
if ts.template.bool.value is not None:
|
||||
bool_val, u = self._render_statement(
|
||||
ts.template.bool.value,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
)
|
||||
unmatched.extend(u)
|
||||
else:
|
||||
@ -435,12 +446,7 @@ class PhotoTemplate:
|
||||
if ts.template.default.value is not None:
|
||||
default, u = self._render_statement(
|
||||
ts.template.default.value,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
)
|
||||
unmatched.extend(u)
|
||||
else:
|
||||
@ -457,12 +463,7 @@ class PhotoTemplate:
|
||||
# conditional value is also a TemplateString
|
||||
conditional_value, u = self._render_statement(
|
||||
ts.template.conditional.value,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
)
|
||||
unmatched.extend(u)
|
||||
else:
|
||||
@ -478,10 +479,8 @@ class PhotoTemplate:
|
||||
vals = self.get_template_value(
|
||||
field,
|
||||
default=default,
|
||||
delim=delim or inplace_sep,
|
||||
path_sep=path_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
# delim=delim or self.inplace_sep,
|
||||
# path_sep=path_sep,
|
||||
)
|
||||
elif field == "exiftool":
|
||||
if subfield is None:
|
||||
@ -489,7 +488,7 @@ class PhotoTemplate:
|
||||
"SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'"
|
||||
)
|
||||
vals = self.get_template_value_exiftool(
|
||||
subfield, filename=filename, dirname=dirname
|
||||
subfield,
|
||||
)
|
||||
elif field == "function":
|
||||
if subfield is None:
|
||||
@ -497,11 +496,16 @@ class PhotoTemplate:
|
||||
"SyntaxError: filename and function must not be null with {function::filename.py:function_name}"
|
||||
)
|
||||
vals = self.get_template_value_function(
|
||||
subfield, filename=filename, dirname=dirname
|
||||
subfield,
|
||||
)
|
||||
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
|
||||
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:
|
||||
unmatched.append(field)
|
||||
@ -509,8 +513,8 @@ class PhotoTemplate:
|
||||
|
||||
vals = [val for val in vals if val is not None]
|
||||
|
||||
if expand_inplace or delim is not None:
|
||||
sep = delim if delim is not None else inplace_sep
|
||||
if self.expand_inplace or delim is not None:
|
||||
sep = delim if delim is not None else self.inplace_sep
|
||||
vals = [sep.join(sorted(vals))]
|
||||
|
||||
for filter_ in filters:
|
||||
@ -531,7 +535,7 @@ class PhotoTemplate:
|
||||
# have a conditional operator
|
||||
|
||||
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
|
||||
for c in conditional_value:
|
||||
for v in vals:
|
||||
@ -546,7 +550,7 @@ class PhotoTemplate:
|
||||
return []
|
||||
|
||||
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:
|
||||
raise ValueError(
|
||||
f"comparison operators may only be used with a single value: {vals} {conditional_value}"
|
||||
@ -607,7 +611,7 @@ class PhotoTemplate:
|
||||
if is_bool:
|
||||
vals = default if not vals else bool_val
|
||||
elif not vals:
|
||||
vals = default or [none_str]
|
||||
vals = default or [self.none_str]
|
||||
|
||||
pre = ts.pre or ""
|
||||
post = ts.post or ""
|
||||
@ -632,11 +636,9 @@ class PhotoTemplate:
|
||||
self,
|
||||
field,
|
||||
default,
|
||||
bool_val=None,
|
||||
delim=None,
|
||||
path_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
# bool_val=None,
|
||||
# delim=None,
|
||||
# path_sep=None,
|
||||
):
|
||||
"""lookup value for template field (single-value template substitutions)
|
||||
|
||||
@ -646,8 +648,6 @@ class PhotoTemplate:
|
||||
bool_val: True value if expression is boolean
|
||||
delim: delimiter for expand in place
|
||||
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:
|
||||
The matching template value (which may be None).
|
||||
@ -655,6 +655,10 @@ class PhotoTemplate:
|
||||
Raises:
|
||||
ValueError if no rule exists for field.
|
||||
"""
|
||||
|
||||
if self.photo.uuid is None:
|
||||
return []
|
||||
|
||||
if field not in FIELD_NAMES:
|
||||
raise ValueError(f"SyntaxError: Unknown field: {field}")
|
||||
|
||||
@ -920,9 +924,36 @@ class PhotoTemplate:
|
||||
# if here, didn't get a match
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
|
||||
if filename:
|
||||
if self.filename:
|
||||
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)
|
||||
|
||||
return [value]
|
||||
@ -974,13 +1005,16 @@ class PhotoTemplate:
|
||||
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)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
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:
|
||||
List of the matching template values or [].
|
||||
@ -990,6 +1024,10 @@ class PhotoTemplate:
|
||||
"""
|
||||
|
||||
""" return list of values for a multi-valued template field """
|
||||
|
||||
if self.photo.uuid is None:
|
||||
return []
|
||||
|
||||
values = []
|
||||
if field == "album":
|
||||
values = self.photo.burst_albums if self.photo.burst else self.photo.albums
|
||||
@ -1013,7 +1051,7 @@ class PhotoTemplate:
|
||||
for album in album_info:
|
||||
if album.folder_names:
|
||||
# album in folder
|
||||
if dirname:
|
||||
if self.dirname:
|
||||
# being used as a filepath so sanitize each part
|
||||
folder = path_sep.join(
|
||||
sanitize_dirname(f) for f in album.folder_names
|
||||
@ -1025,7 +1063,7 @@ class PhotoTemplate:
|
||||
values.append(folder)
|
||||
else:
|
||||
# album not in folder
|
||||
if dirname:
|
||||
if self.dirname:
|
||||
values.append(sanitize_dirname(album.title))
|
||||
else:
|
||||
values.append(album.title)
|
||||
@ -1073,9 +1111,9 @@ class PhotoTemplate:
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
|
||||
# sanitize directory names if needed, folder_album handled differently above
|
||||
if filename:
|
||||
if self.filename:
|
||||
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
|
||||
values = [sanitize_dirname(value) for value in values]
|
||||
|
||||
@ -1083,9 +1121,15 @@ class PhotoTemplate:
|
||||
values = values or []
|
||||
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}" """
|
||||
|
||||
if self.photo is None:
|
||||
return []
|
||||
|
||||
if not self.photo.path:
|
||||
return []
|
||||
|
||||
@ -1098,17 +1142,20 @@ class PhotoTemplate:
|
||||
values = [str(v) for v in values]
|
||||
|
||||
# sanitize directory names if needed
|
||||
if filename:
|
||||
if self.filename:
|
||||
values = [sanitize_pathpart(value) for value in values]
|
||||
elif dirname:
|
||||
elif self.dirname:
|
||||
values = [sanitize_dirname(value) for value in values]
|
||||
else:
|
||||
values = []
|
||||
|
||||
return values
|
||||
|
||||
def get_template_value_function(self, subfield, filename=None, dirname=None):
|
||||
"""Get template value from external function """
|
||||
def get_template_value_function(
|
||||
self,
|
||||
subfield,
|
||||
):
|
||||
"""Get template value from external function"""
|
||||
|
||||
if "::" not in subfield:
|
||||
raise ValueError(
|
||||
@ -1131,17 +1178,17 @@ class PhotoTemplate:
|
||||
values = [values]
|
||||
|
||||
# sanitize directory names if needed
|
||||
if filename:
|
||||
if self.filename:
|
||||
values = [sanitize_pathpart(value) for value in values]
|
||||
elif dirname:
|
||||
elif self.dirname:
|
||||
values = [sanitize_dirname(value) for value in values]
|
||||
|
||||
return 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_:
|
||||
raise ValueError(
|
||||
@ -1166,9 +1213,8 @@ class PhotoTemplate:
|
||||
|
||||
return values
|
||||
|
||||
|
||||
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)
|
||||
if self.photo.isphoto:
|
||||
return default_dict["photo"]
|
||||
@ -1176,7 +1222,7 @@ class PhotoTemplate:
|
||||
return default_dict["video"]
|
||||
|
||||
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)
|
||||
p = self.photo
|
||||
if p.selfie:
|
||||
@ -1210,7 +1256,7 @@ class PhotoTemplate:
|
||||
|
||||
|
||||
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:
|
||||
default: str, in form 'photo=foto;video=vidéo'
|
||||
@ -1235,9 +1281,29 @@ def parse_default_kv(default, default_dict):
|
||||
|
||||
|
||||
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
|
||||
help_file = pathlib.Path(__file__).parent / "phototemplate.md"
|
||||
with open(help_file, "r") as fd:
|
||||
md = fd.read()
|
||||
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}")
|
||||
|
||||
@ -8,6 +8,7 @@ class PhotoInfoMock(PhotoInfo):
|
||||
self._photo = photo
|
||||
self._db = photo._db
|
||||
self._info = photo._info
|
||||
self._uuid = photo.uuid
|
||||
|
||||
for kw in kwargs:
|
||||
if hasattr(photo, kw):
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
""" Test template.py """
|
||||
import os
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from photoinfo_mock import PhotoInfoMock
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.exiftool import get_exiftool_path
|
||||
|
||||
from photoinfo_mock import PhotoInfoMock
|
||||
from osxphotos.phototemplate import (
|
||||
TEMPLATE_SUBSTITUTIONS,
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||
PhotoTemplate,
|
||||
RenderOptions,
|
||||
)
|
||||
|
||||
try:
|
||||
exiftool = get_exiftool_path()
|
||||
@ -357,8 +366,6 @@ def photosdb_cloud():
|
||||
|
||||
def test_lookup(photosdb_places):
|
||||
"""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]
|
||||
template = PhotoTemplate(photo)
|
||||
@ -371,13 +378,6 @@ def test_lookup(photosdb_places):
|
||||
|
||||
def test_lookup_multi(photosdb_places):
|
||||
"""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]
|
||||
template = PhotoTemplate(photo)
|
||||
|
||||
@ -464,7 +464,6 @@ def test_subst_locale_2(photosdb_places):
|
||||
def test_subst_default_val(photosdb_places):
|
||||
"""Test substitution with default value specified"""
|
||||
import locale
|
||||
import osxphotos
|
||||
|
||||
locale.setlocale(locale.LC_ALL, "en_US")
|
||||
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):
|
||||
"""Test that substitutions are correct"""
|
||||
# one album, one keyword, two persons
|
||||
import osxphotos
|
||||
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
|
||||
|
||||
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):
|
||||
"""Test that substitutions are correct"""
|
||||
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
|
||||
import osxphotos
|
||||
|
||||
# one album, one keyword, two persons
|
||||
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):
|
||||
"""Test substitutions for folder_album on < Photos 5 with custom PATH_SEP"""
|
||||
import osxphotos
|
||||
|
||||
# photo in an album in a folder
|
||||
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):
|
||||
"""Test substitutions for folder_album on < Photos 5 with custom PATH_SEP"""
|
||||
import osxphotos
|
||||
|
||||
# photo in an album in a folder
|
||||
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):
|
||||
"""Test that strftime substitutions are correct"""
|
||||
import locale
|
||||
import osxphotos
|
||||
|
||||
locale.setlocale(locale.LC_ALL, "en_US")
|
||||
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||
@ -773,7 +766,8 @@ def test_subst_expand_inplace_1(photosdb):
|
||||
|
||||
template = "{person}"
|
||||
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)
|
||||
|
||||
|
||||
@ -784,7 +778,8 @@ def test_subst_expand_inplace_2(photosdb):
|
||||
|
||||
template = "{person}-{keyword}"
|
||||
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)
|
||||
|
||||
|
||||
@ -795,9 +790,9 @@ def test_subst_expand_inplace_3(photosdb):
|
||||
|
||||
template = "{person}-{keyword}"
|
||||
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)
|
||||
|
||||
|
||||
@ -837,8 +832,9 @@ def test_bool_values(photosdb_cloud):
|
||||
if uuid is not None:
|
||||
photo = photosdb_cloud.get_photo(uuid)
|
||||
edited = field == "edited_version"
|
||||
options = RenderOptions(edited_version=edited)
|
||||
rendered, _ = photo.render_template(
|
||||
"{" + f"{field}" + "?True,False}", edited=edited
|
||||
"{" + f"{field}" + "?True,False}", options
|
||||
)
|
||||
assert rendered[0] == "True"
|
||||
|
||||
@ -1019,3 +1015,29 @@ def test_function_filter_bad(photosdb):
|
||||
rendered, _ = photo.render_template(
|
||||
"{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)
|
||||
|
||||
@ -2,6 +2,9 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.phototemplate import RenderOptions
|
||||
|
||||
PHOTOS_DB_PLACES = (
|
||||
"./tests/Test-Places-Catalina-10_15_1.photoslibrary/database/photos.db"
|
||||
)
|
||||
@ -35,35 +38,40 @@ TODAY_VALUES = {
|
||||
}
|
||||
|
||||
|
||||
def test_subst_today():
|
||||
""" Test that substitutions are correct for {today.x}"""
|
||||
@pytest.fixture(scope="module")
|
||||
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 osxphotos
|
||||
|
||||
locale.setlocale(locale.LC_ALL, "en_US")
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||
|
||||
photo_template = osxphotos.PhotoTemplate(photo)
|
||||
photo_template.today = DATETIME_TODAY
|
||||
|
||||
options = RenderOptions()
|
||||
for template in TODAY_VALUES:
|
||||
rendered, _ = photo_template.render(template)
|
||||
rendered, _ = photo_template.render(template, options)
|
||||
assert rendered[0] == TODAY_VALUES[template]
|
||||
|
||||
|
||||
def test_subst_strftime_today():
|
||||
""" Test that strftime substitutions are correct for {today.strftime}"""
|
||||
def test_subst_strftime_today(photosdb):
|
||||
"""Test that strftime substitutions are correct for {today.strftime}"""
|
||||
import locale
|
||||
import osxphotos
|
||||
|
||||
locale.setlocale(locale.LC_ALL, "en_US")
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||
|
||||
photo_template = osxphotos.PhotoTemplate(photo)
|
||||
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"
|
||||
|
||||
rendered, unmatched = photo.render_template("{today.strftime}")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user