Refactored PhotoTemplate to support pathlib templates
This commit is contained in:
14
README.md
14
README.md
@@ -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 -->
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
""" version info """
|
""" 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 .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,12 +3194,15 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,11 +636,9 @@ 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)
|
||||||
|
|
||||||
@@ -646,8 +648,6 @@ class PhotoTemplate:
|
|||||||
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).
|
||||||
@@ -655,6 +655,10 @@ class PhotoTemplate:
|
|||||||
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,13 +1005,16 @@ 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}")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user