Refactored PhotoTemplate to support pathlib templates

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

View File

@ -1574,6 +1574,20 @@ Substitution Description
/blob/master/examples/template_function.py for an
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 -->

View File

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

View File

@ -50,9 +50,10 @@ from .fileutil import FileUtil, FileUtilNoOp
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .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,13 +3194,16 @@ 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

View File

@ -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())
@ -178,14 +211,14 @@ def rich_text(text, width=78):
def strip_md_links(md):
"""strip markdown links from markdown text md
Args:
md: str, markdown text
Returns:
str with markdown links removed
Note: This uses a very basic regex that likely fails on all sorts of edge cases
Note: This uses a very basic regex that likely fails on all sorts of edge cases
but works for the links in the osxphotos docs
"""
links = r"(?:[*#])|\[(.*?)\]\(.+?\)"
@ -194,4 +227,3 @@ def strip_md_links(md):
return match.group(1)
return re.sub(links, subfn, md)

View File

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

View File

@ -36,10 +36,10 @@ from .._constants import (
_UNKNOWN_PERSON,
_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}"

View File

@ -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

View File

@ -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)

View File

@ -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)
template: str template
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,29 +636,29 @@ 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)
Args:
field: template field to find value for.
default: the default value provided by the user
bool_val: True value if expression is boolean
bool_val: True value if expression is boolean
delim: delimiter for expand in place
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).
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,14 +1005,17 @@ 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}")

View File

@ -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):

View File

@ -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)

View File

@ -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}")