Added {filepath} template field in prep for --post-command and other goodies
This commit is contained in:
25
README.md
25
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|
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.42.32"
|
||||
__version__ = "0.42.33"
|
||||
|
||||
@@ -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]}")
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user