diff --git a/README.md b/README.md index df7ba724..49c2a905 100644 --- a/README.md +++ b/README.md @@ -1505,7 +1505,7 @@ Substitution Description {lf} A line feed: '\n', alias for {newline} {cr} A carriage return: '\r' {crlf} a carriage return + line feed: '\r\n' -{osxphotos_version} The osxphotos version, e.g. '0.42.31' +{osxphotos_version} The osxphotos version, e.g. '0.42.32' {osxphotos_cmd_line} The full command line used to run osxphotos The following substitutions may result in multiple values. Thus if specified for @@ -1574,19 +1574,26 @@ 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: +The following substitutions are file or directory paths. 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 '.' +{path.parent}: the parent directory +{path.name}: the name of the file or final sub-directory +{path.stem}: the name of the file without the extension +{path.suffix}: the suffix of the file including the leading '.' -For example, if the field {export_dir} is '/Shared/Backup/Photos', +For example, if the field {export_dir} is '/Shared/Backup/Photos': {export_dir.parent} is '/Shared/Backup' +If the field {filepath} is '/Shared/Backup/Photos/IMG_1234.JPG': +{filepath.parent} is '/Shared/Backup/Photos' +{filepath.name} is 'IMG_1234.JPG' +{filepath.stem} is 'IMG_1234' +{filepath.suffix} is '.JPG' + Substitution Description {export_dir} The full path to the export directory +{filepath} The full path to the exported file ``` @@ -3216,7 +3223,7 @@ The following template field substitutions are availabe for use the templating s |{lf}|A line feed: '\n', alias for {newline}| |{cr}|A carriage return: '\r'| |{crlf}|a carriage return + line feed: '\r\n'| -|{osxphotos_version}|The osxphotos version, e.g. '0.42.31'| +|{osxphotos_version}|The osxphotos version, e.g. '0.42.32'| |{osxphotos_cmd_line}|The full command line used to run osxphotos| |{album}|Album(s) photo is contained in| |{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder| diff --git a/osxphotos/_version.py b/osxphotos/_version.py index d3044ed3..521c287e 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.42.32" +__version__ = "0.42.33" diff --git a/osxphotos/cli.py b/osxphotos/cli.py index e5053a52..1dacce52 100644 --- a/osxphotos/cli.py +++ b/osxphotos/cli.py @@ -1630,6 +1630,7 @@ def export( jpeg_ext=jpeg_ext, replace_keywords=replace_keywords, retry=retry, + export_dir=dest, ) if album_export and export_results.exported: @@ -2251,6 +2252,7 @@ def export_photo( jpeg_ext=None, replace_keywords=False, retry=0, + export_dir=None, ): """Helper function for export that does the actual export @@ -2291,6 +2293,7 @@ def export_photo( jpeg_ext: if not None, specify the extension to use for all JPEG images on export replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords retry: retry up to retry # of times if there's an error + export_dir: top-level export directory for {export_dir} template Returns: list of path(s) of exported photo or None if photo was missing @@ -2449,6 +2452,7 @@ def export_photo( jpeg_ext=jpeg_ext, replace_keywords=replace_keywords, retry=retry, + export_dir=export_dir, ) if export_edited and photo.hasadjustments: @@ -2553,6 +2557,7 @@ def export_photo( jpeg_ext=jpeg_ext, replace_keywords=replace_keywords, retry=retry, + export_dir=export_dir, ) return results @@ -2597,6 +2602,7 @@ def export_photo_with_template( jpeg_ext, replace_keywords, retry, + export_dir, ): """Evaluate directory template then export photo to each directory""" @@ -2647,6 +2653,8 @@ def export_photo_with_template( results.missing.append(str(pathlib.Path(dest_path) / filename)) continue + render_options = RenderOptions(export_dir=export_dir) + tries = 0 while tries <= retry: tries += 1 @@ -2684,6 +2692,7 @@ def export_photo_with_template( exiftool_flags=exiftool_option, jpeg_ext=jpeg_ext, replace_keywords=replace_keywords, + render_options=render_options, ) for warning_ in export_results.exiftool_warning: verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}") diff --git a/osxphotos/cli_help.py b/osxphotos/cli_help.py index 70889590..8b4044f7 100644 --- a/osxphotos/cli_help.py +++ b/osxphotos/cli_help.py @@ -163,21 +163,27 @@ The following attributes may be used with '--xattr-template': formatter.write("\n") formatter.write_text( - "The following substitutions are 'path-like'. " + "The following substitutions are file or directory paths. " + "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("{path.parent}: the parent directory\n") + formatter.write("{path.name}: the name of the file or final sub-directory\n") + formatter.write("{path.stem}: the name of the file without the extension\n") formatter.write( - "{field.suffix}: the suffix of the file including the leading '.'\n" + "{path.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( + "For example, if the field {export_dir} is '/Shared/Backup/Photos':\n") + formatter.write("{export_dir.parent} is '/Shared/Backup'\n") + formatter.write("\n") + formatter.write( + "If the field {filepath} is '/Shared/Backup/Photos/IMG_1234.JPG':\n") + formatter.write("{filepath.parent} is '/Shared/Backup/Photos'\n") + formatter.write("{filepath.name} is 'IMG_1234.JPG'\n") + formatter.write("{filepath.stem} is 'IMG_1234'\n") + formatter.write("{filepath.suffix} is '.JPG'\n") formatter.write("\n") templ_tuples = [("Substitution", "Description")] templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS_PATHLIB.items()) diff --git a/osxphotos/photoinfo/_photoinfo_export.py b/osxphotos/photoinfo/_photoinfo_export.py index 1c612903..c6d5160d 100644 --- a/osxphotos/photoinfo/_photoinfo_export.py +++ b/osxphotos/photoinfo/_photoinfo_export.py @@ -15,6 +15,7 @@ # TODO: should this be its own PhotoExporter class? # TODO: the various sidecar_json, sidecar_xmp, etc args should all be collapsed to a sidecar param using a bit mask +import dataclasses import glob import hashlib import json @@ -24,6 +25,7 @@ import pathlib import re import tempfile from collections import namedtuple # pylint: disable=syntax-error +from typing import Optional import photoscript from mako.template import Template @@ -391,7 +393,7 @@ def export( use_persons_as_keywords=False, keyword_template=None, description_template=None, - export_dir=None, + render_options: Optional[RenderOptions] = None, ): """export photo dest: must be valid destination path (or exception raised) @@ -429,7 +431,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 + render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer Returns: list of photos exported """ @@ -461,7 +463,7 @@ def export( use_persons_as_keywords=use_persons_as_keywords, keyword_template=keyword_template, description_template=description_template, - export_dir=export_dir, + render_options = render_options, ) return results.exported @@ -504,7 +506,7 @@ def export2( persons=True, location=True, replace_keywords=False, - export_dir=None, + render_options: Optional[RenderOptions] = None ): """export photo, like export but with update and dry_run options dest: must be valid destination path or exception raised @@ -560,7 +562,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 + render_options: optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates Returns: ExportResults class ExportResults has attributes: @@ -602,6 +604,8 @@ def export2( if verbose is None: verbose = self._verbose + self._render_options = render_options or RenderOptions() + # suffix to add to edited files # e.g. name will be filename_edited.jpg edited_identifier = "_edited" @@ -685,6 +689,7 @@ def export2( f"destination exists ({dest}); overwrite={overwrite}, increment={increment}" ) + self._render_options.filepath = str(dest) all_results = ExportResults() if not use_photos_export: # find the source file on disk and export @@ -1598,7 +1603,7 @@ def _exiftool_dict( ) if description_template is not None: - options = RenderOptions(expand_inplace=True, inplace_sep=", ") + options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ") rendered = self.render_template(description_template, options)[0] description = " ".join(rendered) if rendered else "" exif["EXIF:ImageDescription"] = description @@ -1637,7 +1642,7 @@ def _exiftool_dict( if keyword_template: rendered_keywords = [] - options = RenderOptions(none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/") + options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/") for template_str in keyword_template: rendered, unmatched = self.render_template(template_str, options) if unmatched: @@ -1915,7 +1920,7 @@ def _xmp_sidecar( extension = extension.suffix[1:] if extension.suffix else None if description_template is not None: - options = RenderOptions(expand_inplace=True, inplace_sep=", ") + options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ") rendered = self.render_template(description_template, options)[0] description = " ".join(rendered) if rendered else "" else: @@ -1948,7 +1953,7 @@ def _xmp_sidecar( if keyword_template: rendered_keywords = [] - options = RenderOptions(none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/") + options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/") for template_str in keyword_template: rendered, unmatched = self.render_template(template_str, options) if unmatched: diff --git a/osxphotos/photoinfo/photoinfo.py b/osxphotos/photoinfo/photoinfo.py index 3834683f..4494ed53 100644 --- a/osxphotos/photoinfo/photoinfo.py +++ b/osxphotos/photoinfo/photoinfo.py @@ -78,6 +78,9 @@ class PhotoInfo: self._db = db self._verbose = self._db._verbose + # TODO: remove this once refactor of PhotoExporter is done + self._render_options = RenderOptions() + @property def filename(self): """filename of the picture""" diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index 47dab5db..bd1825ef 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -146,6 +146,7 @@ TEMPLATE_SUBSTITUTIONS = { TEMPLATE_SUBSTITUTIONS_PATHLIB = { "{export_dir}": "The full path to the export directory", + "{filepath}": "The full path to the exported file", } # Permitted multi-value substitutions (each of these returns None or 1 or more values) @@ -504,9 +505,7 @@ class PhotoTemplate: path_sep=path_sep, ) elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS: - vals = self.get_template_value_pathlib( - field, - ) + vals = self.get_template_value_pathlib(field) else: unmatched.append(field) return [], unmatched @@ -931,10 +930,7 @@ class PhotoTemplate: return [value] - def get_template_value_pathlib( - self, - field, - ): + def get_template_value_pathlib(self, field): """lookup value for template pathlib template fields Args: @@ -946,10 +942,17 @@ class PhotoTemplate: Raises: ValueError if no rule exists for field. """ - if field.split(".")[0] not in PATHLIB_SUBSTITUTIONS: + field_stem = field.split(".")[0] + if field_stem not in PATHLIB_SUBSTITUTIONS: raise ValueError(f"SyntaxError: Unknown field: {field}") - value = _get_pathlib_value(field, self.export_dir) + field_value = None + try: + field_value = getattr(self, field_stem) + except AttributeError: + raise ValueError(f"Unknown path-like field: {field_stem}") + + value = _get_pathlib_value(field, field_value) if self.filename: value = sanitize_pathpart(value) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9434a386..5449b82d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -473,6 +473,7 @@ CLI_UUID_DICT_15_7 = { } CLI_TEMPLATE_SIDECAR_FILENAME = "Pumkins1.jpg.json" +CLI_TEMPLATE_FILENAME = "Pumkins1.jpg" CLI_UUID_DICT_14_6 = {"intrash": "3tljdX43R8+k6peNHVrJNQ"} @@ -6286,3 +6287,79 @@ def test_query_regex_4(): json_got = json.loads(result.output) assert len(json_got) == 2 + + +def test_export_export_dir_template(): + """Test {export_dir} template""" + import json + import os + import os.path + import osxphotos + + from osxphotos.cli import cli + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + isolated_cwd = os.getcwd() + result = runner.invoke( + cli, + [ + "export", + "--db", + os.path.join(cwd, PHOTOS_DB_15_7), + ".", + "--sidecar=json", + f"--uuid={CLI_UUID_DICT_15_7['template']}", + "-V", + "--keyword-template", + "{person}", + "--description-template", + "{export_dir}", + ], + ) + assert result.exit_code == 0 + assert os.path.isfile(CLI_TEMPLATE_SIDECAR_FILENAME) + with open(CLI_TEMPLATE_SIDECAR_FILENAME, "r") as jsonfile: + exifdata = json.load(jsonfile) + assert exifdata[0]["XMP:Description"] == isolated_cwd + + +def test_export_filepath_template(): + """Test {filepath} template""" + import json + import os + import os.path + import osxphotos + + from osxphotos.cli import cli + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + isolated_cwd = os.getcwd() + result = runner.invoke( + cli, + [ + "export", + "--db", + os.path.join(cwd, PHOTOS_DB_15_7), + ".", + "--sidecar=json", + f"--uuid={CLI_UUID_DICT_15_7['template']}", + "-V", + "--keyword-template", + "{person}", + "--description-template", + "{filepath}", + ], + ) + assert result.exit_code == 0 + assert os.path.isfile(CLI_TEMPLATE_SIDECAR_FILENAME) + with open(CLI_TEMPLATE_SIDECAR_FILENAME, "r") as jsonfile: + exifdata = json.load(jsonfile) + assert exifdata[0]["XMP:Description"] == os.path.join( + isolated_cwd, CLI_TEMPLATE_FILENAME + ) diff --git a/tests/test_template.py b/tests/test_template.py index 0672cee5..b3b80cb9 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1041,3 +1041,29 @@ def test_export_dir(): with pytest.raises(ValueError): rendered, _ = template.render("{export_dir.foo}", options) + + +def test_filepath(): + """Test {filepath} template""" + from osxphotos.photoinfo import PhotoInfoNone + from osxphotos.phototemplate import PhotoTemplate + + options = RenderOptions(filepath="/foo/bar.jpeg") + template = PhotoTemplate(PhotoInfoNone()) + rendered, _ = template.render("{filepath}", options) + assert rendered[0] == "/foo/bar.jpeg" + + rendered, _ = template.render("{filepath.name}", options) + assert rendered[0] == "bar.jpeg" + + rendered, _ = template.render("{filepath.parent}", options) + assert rendered[0] == "/foo" + + rendered, _ = template.render("{filepath.stem}", options) + assert rendered[0] == "bar" + + rendered, _ = template.render("{filepath.suffix}", options) + assert rendered[0] == ".jpeg" + + with pytest.raises(ValueError): + rendered, _ = template.render("{filepath.foo}", options)