From fa29f51aeb89b3f14176693a9d0a5ff8c3565b71 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Fri, 18 Jun 2021 09:04:36 -0700 Subject: [PATCH] Added --post-command, implements #443 --- README.md | 129 ++++++++++++++++++++++++++++++++++++- docsrc/source/tutorial.md | 34 ++++++++++ osxphotos/__init__.py | 4 +- osxphotos/_constants.py | 21 ++++++ osxphotos/_version.py | 2 +- osxphotos/cli.py | 70 +++++++++++++++++++- osxphotos/cli_help.py | 56 +++++++++++++++- osxphotos/phototemplate.md | 1 + osxphotos/phototemplate.py | 43 +++++++++---- tests/test_cli.py | 80 +++++++++++++++++++++++ tests/test_template.py | 5 +- 11 files changed, 423 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index d13f87fd..18cdcbe3 100644 --- a/README.md +++ b/README.md @@ -482,6 +482,40 @@ Then the next to you run osxphotos, you can simply do this: The configuration file is a plain text file in [TOML](https://toml.io/en/) format so the `.toml` extension is standard but you can name the file anything you like. +#### Run commands on exported photos for post-processing + +You can use the `--post-command` option to run one or more commands against exported files. The `--post-command` option takes two arguments: CATEGORY and COMMAND. CATEGORY is a string that describes which category of file to run the command against. The available categories are described in the help text available via: `osxphotos help export`. For example, the `exported` category includes all exported photos and the `skipped` category includes all photos that were skipped when running export with `--update`. COMMAND is an osxphotos template string which will be rendered then passed to the shell for execution. + +For example, the following command generates a log of all exported files and their associated keywords: + +`osxphotos export /path/to/export --post-command exported "echo {shell_quote,{filepath}{comma}{,+keyword,}} >> {shell_quote,{export_dir}/exported.txt}"` + +The special template field `{shell_quote}` ensures a string is properly quoted for execution in the shell. For example, it's possible that a file path or keyword in this example has a space in the value and if not properly quoted, this would cause an error in the execution of the command. When running commands, the template `{filepath}` is set to the full path of the exported file and `{export_dir}` is set to the full path of the base export directory. + +Explanation of the template string: + +```txt +{shell_quote,{filepath}{comma}{,+keyword,}} + │ │ │ │ │ + │ │ │ | │ + └──> quote everything after comma for proper execution in the shell + │ │ │ │ + └───> filepath of the exported file + │ │ │ + └───> insert a comma + │ │ + └───> join the list of keywords together with a "," + │ + └───> if no keywords, insert nothing (empty string: "") +``` + +Another example: if you had `exiftool` installed and wanted to wipe all metadata from all exported files, you could use the following: + +`osxphotos export /path/to/export --post-command exported "/usr/local/bin/exiftool -all= {filepath|shell_quote}"` + +This command uses the `|shell_quote` template filter instead of the `{shell_quote}` template because the only thing that needs to be quoted is the path to the exported file. Template filters filter the value of the rendered template field. A number of other filters are available and are described in the help text. + + #### An example from an actual osxphotos user Here's a comprehensive use case from an actual osxphotos user that integrates many of the concepts discussed in this tutorial (thank-you Philippe for contributing this!): @@ -1009,6 +1043,24 @@ Options: feature is currently experimental. I don't know how well it will work on large export sets. + --post-command CATEGORY COMMAND + Run COMMAND on exported files of category + CATEGORY. CATEGORY can be one of: exported, + new, updated, skipped, missing, exif_updated, + touched, converted_to_jpeg, + sidecar_json_written, sidecar_json_skipped, + sidecar_exiftool_written, + sidecar_exiftool_skipped, sidecar_xmp_written, + sidecar_xmp_skipped, error. COMMAND is an + osxphotos template string, for example: '-- + post-command exported "echo + {filepath|shell_quote} >> + {export_dir}/exported.txt"', which appends the + full path of all exported files to the file + 'exported.txt'. You can run more than one + command by repeating the '--post-command' + option with different arguments. See Post + Command below. --exportdb EXPORTDB_FILE Specify alternate name for database file which stores state information for export and --update. If --exportdb is not specified, @@ -1166,6 +1218,8 @@ Valid filters are: • 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]' + • shell_quote: Quotes the value for safe usage in the shell, e.g. My file.jpeg + => 'My file.jpeg'; only adds quotes if needed. • 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 @@ -1505,7 +1559,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.34' +{osxphotos_version} The osxphotos version, e.g. '0.42.35' {osxphotos_cmd_line} The full command line used to run osxphotos The following substitutions may result in multiple values. Thus if specified for @@ -1564,6 +1618,10 @@ Substitution Description underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class. +{shell_quote} Use in form '{shell_quote,TEMPLATE}'; quotes the + rendered TEMPLATE value(s) for safe usage in the + shell, e.g. My file.jpeg => 'My file.jpeg'; only adds + quotes if needed. {function} Execute a python function from an external file and use return value as template substitution. Use in format: {function:file.py::function_name} where @@ -1596,6 +1654,71 @@ Substitution Description {filepath} The full path to the exported file +** Post Command ** +You can run commands on the exported photos for post-processing using the '-- +post-command' option. '--post-command' is passed a CATEGORY and a COMMAND. +COMMAND is an osxphotos template string which will be rendered and passed to the +shell for execution. CATEGORY is the category of file to pass to COMMAND. The +following categories are available: + +Catgory Description +exported All exported files +new When used with '--update', all newly exported files +updated When used with '--update', all files which were + previously exported but updated this time +skipped When used with '--update', all files which were + skipped (because they were previously exported and + didn't change) +missing All files which were not exported because they were + missing from the Photos library +exif_updated When used with '--exiftool', all files on which + exiftool updated the metadata +touched When used with '--touch-file', all files where the + date was touched +converted_to_jpeg When used with '--convert-to-jpeg', all files which + were converted to jpeg +sidecar_json_written When used with '--sidecar json', all JSON sidecar + files which were written +sidecar_json_skipped When used with '--sidecar json' and '--update', all + JSON sidecar files which were skipped +sidecar_exiftool_written When used with '--sidecar exiftool', all exiftool + sidecar files which were written +sidecar_exiftool_skipped When used with '--sidecar exiftool' and '--update, + all exiftool sidecar files which were skipped +sidecar_xmp_written When used with '--sidecar xmp', all XMP sidecar + files which were written +sidecar_xmp_skipped When used with '--sidecar xmp' and '--update', all + XMP sidecar files which were skipped +error All files which produced an error during export + +In addition to all normal template fields, the template fields '{filepath}' and +'{export_dir}' will be available to your command template. Both of these are +path-type templates which means their various parts can be accessed using the +available properties, e.g. '{filepath.name}' provides just the file name without +path and '{filepath.suffix}' is the file extension (suffix) of the file. When +using paths in your command template, it is important to properly quote the +paths as they will be passed to the shell and path names may contain spaces. +Both the '{shell_quote}' template and the '|shell_quote' template filter are +available for this purpose. For example, the following command outputs the full +path of newly exported files to file 'new.txt': + +--post-command new "echo {filepath.name|shell_quote} >> {shell_quote,{export_dir}/exported.txt}" + +In the above command, the 'shell_quote' filter is used to ensure +'{filepath.name}' is properly quoted and the '{shell_quote}' template ensures +the constructed path of '{exported_dir}/exported.txt' is properly quoted. If +'{filepath.name}' is 'IMG 1234.jpeg' and '{export_dir}' is '/Volumes/Photo +Export', the command thus renders to: + +echo 'IMG 1234.jpeg' >> '/Volumes/Photo Export/exported.txt' + +It is highly recommended that you run osxphotos with '--dry-run --verbose' first +to ensure your commands are as expected. This will not actually run the commands +but will print out the exact command string which would be executed. + + + + ``` @@ -3047,6 +3170,7 @@ Valid filters are: - 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]' +- shell_quote: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed. - 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 @@ -3223,7 +3347,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.34'| +|{osxphotos_version}|The osxphotos version, e.g. '0.42.35'| |{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| @@ -3238,6 +3362,7 @@ The following template field substitutions are availabe for use the templating s |{searchinfo.venue}|Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).| |{searchinfo.venue_type}|Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).| |{photo}|Provides direct access to the PhotoInfo object for the photo. Must be used in format '{photo.property}' where 'property' represents a PhotoInfo property. For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. '{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of the underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.| +|{shell_quote}|Use in form '{shell_quote,TEMPLATE}'; quotes the rendered TEMPLATE value(s) for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.| |{function}|Execute a python function from an external file and use return value as template substitution. Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. The function will be passed the PhotoInfo object for the photo. See https://github.com/RhetTbull/osxphotos/blob/master/examples/template_function.py for an example of how to implement a template function.| diff --git a/docsrc/source/tutorial.md b/docsrc/source/tutorial.md index fe2421ed..8ab48d1a 100644 --- a/docsrc/source/tutorial.md +++ b/docsrc/source/tutorial.md @@ -315,6 +315,40 @@ Then the next to you run osxphotos, you can simply do this: The configuration file is a plain text file in [TOML](https://toml.io/en/) format so the `.toml` extension is standard but you can name the file anything you like. +### Run commands on exported photos for post-processing + +You can use the `--post-command` option to run one or more commands against exported files. The `--post-command` option takes two arguments: CATEGORY and COMMAND. CATEGORY is a string that describes which category of file to run the command against. The available categories are described in the help text available via: `osxphotos help export`. For example, the `exported` category includes all exported photos and the `skipped` category includes all photos that were skipped when running export with `--update`. COMMAND is an osxphotos template string which will be rendered then passed to the shell for execution. + +For example, the following command generates a log of all exported files and their associated keywords: + +`osxphotos export /path/to/export --post-command exported "echo {shell_quote,{filepath}{comma}{,+keyword,}} >> {shell_quote,{export_dir}/exported.txt}"` + +The special template field `{shell_quote}` ensures a string is properly quoted for execution in the shell. For example, it's possible that a file path or keyword in this example has a space in the value and if not properly quoted, this would cause an error in the execution of the command. When running commands, the template `{filepath}` is set to the full path of the exported file and `{export_dir}` is set to the full path of the base export directory. + +Explanation of the template string: + +```txt +{shell_quote,{filepath}{comma}{,+keyword,}} + │ │ │ │ │ + │ │ │ | │ + └──> quote everything after comma for proper execution in the shell + │ │ │ │ + └───> filepath of the exported file + │ │ │ + └───> insert a comma + │ │ + └───> join the list of keywords together with a "," + │ + └───> if no keywords, insert nothing (empty string: "") +``` + +Another example: if you had `exiftool` installed and wanted to wipe all metadata from all exported files, you could use the following: + +`osxphotos export /path/to/export --post-command exported "/usr/local/bin/exiftool -all= {filepath|shell_quote}"` + +This command uses the `|shell_quote` template filter instead of the `{shell_quote}` template because the only thing that needs to be quoted is the path to the exported file. Template filters filter the value of the rendered template field. A number of other filters are available and are described in the help text. + + ### An example from an actual osxphotos user Here's a comprehensive use case from an actual osxphotos user that integrates many of the concepts discussed in this tutorial (thank-you Philippe for contributing this!): diff --git a/osxphotos/__init__.py b/osxphotos/__init__.py index 4a93a89d..33f27e4b 100644 --- a/osxphotos/__init__.py +++ b/osxphotos/__init__.py @@ -1,5 +1,6 @@ from ._version import __version__ -from .photoinfo import PhotoInfo +from .exiftool import ExifTool +from .photoinfo import ExportResults, PhotoInfo from .photosdb import PhotosDB from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo from .phototemplate import PhotoTemplate @@ -7,5 +8,4 @@ from .queryoptions import QueryOptions from .utils import _debug, _get_logger, _set_debug # TODO: Add test for imageTimeZoneOffsetSeconds = None -# TODO: Add test for __str__ and to_json # TODO: Add special albums and magic albums diff --git a/osxphotos/_constants.py b/osxphotos/_constants.py index 782c705b..009431cb 100644 --- a/osxphotos/_constants.py +++ b/osxphotos/_constants.py @@ -220,3 +220,24 @@ BURST_KEY = 0b10000 # 16: burst image is the key photo (top of burst stack) BURST_UNKNOWN = 0b100000 # 32: this is almost always set with BURST_DEFAULT_PICK and never if BURST_DEFAULT_PICK is not set. I think this has something to do with what algorithm Photos used to pick the default image LIVE_VIDEO_EXTENSIONS = [".mov"] + +# categories that --post-command can be used with; these map to ExportResults fields +POST_COMMAND_CATEGORIES = { + "exported": "All exported files", + "new": "When used with '--update', all newly exported files", + "updated": "When used with '--update', all files which were previously exported but updated this time", + "skipped": "When used with '--update', all files which were skipped (because they were previously exported and didn't change)", + "missing": "All files which were not exported because they were missing from the Photos library", + "exif_updated": "When used with '--exiftool', all files on which exiftool updated the metadata", + "touched": "When used with '--touch-file', all files where the date was touched", + "converted_to_jpeg": "When used with '--convert-to-jpeg', all files which were converted to jpeg", + "sidecar_json_written": "When used with '--sidecar json', all JSON sidecar files which were written", + "sidecar_json_skipped": "When used with '--sidecar json' and '--update', all JSON sidecar files which were skipped", + "sidecar_exiftool_written": "When used with '--sidecar exiftool', all exiftool sidecar files which were written", + "sidecar_exiftool_skipped": "When used with '--sidecar exiftool' and '--update, all exiftool sidecar files which were skipped", + "sidecar_xmp_written": "When used with '--sidecar xmp', all XMP sidecar files which were written", + "sidecar_xmp_skipped": "When used with '--sidecar xmp' and '--update', all XMP sidecar files which were skipped", + "error": "All files which produced an error during export", + # "deleted_files": "When used with '--cleanup', all files deleted during the export", + # "deleted_directories": "When used with '--cleanup', all directories deleted during the export", +} diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 2974939e..58c9d624 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.42.34" +__version__ = "0.42.35" diff --git a/osxphotos/cli.py b/osxphotos/cli.py index 1dacce52..1c3626cd 100644 --- a/osxphotos/cli.py +++ b/osxphotos/cli.py @@ -7,6 +7,8 @@ import os import os.path import pathlib import pprint +import shlex +import subprocess import sys import time @@ -31,10 +33,10 @@ from ._constants import ( EXTENDED_ATTRIBUTE_NAMES_QUOTED, OSXPHOTOS_EXPORT_DB, OSXPHOTOS_URL, + POST_COMMAND_CATEGORIES, SIDECAR_EXIFTOOL, SIDECAR_JSON, SIDECAR_XMP, - UNICODE_FORMAT, ) from ._version import __version__ from .cli_help import ExportCommand @@ -51,7 +53,7 @@ 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 .phototemplate import PhotoTemplate, RenderOptions from .queryoptions import QueryOptions from .utils import get_preferred_uti_extension @@ -911,6 +913,19 @@ def cli(ctx, db, json_, debug): "This only works if the Photos library being exported is the last-opened (default) library in Photos. " "This feature is currently experimental. I don't know how well it will work on large export sets.", ) +@click.option( + "--post-command", + metavar="CATEGORY COMMAND", + nargs=2, + type=(click.Choice(POST_COMMAND_CATEGORIES, case_sensitive=False), str), + multiple=True, + help="Run COMMAND on exported files of category CATEGORY. CATEGORY can be one of: " + f"{', '.join(list(POST_COMMAND_CATEGORIES.keys()))}. " + "COMMAND is an osxphotos template string, for example: '--post-command exported \"echo {filepath|shell_quote} >> {export_dir}/exported.txt\"', " + "which appends the full path of all exported files to the file 'exported.txt'. " + "You can run more than one command by repeating the '--post-command' option with different arguments. " + "See Post Command below.", +) @click.option( "--exportdb", metavar="EXPORTDB_FILE", @@ -1075,6 +1090,7 @@ def export( regex, query_eval, duplicate, + post_command, ): """Export photos from the Photos database. Export path DEST is required. @@ -1230,6 +1246,7 @@ def export( regex = cfg.regex query_eval = cfg.query_eval duplicate = cfg.duplicate + post_command = cfg.post_command # config file might have changed verbose VERBOSE = bool(verbose) @@ -1633,6 +1650,15 @@ def export( export_dir=dest, ) + run_post_command( + photo=p, + post_command=post_command, + export_results=export_results, + export_dir=dest, + dry_run=dry_run, + exiftool_path=exiftool_path, + ) + if album_export and export_results.exported: try: album_export.add(p) @@ -3275,6 +3301,46 @@ def write_extended_attributes( return list(written), [f for f in skipped if f not in written] +def run_post_command( + photo, post_command, export_results, export_dir, dry_run, exiftool_path +): + # todo: pass in RenderOptions from export? (e.g. so it contains strip, etc?) + # todo: need a shell_quote template type: + # {shell_quote,{filepath}/foo/bar} + # that quotes everything in the default value + for category, command_template in post_command: + files = getattr(export_results, category) + for f in files: + # some categories, like error, return a tuple of (file, error str) + if isinstance(f, tuple): + f = f[0] + render_options = RenderOptions(export_dir=export_dir, filepath=f) + template = PhotoTemplate(photo, exiftool_path=exiftool_path) + command, _ = template.render(command_template, options=render_options) + command = command[0] if command else None + if command: + verbose_(f'Running command: "{command}"') + if not dry_run: + args = shlex.split(command) + cwd = pathlib.Path(f).parent + run_error = None + run_results = None + try: + run_results = subprocess.run(command, shell=True, cwd=cwd) + except Exception as e: + run_error = e + finally: + run_error = run_error or run_results.returncode + if run_error: + click.echo( + click.style( + f'Error running command "{command}": {run_error}', + fg=CLI_COLOR_ERROR, + ), + err=True, + ) + + @cli.command(hidden=True) @DB_OPTION @DB_ARGUMENT diff --git a/osxphotos/cli_help.py b/osxphotos/cli_help.py index 8b4044f7..4119cc98 100644 --- a/osxphotos/cli_help.py +++ b/osxphotos/cli_help.py @@ -12,6 +12,7 @@ from ._constants import ( EXTENDED_ATTRIBUTE_NAMES, EXTENDED_ATTRIBUTE_NAMES_QUOTED, OSXPHOTOS_EXPORT_DB, + POST_COMMAND_CATEGORIES, ) from .phototemplate import ( TEMPLATE_SUBSTITUTIONS, @@ -21,6 +22,7 @@ from .phototemplate import ( ) +# TODO: The following help text could probably be done as mako template class ExportCommand(click.Command): """Custom click.Command that overrides get_help() to show additional help info for export""" @@ -175,11 +177,13 @@ The following attributes may be used with '--xattr-template': ) formatter.write("\n") formatter.write( - "For example, if the field {export_dir} is '/Shared/Backup/Photos':\n") + "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") + "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") @@ -190,6 +194,54 @@ The following attributes may be used with '--xattr-template': formatter.write_dl(templ_tuples) + formatter.write("\n\n") + formatter.write( + rich_text("[bold]** Post Command **[/bold]", width=formatter.width) + ) + formatter.write_text( + "You can run commands on the exported photos for post-processing " + + "using the '--post-command' option. '--post-command' is passed a CATEGORY and a COMMAND. " + + "COMMAND is an osxphotos template string which will be rendered and passed to the shell " + + "for execution. CATEGORY is the category of file to pass to COMMAND. " + + "The following categories are available: " + ) + formatter.write("\n") + templ_tuples = [("Catgory", "Description")] + templ_tuples.extend((k, v) for k, v in POST_COMMAND_CATEGORIES.items()) + formatter.write_dl(templ_tuples) + formatter.write("\n") + formatter.write_text( + "In addition to all normal template fields, the template fields " + + "'{filepath}' and '{export_dir}' will be available to your command template. " + + "Both of these are path-type templates which means their various parts can be accessed using " + + "the available properties, e.g. '{filepath.name}' provides just the file name without path " + + "and '{filepath.suffix}' is the file extension (suffix) of the file. " + + "When using paths in your command template, it is important to properly quote the paths " + + "as they will be passed to the shell and path names may contain spaces. " + + "Both the '{shell_quote}' template and the '|shell_quote' template filter are available for " + + "this purpose. For example, the following command outputs the full path of newly exported files to file 'new.txt': " + ) + formatter.write("\n") + formatter.write( + '--post-command new "echo {filepath.name|shell_quote} >> {shell_quote,{export_dir}/exported.txt}"' + ) + formatter.write("\n\n") + formatter.write_text( + "In the above command, the 'shell_quote' filter is used to ensure '{filepath.name}' is properly quoted " + + "and the '{shell_quote}' template ensures the constructed path of '{exported_dir}/exported.txt' is properly quoted. " + "If '{filepath.name}' is 'IMG 1234.jpeg' and '{export_dir}' is '/Volumes/Photo Export', the command " + "thus renders to: " + ) + formatter.write("\n") + formatter.write("echo 'IMG 1234.jpeg' >> '/Volumes/Photo Export/exported.txt'") + formatter.write("\n\n") + formatter.write_text( + "It is highly recommended that you run osxphotos with '--dry-run --verbose' " + + "first to ensure your commands are as expected. This will not actually run the commands but will " + + "print out the exact command string which would be executed." + ) + formatter.write("\n\n") + help_text += formatter.getvalue() return help_text diff --git a/osxphotos/phototemplate.md b/osxphotos/phototemplate.md index 603a43dd..742565b2 100644 --- a/osxphotos/phototemplate.md +++ b/osxphotos/phototemplate.md @@ -39,6 +39,7 @@ Valid filters are: - 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]' +- shell_quote: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed. - 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 diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index bd1825ef..929858ef 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -5,6 +5,7 @@ import locale import os import pathlib import sys +import shlex from textx import TextXSyntaxError, metamodel_from_file @@ -173,6 +174,7 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = { + "For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. " + "'{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of " + "the underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.", + "{shell_quote}": "Use in form '{shell_quote,TEMPLATE}'; quotes the rendered TEMPLATE value(s) for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.", "{function}": "Execute a python function from an external file and use return value as template substitution. " + "Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. " + "The function will be passed the PhotoInfo object for the photo. " @@ -188,6 +190,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]'", + "shell_quote": "Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.", "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", } @@ -248,6 +251,7 @@ class RenderOptions: 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 + quote: quote path templates for execution in the shell """ none_str: str = "_" @@ -260,6 +264,7 @@ class RenderOptions: edited_version: bool = False export_dir: Optional[str] = None filepath: Optional[str] = None + quote: bool = False class PhotoTemplateParser: @@ -320,6 +325,7 @@ class PhotoTemplate: self.strip = options.strip self.export_dir = options.export_dir self.filepath = options.filepath + self.quote = options.quote def render( self, @@ -349,6 +355,7 @@ class PhotoTemplate: self.strip = options.strip self.export_dir = options.export_dir self.filepath = options.filepath + self.quote = options.quote try: model = self.parser.parse(template) @@ -501,8 +508,7 @@ class PhotoTemplate: ) elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"): vals = self.get_template_value_multi( - field, - path_sep=path_sep, + field, path_sep=path_sep, default=default ) elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS: vals = self.get_template_value_pathlib(field) @@ -952,7 +958,7 @@ class PhotoTemplate: except AttributeError: raise ValueError(f"Unknown path-like field: {field_stem}") - value = _get_pathlib_value(field, field_value) + value = _get_pathlib_value(field, field_value, self.quote) if self.filename: value = sanitize_pathpart(value) @@ -1002,22 +1008,24 @@ class PhotoTemplate: value = ["[" + v + "]" for v in values] else: value = ["[" + values + "]"] if values else [] + elif filter_ == "shell_quote": + if values and type(values) == list: + value = [shlex.quote(v) for v in values] + else: + value = [shlex.quote(values)] if values else [] elif filter_.startswith("function:"): value = self.get_template_value_filter_function(filter_, values) else: value = [] return value - def get_template_value_multi( - self, - field, - path_sep, - ): + def get_template_value_multi(self, field, path_sep, default): """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 + default: value of default field Returns: List of the matching template values or []. @@ -1084,6 +1092,8 @@ class PhotoTemplate: values = ( self.photo.search_info.venue_types if self.photo.search_info else [] ) + elif field == "shell_quote": + values = [shlex.quote(v) for v in default if v] elif field.startswith("photo"): # provide access to PhotoInfo object properties = field.split(".") @@ -1292,12 +1302,18 @@ def get_template_help(): return md -def _get_pathlib_value(field, value): - """Get the value for a pathlib.Path type template""" +def _get_pathlib_value(field, value, quote): + """Get the value for a pathlib.Path type template + + Args: + field: the path field, e.g. "filename.stem" + value: the value for the path component + quote: bool; if true, quotes the returned path for safe execution in the shell + """ parts = field.split(".") if len(parts) == 1: - return value + return shlex.quote(value) if quote else value if len(parts) > 2: raise ValueError(f"Illegal value for path template: {field}") @@ -1307,6 +1323,9 @@ def _get_pathlib_value(field, value): path = pathlib.Path(value) try: val = getattr(path, attribute) - return str(val) + val_str = str(val) + if quote: + val_str = shlex.quote(val_str) + return val_str except AttributeError: raise ValueError("Illegal value for path template: {attribute}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 5449b82d..f6fa1f42 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6363,3 +6363,83 @@ def test_export_filepath_template(): assert exifdata[0]["XMP:Description"] == os.path.join( isolated_cwd, CLI_TEMPLATE_FILENAME ) + + +def test_export_post_command(): + """Test --post-command""" + import os.path + from osxphotos.cli import cli + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + cli, + [ + "export", + "--db", + os.path.join(cwd, PHOTOS_DB_15_7), + ".", + "--post-command", + "exported", + "echo {filepath.name|shell_quote} >> {export_dir}/exported.txt", + "--name", + "Park", + "--skip-original-if-edited", + ], + ) + assert result.exit_code == 0 + with open("exported.txt") as f: + lines = [line.strip() for line in f] + assert lines[0] == "St James Park_edited.jpeg" + + # run again with --update to test skipped + result = runner.invoke( + cli, + [ + "export", + "--db", + os.path.join(cwd, PHOTOS_DB_15_7), + ".", + "--post-command", + "skipped", + "echo {filepath.name|shell_quote} >> {export_dir}/skipped.txt", + "--name", + "Park", + "--skip-original-if-edited", + "--update", + ], + ) + assert result.exit_code == 0 + with open("skipped.txt") as f: + lines = [line.strip() for line in f] + assert lines[0] == "St James Park_edited.jpeg" + + +def test_export_post_command_bad_command(): + """Test --post-command with bad command""" + import os.path + from osxphotos.cli import cli + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + cli, + [ + "export", + "--db", + os.path.join(cwd, PHOTOS_DB_15_7), + ".", + "--post-command", + "exported", + "foobar {filepath.name|shell_quote} >> {export_dir}/exported.txt", + "--name", + "Park", + "--skip-original-if-edited", + ], + ) + assert result.exit_code == 0 + assert 'Error running command "foobar' in result.output diff --git a/tests/test_template.py b/tests/test_template.py index b3b80cb9..86c6206f 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -66,6 +66,7 @@ TEMPLATE_VALUES_MULTI_KEYWORDS = { "{keyword|lower}": ["flowers", "wedding"], "{keyword|titlecase}": ["Flowers", "Wedding"], "{keyword|capitalize}": ["Flowers", "Wedding"], + "{keyword|shell_quote}": ["flowers", "wedding"], "{+keyword}": ["flowerswedding"], "{+keyword|titlecase}": ["Flowerswedding"], "{+keyword|capitalize}": ["Flowerswedding"], @@ -81,6 +82,7 @@ TEMPLATE_VALUES_TITLE = { "{title|titlecase}": ["Tulips Tied Together At A Flower Shop"], "{title|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"], "{title|titlecase|lower|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"], + "{title|titlecase|lower|upper|shell_quote}": ["'TULIPS TIED TOGETHER AT A FLOWER SHOP'"], "{title|upper|titlecase}": ["Tulips Tied Together At A Flower Shop"], "{title|capitalize}": ["Tulips tied together at a flower shop"], "{title[ ,_]}": ["Tulips_tied_together_at_a_flower_shop"], @@ -90,6 +92,7 @@ TEMPLATE_VALUES_TITLE = { "{+title}": ["Tulips tied together at a flower shop"], "{,+title}": ["Tulips tied together at a flower shop"], "{, +title}": ["Tulips tied together at a flower shop"], + "{title|shell_quote}": ["'Tulips tied together at a flower shop'"], } # Boolean type values that render to True @@ -385,7 +388,7 @@ def test_lookup_multi(photosdb_places): lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1) if subst in ["{exiftool}", "{photo}", "{function}"]: continue - lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep) + lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep, default=[]) assert isinstance(lookup, list)