Added --post-command, implements #443

This commit is contained in:
Rhet Turnbull
2021-06-18 09:04:36 -07:00
parent ee0b369086
commit fa29f51aeb
11 changed files with 423 additions and 22 deletions

129
README.md
View File

@@ -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.
```
<!-- OSXPHOTOS-EXPORT-USAGE:END -->
@@ -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
<!-- OSXPHOTOS-FILTER-TABLE:END -->
@@ -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.|
<!-- OSXPHOTOS-TEMPLATE-TABLE:END -->

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.42.34"
__version__ = "0.42.35"

View File

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

View File

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

View File

@@ -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
<!-- OSXPHOTOS-FILTER-TABLE:END -->

View File

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

View File

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

View File

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