Added {exiftool} template, implements issue #259

This commit is contained in:
Rhet Turnbull
2020-11-27 16:43:48 -08:00
parent eba661acf7
commit 48acb42631
4 changed files with 386 additions and 149 deletions

View File

@@ -639,15 +639,23 @@ albums 'Vacation' and 'Family': 2019/Vacation, 2019/Family
Substitution Description Substitution Description
{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_album} Folder path + album photo is contained in. e.g.
'Folder/Subfolder/Album' or just 'Album' if no enclosing 'Folder/Subfolder/Album' or just 'Album' if no
folder enclosing folder
{keyword} Keyword(s) assigned to photo {keyword} Keyword(s) assigned to photo
{person} Person(s) / face(s) in a photo {person} Person(s) / face(s) in a photo
{label} Image categorization label associated with a photo {label} Image categorization label associated with a photo
(Photos 5 only) (Photos 5 only)
{label_normalized} All lower case version of 'label' (Photos 5 only) {label_normalized} All lower case version of 'label' (Photos 5 only)
{comment} Comment(s) on shared Photos; format is 'Person name: {comment} Comment(s) on shared Photos; format is 'Person
comment text' (Photos 5 only) name: comment text' (Photos 5 only)
{exiftool:GROUP:TAGNAME} Use exiftool (https://exiftool.org) to extract
metadata, in form GROUP:TAGNAME, from image. E.g.
'{exiftool:EXIF:Make}' to get camera make, or
{exiftool:IPTC:Keywords} to extract keywords. See
https://exiftool.org/TagNames/ for list of valid
tag names. You must specify group (e.g. EXIF,
IPTC, etc) as used in `exiftool -G`. exiftool must
be installed in the path to use this template.
``` ```
Example: export all photos to ~/Desktop/export group in folders by date created Example: export all photos to ~/Desktop/export group in folders by date created

View File

@@ -1,4 +1,4 @@
""" version info """ """ version info """
__version__ = "0.36.25" __version__ = "0.37.0"

View File

@@ -18,6 +18,7 @@ from functools import partial
from ._constants import _UNKNOWN_PERSON from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifTool
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
# ensure locale set to user's locale # ensure locale set to user's locale
@@ -126,6 +127,10 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{label}": "Image categorization label associated with a photo (Photos 5 only)", "{label}": "Image categorization label associated with a photo (Photos 5 only)",
"{label_normalized}": "All lower case version of 'label' (Photos 5 only)", "{label_normalized}": "All lower case version of 'label' (Photos 5 only)",
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)", "{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)",
"{exiftool:GROUP:TAGNAME}": "Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. "
"E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. "
"See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) "
"as used in `exiftool -G`. exiftool must be installed in the path to use this template.",
} }
# Just the multi-valued substitution names without the braces # Just the multi-valued substitution names without the braces
@@ -150,6 +155,62 @@ class PhotoTemplate:
# gets initialized in get_template_value # gets initialized in get_template_value
self.today = None self.today = None
def make_subst_function(
self, none_str, filename, dirname, replacement, get_func=None
):
""" returns: substitution function for use in re.sub
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 """
if get_func is None:
# used by make_subst_function to get the value for a template substitution
get_func = partial(
self.get_template_value,
filename=filename,
dirname=dirname,
replacement=replacement,
)
# closure to capture photo, none_str, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups != 5:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
delim = matchobj.group(1)
field = matchobj.group(2)
path_sep = matchobj.group(3)
bool_val = matchobj.group(4)
default = matchobj.group(5)
# drop the '+' on delim
delim = delim[:-1] if delim is not None else None
# drop () from path_sep
path_sep = path_sep.strip("()") if path_sep is not None else None
# drop the ? on bool_val
bool_val = bool_val[1:] if bool_val is not None else None
# drop the comma on default
default_val = default[1:] if default is not None else None
try:
val = get_func(field, default_val, bool_val, delim, path_sep)
except ValueError:
return matchobj.group(0)
if val is None:
# field valid but didn't match a value
if default == ",":
val = ""
else:
val = default_val if default_val is not None else none_str
return val
return subst
def render( def render(
self, self,
template, template,
@@ -208,60 +269,7 @@ class PhotoTemplate:
if type(template) is not str: if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}") raise TypeError(f"template must be type str, not {type(template)}")
# used by make_subst_function to get the value for a template substitution subst_func = self.make_subst_function(none_str, filename, dirname, replacement)
get_func = partial(
self.get_template_value,
filename=filename,
dirname=dirname,
replacement=replacement,
)
def make_subst_function(self, none_str, get_func=get_func):
""" returns: substitution function for use in re.sub
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, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 5:
delim = matchobj.group(1)
field = matchobj.group(2)
path_sep = matchobj.group(3)
bool_val = matchobj.group(4)
default = matchobj.group(5)
# drop the '+' on delim
delim = delim[:-1] if delim is not None else None
# drop () from path_sep
path_sep = path_sep.strip("()") if path_sep is not None else None
# drop the ? on bool_val
bool_val = bool_val[1:] if bool_val is not None else None
# drop the comma on default
default_val = default[1:] if default is not None else None
try:
val = get_func(field, default_val, bool_val, delim, path_sep)
except ValueError:
return matchobj.group(0)
if val is None:
# field valid but didn't match a value
if default == ",":
val = ""
else:
val = default_val if default_val is not None else none_str
return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
return subst
subst_func = make_subst_function(self, none_str)
# do the replacements # do the replacements
rendered = re.sub(regex, subst_func, template) rendered = re.sub(regex, subst_func, template)
@@ -289,7 +297,68 @@ class PhotoTemplate:
# '2011/Album2/keyword1/person1', # '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',] # '2011/Album2/keyword2/person1',]
rendered_strings = self._render_multi_valued_templates(
rendered,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
)
# process exiftool: templates
rendered_strings = self._render_exiftool_template(
rendered_strings,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
)
# find any {fields} that weren't replaced
unmatched = []
for rendered_str in rendered_strings:
unmatched.extend(
[
no_match[1]
for no_match in re.findall(regex, rendered_str)
if no_match[1] not in unmatched
]
)
# fix any escaped curly braces
rendered_strings = [
rendered_str.replace("{{", "{").replace("}}", "}")
for rendered_str in rendered_strings
]
if filename:
rendered_strings = [
sanitize_filename(rendered_str) for rendered_str in rendered_strings
]
return rendered_strings, unmatched
def _render_multi_valued_templates(
self,
rendered,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
):
rendered_strings = [rendered] rendered_strings = [rendered]
new_rendered_strings = []
while new_rendered_strings != rendered_strings:
new_rendered_strings = rendered_strings
for field in MULTI_VALUE_SUBSTITUTIONS: for field in MULTI_VALUE_SUBSTITUTIONS:
# Build a regex that matches only the field being processed # Build a regex that matches only the field being processed
re_str = ( re_str = (
@@ -325,10 +394,16 @@ class PhotoTemplate:
) )
if expand_inplace or matches.group(1) is not None: if expand_inplace or matches.group(1) is not None:
delim = ( delim = (
matches.group(1)[:-1] if matches.group(1) is not None else inplace_sep matches.group(1)[:-1]
if matches.group(1) is not None
else inplace_sep
) )
# instead of returning multiple strings, join values into a single string # instead of returning multiple strings, join values into a single string
val = delim.join(sorted(values)) if values and values[0] else None val = (
delim.join(sorted(values))
if values and values[0]
else None
)
def lookup_template_value_multi(lookup_value, *_): def lookup_template_value_multi(lookup_value, *_):
""" Closure passed to make_subst_function get_func """ Closure passed to make_subst_function get_func
@@ -338,15 +413,21 @@ class PhotoTemplate:
if lookup_value == field: if lookup_value == field:
return val return val
else: else:
raise ValueError(f"Unexpected value: {lookup_value}") raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = make_subst_function( subst = self.make_subst_function(
self, none_str, get_func=lookup_template_value_multi none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_multi,
) )
new_string = regex_multi.sub(subst, str_template) new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process # update rendered_strings for the next field to process
rendered_strings = {new_string} rendered_strings = list({new_string})
else: else:
# create a new template string for each value # create a new template string for each value
for val in values: for val in values:
@@ -363,38 +444,143 @@ class PhotoTemplate:
f"Unexpected value: {lookup_value}" f"Unexpected value: {lookup_value}"
) )
subst = make_subst_function( subst = self.make_subst_function(
self, none_str, get_func=lookup_template_value_multi none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_multi,
) )
new_string = regex_multi.sub(subst, str_template) new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1 new_strings[new_string] = 1
# update rendered_strings for the next field to process # update rendered_strings for the next field to process
rendered_strings = list(new_strings.keys()) rendered_strings = sorted(list(new_strings.keys()))
return rendered_strings
# find any {fields} that weren't replaced def _render_exiftool_template(
unmatched = [] self,
for rendered_str in rendered_strings: rendered_strings,
unmatched.extend( none_str,
[ path_sep,
no_match[1] expand_inplace,
for no_match in re.findall(regex, rendered_str) inplace_sep,
if no_match[1] not in unmatched filename,
] dirname,
replacement,
):
# TODO: lots of code commonality with render_multi_valued_templates -- combine or pull out
# TODO: put these in globals
if path_sep is None:
path_sep = os.path.sep
if inplace_sep is None:
inplace_sep = ","
# Build a regex that matches only the field being processed
# todo: pull out regexes into globals?
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"(exiftool:[^\\,}+\?]+)" # group 3 field name
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
)
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_rendered_strings = []
while new_rendered_strings != rendered_strings:
new_rendered_strings = rendered_strings
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
# allmatches = regex_multi.finditer(str_template)
# for matches in allmatches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
else path_sep
)
field = matches.group(2)
subfield = field[9:]
if not self.photo.path:
values = []
else:
exif = ExifTool(self.photo.path)
exifdict = exif.asdict()
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
subfield = subfield.lower()
if subfield in exifdict:
values = exifdict[subfield]
values = (
[values] if not isinstance(values, list) else values
)
else:
values = [None]
if expand_inplace or matches.group(1) is not None:
delim = (
matches.group(1)[:-1]
if matches.group(1) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = (
delim.join(sorted(values)) if values and values[0] else None
) )
# fix any escaped curly braces def lookup_template_value_exif(lookup_value, *_):
rendered_strings = [ """ Closure passed to make_subst_function get_func
rendered_str.replace("{{", "{").replace("}}", "}") Capture val and field in the closure
for rendered_str in rendered_strings Allows make_subst_function to be re-used w/o modification
] _ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(f"Unexpected value: {lookup_value}")
if filename: subst = self.make_subst_function(
rendered_strings = [ none_str,
sanitize_filename(rendered_str) for rendered_str in rendered_strings filename,
] dirname,
replacement,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = list({new_string})
else:
# create a new template string for each value
for val in values:
return rendered_strings, unmatched def lookup_template_value_exif(lookup_value, *_):
""" 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
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = sorted(list(new_strings.keys()))
return rendered_strings
def get_template_value( def get_template_value(
self, self,
@@ -681,6 +867,7 @@ class PhotoTemplate:
""" """
""" return list of values for a multi-valued template field """ """ return list of values for a multi-valued template field """
values = []
if field == "album": if field == "album":
values = self.photo.albums values = self.photo.albums
elif field == "keyword": elif field == "keyword":
@@ -724,7 +911,7 @@ class PhotoTemplate:
values = [ values = [
f"{comment.user}: {comment.text}" for comment in self.photo.comments f"{comment.user}: {comment.text}" for comment in self.photo.comments
] ]
else: elif not field.startswith("exiftool:"):
raise ValueError(f"Unhandled template value: {field}") raise ValueError(f"Unhandled template value: {field}")
# sanitize directory names if needed, folder_album handled differently above # sanitize directory names if needed, folder_album handled differently above

View File

@@ -1,6 +1,13 @@
""" Test template.py """ """ Test template.py """
import pytest import pytest
from osxphotos.exiftool import get_exiftool_path
try:
exiftool = get_exiftool_path()
except:
exiftool = None
PHOTOS_DB_PLACES = ( PHOTOS_DB_PLACES = (
"./tests/Test-Places-Catalina-10_15_7.photoslibrary/database/photos.db" "./tests/Test-Places-Catalina-10_15_7.photoslibrary/database/photos.db"
) )
@@ -57,6 +64,29 @@ UUID_BOOL_VALUES = {"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15"}
# Boolean type values that render to False # Boolean type values that render to False
UUID_BOOL_VALUES_NOT = {"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE"} UUID_BOOL_VALUES_NOT = {"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE"}
# for exiftool template
UUID_EXIFTOOL = {
"A92D9C26-3A50-4197-9388-CB5F7DB9FA91": {
"{exiftool:EXIF:Make}": ["Canon"],
"{exiftool:EXIF:Model}": ["Canon PowerShot G10"],
"{exiftool:EXIF:Make}/{exiftool:EXIF:Model}": ["Canon/Canon PowerShot G10"],
"{exiftool:IPTC:Keywords,foo}": ["foo"],
},
"DC99FBDD-7A52-4100-A5BB-344131646C30": {
"{exiftool:IPTC:Keywords}": [
"England",
"London",
"London 2018",
"St. James's Park",
"UK",
"United Kingdom",
],
"{,+exiftool:IPTC:Keywords}": [
"England,London,London 2018,St. James's Park,UK,United Kingdom"
],
},
}
TEMPLATE_VALUES = { TEMPLATE_VALUES = {
"{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546", "{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
"{original_name}": "IMG_1064", "{original_name}": "IMG_1064",
@@ -737,3 +767,15 @@ def test_expand_in_place_with_delim_single_value():
for template in TEMPLATE_VALUES_TITLE: for template in TEMPLATE_VALUES_TITLE:
rendered, _ = photo.render_template(template) rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(TEMPLATE_VALUES_TITLE[template]) assert sorted(rendered) == sorted(TEMPLATE_VALUES_TITLE[template])
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_exiftool_template():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
for uuid in UUID_EXIFTOOL:
photo = photosdb.get_photo(uuid)
for template in UUID_EXIFTOOL[uuid]:
rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(UUID_EXIFTOOL[uuid][template])