Updated render_filepath_template to support multiple values

This commit is contained in:
Rhet Turnbull
2020-04-04 09:53:23 -07:00
parent 01cd7fed6d
commit 6a898886dd
6 changed files with 309 additions and 55 deletions

View File

@@ -997,9 +997,14 @@ Render template string for photo. none_str is used if template substitution res
- `photo`: a [PhotoInfo](#photoinfo) object
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
Returns a tuple of (rendered, unmatched) where rendered is the rendered template string with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. strings in the form "{foo}".
Returns a tuple of (rendered, unmatched) where rendered is a list of rendered strings with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. if template contained "{foo}", unmatched would be ["foo"].
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `("2020/{foo}",["foo"])`
If you want to include "{" or "}" in the output, use "{{" or "}}"
e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `("2020/{foo}",[])`
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `("2020/{foo}",["{foo}"])`
| Substitution | Description |
|--------------|-------------|

View File

@@ -1518,6 +1518,7 @@ def export_photo(
dest = create_path_by_date(dest, date_created)
elif directory:
dirname, unmatched = render_filepath_template(directory, photo)
dirname = dirname[0]
if unmatched:
click.echo(
f"Possible unmatched substitution in template: {unmatched}", err=True

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.24.4"
__version__ = "0.24.5"

View File

@@ -1,10 +1,23 @@
""" Custom template system for osxphotos """
# Rolled my own template system because:
# 1. Needed to handle multiple values (e.g. album, keyword)
# 2. Needed to handle default values if template not found
# 3. Didn't want user to need to know python (e.g. by using Mako which is
# already used elsewhere in this project)
# 4. Couldn't figure out how to do #1 and #2 with str.format()
#
# This code isn't elegant but it seems to work well. PRs gladly accepted.
import datetime
import pathlib
import re
from typing import Tuple # pylint: disable=syntax-error
from typing import Tuple, List # pylint: disable=syntax-error
from .photoinfo import PhotoInfo
from ._constants import _UNKNOWN_PERSON
# Permitted substitutions (each of these returns a single value or None)
TEMPLATE_SUBSTITUTIONS = {
"{name}": "Filename of the photo",
"{original_name}": "Photo's original filename when imported to Photos",
@@ -39,9 +52,23 @@ TEMPLATE_SUBSTITUTIONS = {
"{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
}
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{album}": "Album photo is contained in",
"{keyword}": "Keywords assigned to photo",
"{person}": "Person / face in a photo",
}
# Just the multi-valued substitution names without the braces
MULTI_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "")
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.keys()
]
def get_template_value(lookup, photo):
""" lookup: value to find a match for
""" lookup template value (single-value template substitutions) for use in make_subst_function
lookup: value to find a match for
photo: PhotoInfo object whose data will be used for value substitutions
returns: either the matching template value (which may be None)
raises: KeyError if no rule exists for lookup """
@@ -202,29 +229,24 @@ def get_template_value(lookup, photo):
raise KeyError(f"No rule for processing {lookup}")
def render_filepath_template(
template: str, photo: PhotoInfo, none_str: str = "_"
) -> Tuple[str, list]:
""" render a filename or directory template """
def render_filepath_template(template, photo, none_str="_"):
""" render a filename or directory template
template: str template
photo: PhotoInfo object
none_str: str to use default for None values, default is '_' """
# the rendering happens in two phases:
# phase 1: handle all the single-value template substitutions
# results in a single string with all the template fields replaced
# phase 2: loop through all the multi-value template substitutions
# could result in multiple strings
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
# there would be 6 possible renderings (2 albums x 3 persons)
# regex to find {template_field,optional_default} in strings
# for explanation of regex see https://regex101.com/r/4JJg42/1
# pylint: disable=anomalous-backslash-in-string
regex = r"""(?<!\\)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)\}"""
# pylint: disable=anomalous-backslash-in-string
unmatched_regex = r"(?<!\\)(\{[^\\,}]+\})"
# Explanation for regex:
# (?<!\\) Negative Lookbehind to skip escaped braces
# assert regex following does not match "\" preceeding "{"
# \{ Match the opening brace
# 1st Capturing Group ([^\\,}]+) Don't match "\", ",", or "}"
# 2nd Capturing Group (,?(([\w\-. ]+))?)
# ,{0,1} optional ","
# 3rd Capturing Group (([\w\-. ]+))?
# Matches the comma and any word characters after
# 4th Capturing Group ([\w\-. ]+)
# Matches just the characters after the comma
# \} Matches the closing brace
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)(?=\}(?!\}))\}"
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
@@ -232,14 +254,19 @@ def render_filepath_template(
if type(photo) is not PhotoInfo:
raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}")
def make_subst_function(photo, none_str):
""" returns: substitution function for use in re.sub """
def make_subst_function(photo, none_str, get_func=get_template_value):
""" returns: substitution function for use in re.sub
photo: a PhotoInfo object
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
# closure to capture photo, none_str in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 4:
try:
val = get_template_value(matchobj.group(1), photo)
val = get_func(matchobj.group(1), photo)
except KeyError:
return matchobj.group(0)
@@ -261,14 +288,92 @@ def render_filepath_template(
# do the replacements
rendered = re.sub(regex, subst_func, template)
# find any {words} that weren't replaced
unmatched = re.findall(unmatched_regex, rendered)
# do multi-valued placements
# start with the single string from phase 1 above then loop through all
# multi-valued fields and all values for each of those fields
# rendered_strings will be updated as each field is processed
# for example: if two albums, two keywords, and one person and template is:
# "{created.year}/{album}/{keyword}/{person}"
# rendered strings would do the following:
# start (created.year filled in phase 1)
# ['2011/{album}/{keyword}/{person}']
# after processing albums:
# ['2011/Album1/{keyword}/{person}',
# '2011/Album2/{keyword}/{person}',]
# after processing keywords:
# ['2011/Album1/keyword1/{person}',
# '2011/Album1/keyword2/{person}',
# '2011/Album2/keyword1/{person}',
# '2011/Album2/keyword2/{person}',]
# after processing person:
# ['2011/Album1/keyword1/person1',
# '2011/Album1/keyword2/person1',
# '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',]
rendered_strings = [rendered]
for field in MULTI_VALUE_SUBSTITUTIONS:
if field == "album":
values = photo.albums
elif field == "keyword":
values = photo.keywords
elif field == "person":
values = photo.persons
# remove any _UNKNOWN_PERSON values
try:
values.remove(_UNKNOWN_PERSON)
except:
pass
else:
raise ValueError(f"Unhandleded template value: {field}")
# If no values, insert None so code below will substite none_str for None
values = values or [None]
# Build a regex that matches only the field being processed
re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}"
regex_multi = re.compile(re_str)
new_strings = [] # holds each of the new rendered_strings
for str_template in rendered_strings:
for val in values:
def get_template_value_multi(lookup_value, photo):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification """
if lookup_value == field:
return val
else:
raise KeyError(f"Unexpected value: {lookup_value}")
subst = make_subst_function(
photo, none_str, get_func=get_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
new_strings.append(new_string)
# update rendered_strings for the next field to process
rendered_strings = new_strings
# find any {fields} that weren't replaced
unmatched = []
for rendered_str in rendered_strings:
unmatched.extend(
[
no_match[0]
for no_match in re.findall(regex, rendered_str)
if no_match[0] not in unmatched
]
)
# fix any escaped curly braces
rendered = re.sub(r"\\{", "{", rendered)
rendered = re.sub(r"\\}", "}", rendered)
rendered_strings = [
rendered_str.replace("{{", "{").replace("}}", "}")
for rendered_str in rendered_strings
]
return rendered, unmatched
return rendered_strings, unmatched
class DateTimeFormatter:

View File

@@ -408,7 +408,7 @@ def test_export_directory_template_3():
],
)
assert result.exit_code == 0
assert "Possible unmatched substitution in template: ['{foo}']" in result.output
assert "Possible unmatched substitution in template: ['foo']" in result.output
workdir = os.getcwd()
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES3:
assert os.path.isfile(os.path.join(workdir, filepath))
@@ -453,6 +453,7 @@ def test_place_13():
assert len(json_got) == 1 # single element
assert json_got[0]["uuid"] == "2L6X2hv3ROWRSCU3WRRAGQ"
def test_no_place_13():
# test --no-place on 10.13
import json
@@ -466,8 +467,7 @@ def test_no_place_13():
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
query,
[os.path.join(cwd, PLACES_PHOTOS_DB_13), "--json", "--no-place"],
query, [os.path.join(cwd, PLACES_PHOTOS_DB_13), "--json", "--no-place"]
)
assert result.exit_code == 0
json_got = json.loads(result.output)
@@ -498,6 +498,7 @@ def test_place_15_1():
assert len(json_got) == 1 # single element
assert json_got[0]["uuid"] == "128FB4C6-0B16-4E7D-9108-FB2E90DA1546"
def test_place_15_2():
# test --place on 10.15
import json
@@ -518,7 +519,7 @@ def test_place_15_2():
json_got = json.loads(result.output)
assert len(json_got) == 2 # single element
uuid = [json_got[x]["uuid"] for x in (0,1)]
uuid = [json_got[x]["uuid"] for x in (0, 1)]
assert "128FB4C6-0B16-4E7D-9108-FB2E90DA1546" in uuid
assert "FF7AFE2C-49B0-4C9B-B0D7-7E1F8B8F2F0C" in uuid
@@ -536,8 +537,7 @@ def test_no_place_15():
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
query,
[os.path.join(cwd, PLACES_PHOTOS_DB), "--json", "--no-place"],
query, [os.path.join(cwd, PLACES_PHOTOS_DB), "--json", "--no-place"]
)
assert result.exit_code == 0
json_got = json.loads(result.output)

View File

@@ -1,10 +1,14 @@
""" Test template.py """
import pytest
PHOTOS_DB = "./tests/Test-Places-Catalina-10_15_1.photoslibrary/database/photos.db"
UUID_DICT = {"place_dc": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546"}
PHOTOS_DB_1 = "./tests/Test-Places-Catalina-10_15_1.photoslibrary/database/photos.db"
PHOTOS_DB_2 = "./tests/Test-10.15.1.photoslibrary/database/photos.db"
UUID_DICT = {
"place_dc": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
"1_1_2": "1EB2B765-0765-43BA-A90C-0D0580E6172C",
"2_1_1": "D79B8D77-BFFC-460B-9312-034F2877D35B",
"0_2_0": "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4",
}
TEMPLATE_VALUES = {
"{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
@@ -51,7 +55,7 @@ def test_lookup():
TEMPLATE_SUBSTITUTIONS,
)
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
for subst in TEMPLATE_SUBSTITUTIONS:
@@ -67,12 +71,12 @@ def test_subst():
from osxphotos.template import render_filepath_template
locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
for template in TEMPLATE_VALUES:
rendered, _ = render_filepath_template(template, photo)
assert rendered == TEMPLATE_VALUES[template]
assert rendered[0] == TEMPLATE_VALUES[template]
def test_subst_default_val():
@@ -82,12 +86,12 @@ def test_subst_default_val():
from osxphotos.template import render_filepath_template
locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{place.name.area_of_interest,UNKNOWN}"
rendered, _ = render_filepath_template(template, photo)
assert rendered == "UNKNOWN"
assert rendered[0] == "UNKNOWN"
def test_subst_default_val_2():
@@ -97,12 +101,12 @@ def test_subst_default_val_2():
from osxphotos.template import render_filepath_template
locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{place.name.area_of_interest,}"
rendered, _ = render_filepath_template(template, photo)
assert rendered == "_"
assert rendered[0] == "_"
def test_subst_unknown_val():
@@ -112,10 +116,149 @@ def test_subst_unknown_val():
from osxphotos.template import render_filepath_template
locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{created.year}/{foo}"
rendered, unknown = render_filepath_template(template, photo)
assert rendered == "2020/{foo}"
assert unknown == ["{foo}"]
assert rendered[0] == "2020/{foo}"
assert unknown == ["foo"]
template = "{place.name.area_of_interest,}"
rendered, _ = render_filepath_template(template, photo)
assert rendered[0] == "_"
def test_subst_double_brace():
""" Test substitution with double brace {{ which should be ignored """
import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{created.year}/{{foo}}"
rendered, unknown = render_filepath_template(template, photo)
assert rendered[0] == "2020/{foo}"
assert not unknown
def test_subst_unknown_val_with_default():
""" Test substitution with unknown value specified """
import locale
import osxphotos
from osxphotos.template import render_filepath_template
locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{created.year}/{foo,bar}"
rendered, unknown = render_filepath_template(template, photo)
assert rendered[0] == "2020/{foo,bar}"
assert unknown == ["foo"]
def test_subst_multi_1_1_2():
""" Test that substitutions are correct """
# one album, one keyword, two persons
import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2)
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
template = "{created.year}/{album}/{keyword}/{person}"
expected = ["2018/Pumpkin Farm/Kids/Katie", "2018/Pumpkin Farm/Kids/Suzy"]
rendered, _ = render_filepath_template(template, photo)
assert sorted(rendered) == sorted(expected)
def test_subst_multi_2_1_1():
""" Test that substitutions are correct """
# 2 albums, 1 keyword, 1 person
import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2)
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["2_1_1"]])[0]
template = "{created.year}/{album}/{keyword}/{person}"
expected = ["2018/Pumpkin Farm/Kids/Katie", "2018/Test Album/Kids/Katie"]
rendered, _ = render_filepath_template(template, photo)
assert sorted(rendered) == sorted(expected)
def test_subst_multi_0_2_0():
""" Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons
import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2)
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
template = "{created.year}/{album}/{keyword}/{person}"
expected = ["2019/_/wedding/_", "2019/_/flowers/_"]
rendered, _ = render_filepath_template(template, photo)
assert sorted(rendered) == sorted(expected)
def test_subst_multi_0_2_0_default_val():
""" Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons, default vals provided
import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2)
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
template = "{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person,NOPERSON}"
expected = ["2019/NOALBUM/wedding/NOPERSON", "2019/NOALBUM/flowers/NOPERSON"]
rendered, _ = render_filepath_template(template, photo)
assert sorted(rendered) == sorted(expected)
def test_subst_multi_0_2_0_default_val_unknown_val():
""" Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2)
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
template = (
"{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person}/{foo}/{{baz}}"
)
expected = [
"2019/NOALBUM/wedding/_/{foo}/{baz}",
"2019/NOALBUM/flowers/_/{foo}/{baz}",
]
rendered, unknown = render_filepath_template(template, photo)
assert sorted(rendered) == sorted(expected)
assert unknown == ["foo"]
def test_subst_multi_0_2_0_default_val_unknown_val_2():
""" Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2)
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
template = "{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person}/{foo,bar}/{{baz,bar}}"
expected = [
"2019/NOALBUM/wedding/_/{foo,bar}/{baz,bar}",
"2019/NOALBUM/flowers/_/{foo,bar}/{baz,bar}",
]
rendered, unknown = render_filepath_template(template, photo)
assert sorted(rendered) == sorted(expected)
assert unknown == ["foo"]