Added {filepath} template field in prep for --post-command and other goodies

This commit is contained in:
Rhet Turnbull
2021-06-13 18:40:45 -07:00
parent 2cdec3fc78
commit c0bd0ffc9f
9 changed files with 173 additions and 37 deletions

View File

@@ -1505,7 +1505,7 @@ Substitution Description
{lf} A line feed: '\n', alias for {newline} {lf} A line feed: '\n', alias for {newline}
{cr} A carriage return: '\r' {cr} A carriage return: '\r'
{crlf} a carriage return + line feed: '\r\n' {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 {osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for 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 /blob/master/examples/template_function.py for an
example of how to implement a template function. example of how to implement a template function.
The following substitutions are 'path-like'. You can access various parts of the The following substitutions are file or directory paths. You can access various
path using the following modifiers: parts of the path using the following modifiers:
{field.parent}: the parent directory {path.parent}: the parent directory
{field.name}: the name of the file or final sub-directory {path.name}: the name of the file or final sub-directory
{field.stem}: the name of the file without the extension {path.stem}: the name of the file without the extension
{field.suffix}: the suffix of the file including the leading '.' {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' {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 Substitution Description
{export_dir} The full path to the export directory {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}| |{lf}|A line feed: '\n', alias for {newline}|
|{cr}|A carriage return: '\r'| |{cr}|A carriage return: '\r'|
|{crlf}|a carriage return + line feed: '\r\n'| |{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| |{osxphotos_cmd_line}|The full command line used to run osxphotos|
|{album}|Album(s) photo is contained in| |{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| |{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|

View File

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

View File

@@ -1630,6 +1630,7 @@ def export(
jpeg_ext=jpeg_ext, jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords, replace_keywords=replace_keywords,
retry=retry, retry=retry,
export_dir=dest,
) )
if album_export and export_results.exported: if album_export and export_results.exported:
@@ -2251,6 +2252,7 @@ def export_photo(
jpeg_ext=None, jpeg_ext=None,
replace_keywords=False, replace_keywords=False,
retry=0, retry=0,
export_dir=None,
): ):
"""Helper function for export that does the actual export """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 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 replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
retry: retry up to retry # of times if there's an error retry: retry up to retry # of times if there's an error
export_dir: top-level export directory for {export_dir} template
Returns: Returns:
list of path(s) of exported photo or None if photo was missing list of path(s) of exported photo or None if photo was missing
@@ -2449,6 +2452,7 @@ def export_photo(
jpeg_ext=jpeg_ext, jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords, replace_keywords=replace_keywords,
retry=retry, retry=retry,
export_dir=export_dir,
) )
if export_edited and photo.hasadjustments: if export_edited and photo.hasadjustments:
@@ -2553,6 +2557,7 @@ def export_photo(
jpeg_ext=jpeg_ext, jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords, replace_keywords=replace_keywords,
retry=retry, retry=retry,
export_dir=export_dir,
) )
return results return results
@@ -2597,6 +2602,7 @@ def export_photo_with_template(
jpeg_ext, jpeg_ext,
replace_keywords, replace_keywords,
retry, retry,
export_dir,
): ):
"""Evaluate directory template then export photo to each directory""" """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)) results.missing.append(str(pathlib.Path(dest_path) / filename))
continue continue
render_options = RenderOptions(export_dir=export_dir)
tries = 0 tries = 0
while tries <= retry: while tries <= retry:
tries += 1 tries += 1
@@ -2684,6 +2692,7 @@ def export_photo_with_template(
exiftool_flags=exiftool_option, exiftool_flags=exiftool_option,
jpeg_ext=jpeg_ext, jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords, replace_keywords=replace_keywords,
render_options=render_options,
) )
for warning_ in export_results.exiftool_warning: for warning_ in export_results.exiftool_warning:
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}") verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")

View File

@@ -163,21 +163,27 @@ The following attributes may be used with '--xattr-template':
formatter.write("\n") formatter.write("\n")
formatter.write_text( 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:" + "You can access various parts of the path using the following modifiers:"
) )
formatter.write("\n") formatter.write("\n")
formatter.write("{field.parent}: the parent directory\n") formatter.write("{path.parent}: the parent directory\n")
formatter.write("{field.name}: the name of the file or final sub-directory\n") formatter.write("{path.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.stem}: the name of the file without the extension\n")
formatter.write( 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("\n")
formatter.write_text( formatter.write(
"For example, if the field {export_dir} is '/Shared/Backup/Photos', " "For example, if the field {export_dir} is '/Shared/Backup/Photos':\n")
+ "{export_dir.parent} is '/Shared/Backup'" 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") formatter.write("\n")
templ_tuples = [("Substitution", "Description")] templ_tuples = [("Substitution", "Description")]
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS_PATHLIB.items()) templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS_PATHLIB.items())

View File

@@ -15,6 +15,7 @@
# TODO: should this be its own PhotoExporter class? # 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 # 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 glob
import hashlib import hashlib
import json import json
@@ -24,6 +25,7 @@ import pathlib
import re import re
import tempfile import tempfile
from collections import namedtuple # pylint: disable=syntax-error from collections import namedtuple # pylint: disable=syntax-error
from typing import Optional
import photoscript import photoscript
from mako.template import Template from mako.template import Template
@@ -391,7 +393,7 @@ def export(
use_persons_as_keywords=False, use_persons_as_keywords=False,
keyword_template=None, keyword_template=None,
description_template=None, description_template=None,
export_dir=None, render_options: Optional[RenderOptions] = None,
): ):
"""export photo """export photo
dest: must be valid destination path (or exception raised) dest: must be valid destination path (or exception raised)
@@ -429,7 +431,7 @@ def export(
when exporting metadata with exiftool or sidecar when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description description_template: string; optional template string that will be rendered for use as photo description
export_dir: value to use for {export_dir} template render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer
Returns: list of photos exported Returns: list of photos exported
""" """
@@ -461,7 +463,7 @@ def export(
use_persons_as_keywords=use_persons_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template, keyword_template=keyword_template,
description_template=description_template, description_template=description_template,
export_dir=export_dir, render_options = render_options,
) )
return results.exported return results.exported
@@ -504,7 +506,7 @@ def export2(
persons=True, persons=True,
location=True, location=True,
replace_keywords=False, replace_keywords=False,
export_dir=None, render_options: Optional[RenderOptions] = None
): ):
"""export photo, like export but with update and dry_run options """export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised dest: must be valid destination path or exception raised
@@ -560,7 +562,7 @@ def export2(
persons: if True, include persons in exported metadata persons: if True, include persons in exported metadata
location: if True, include location in exported metadata location: if True, include location in exported metadata
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
export_dir: value to use for {export_dir} template render_options: optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
Returns: ExportResults class Returns: ExportResults class
ExportResults has attributes: ExportResults has attributes:
@@ -602,6 +604,8 @@ def export2(
if verbose is None: if verbose is None:
verbose = self._verbose verbose = self._verbose
self._render_options = render_options or RenderOptions()
# suffix to add to edited files # suffix to add to edited files
# e.g. name will be filename_edited.jpg # e.g. name will be filename_edited.jpg
edited_identifier = "_edited" edited_identifier = "_edited"
@@ -685,6 +689,7 @@ def export2(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}" f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
) )
self._render_options.filepath = str(dest)
all_results = ExportResults() all_results = ExportResults()
if not use_photos_export: if not use_photos_export:
# find the source file on disk and export # find the source file on disk and export
@@ -1598,7 +1603,7 @@ def _exiftool_dict(
) )
if description_template is not 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] rendered = self.render_template(description_template, options)[0]
description = " ".join(rendered) if rendered else "" description = " ".join(rendered) if rendered else ""
exif["EXIF:ImageDescription"] = description exif["EXIF:ImageDescription"] = description
@@ -1637,7 +1642,7 @@ def _exiftool_dict(
if keyword_template: if keyword_template:
rendered_keywords = [] 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: for template_str in keyword_template:
rendered, unmatched = self.render_template(template_str, options) rendered, unmatched = self.render_template(template_str, options)
if unmatched: if unmatched:
@@ -1915,7 +1920,7 @@ def _xmp_sidecar(
extension = extension.suffix[1:] if extension.suffix else None extension = extension.suffix[1:] if extension.suffix else None
if description_template is not None: if description_template is not None:
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] rendered = self.render_template(description_template, options)[0]
description = " ".join(rendered) if rendered else "" description = " ".join(rendered) if rendered else ""
else: else:
@@ -1948,7 +1953,7 @@ def _xmp_sidecar(
if keyword_template: if keyword_template:
rendered_keywords = [] 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: for template_str in keyword_template:
rendered, unmatched = self.render_template(template_str, options) rendered, unmatched = self.render_template(template_str, options)
if unmatched: if unmatched:

View File

@@ -78,6 +78,9 @@ class PhotoInfo:
self._db = db self._db = db
self._verbose = self._db._verbose self._verbose = self._db._verbose
# TODO: remove this once refactor of PhotoExporter is done
self._render_options = RenderOptions()
@property @property
def filename(self): def filename(self):
"""filename of the picture""" """filename of the picture"""

View File

@@ -146,6 +146,7 @@ TEMPLATE_SUBSTITUTIONS = {
TEMPLATE_SUBSTITUTIONS_PATHLIB = { TEMPLATE_SUBSTITUTIONS_PATHLIB = {
"{export_dir}": "The full path to the export directory", "{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) # Permitted multi-value substitutions (each of these returns None or 1 or more values)
@@ -504,9 +505,7 @@ class PhotoTemplate:
path_sep=path_sep, path_sep=path_sep,
) )
elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS: elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS:
vals = self.get_template_value_pathlib( vals = self.get_template_value_pathlib(field)
field,
)
else: else:
unmatched.append(field) unmatched.append(field)
return [], unmatched return [], unmatched
@@ -931,10 +930,7 @@ class PhotoTemplate:
return [value] return [value]
def get_template_value_pathlib( def get_template_value_pathlib(self, field):
self,
field,
):
"""lookup value for template pathlib template fields """lookup value for template pathlib template fields
Args: Args:
@@ -946,10 +942,17 @@ class PhotoTemplate:
Raises: Raises:
ValueError if no rule exists for field. 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}") 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: if self.filename:
value = sanitize_pathpart(value) value = sanitize_pathpart(value)

View File

@@ -473,6 +473,7 @@ CLI_UUID_DICT_15_7 = {
} }
CLI_TEMPLATE_SIDECAR_FILENAME = "Pumkins1.jpg.json" CLI_TEMPLATE_SIDECAR_FILENAME = "Pumkins1.jpg.json"
CLI_TEMPLATE_FILENAME = "Pumkins1.jpg"
CLI_UUID_DICT_14_6 = {"intrash": "3tljdX43R8+k6peNHVrJNQ"} CLI_UUID_DICT_14_6 = {"intrash": "3tljdX43R8+k6peNHVrJNQ"}
@@ -6286,3 +6287,79 @@ def test_query_regex_4():
json_got = json.loads(result.output) json_got = json.loads(result.output)
assert len(json_got) == 2 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
)

View File

@@ -1041,3 +1041,29 @@ def test_export_dir():
with pytest.raises(ValueError): with pytest.raises(ValueError):
rendered, _ = template.render("{export_dir.foo}", options) 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)