Added {function} template, #419

This commit is contained in:
Rhet Turnbull
2021-04-14 22:00:04 -07:00
parent eff8e7a63f
commit 21dc0d388f
20 changed files with 149 additions and 22 deletions

View File

@@ -858,7 +858,7 @@ e.g. If Photo is in Album1 in Folder1:
• "{folder_album(>)}" renders to ["Folder1>Album1"] • "{folder_album(>)}" renders to ["Folder1>Album1"]
• "{folder_album()}" renders to ["Folder1Album1"] • "{folder_album()}" renders to ["Folder1Album1"]
[find|replace]: optional text replacement to perform on rendered template value. [find,replace]: optional text replacement to perform on rendered template value.
For example, to replace "/" in an album name, you could use the template For example, to replace "/" in an album name, you could use the template
"{album[/,-]}". Multiple replacements can be made by appending "|" and adding "{album[/,-]}". Multiple replacements can be made by appending "|" and adding
another find|replace pair. e.g. to replace both "/" and ":" in album name: another find|replace pair. e.g. to replace both "/" and ":" in album name:
@@ -1281,6 +1281,16 @@ Substitution Description
https://rhettbull.github.io/osxphotos/ for additional https://rhettbull.github.io/osxphotos/ for additional
documentation on the PhotoInfo class. documentation on the PhotoInfo class.
{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.
``` ```
@@ -2213,7 +2223,7 @@ e.g. If Photo is in `Album1` in `Folder1`:
- `"{folder_album(>)}"` renders to `["Folder1>Album1"]` - `"{folder_album(>)}"` renders to `["Folder1>Album1"]`
- `"{folder_album()}"` renders to `["Folder1Album1"]` - `"{folder_album()}"` renders to `["Folder1Album1"]`
`[find|replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair. `[find,replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair.
`conditional`: optional conditional expression that is evaluated as boolean (True/False) for use with the `?bool_value` modifier. Conditional expressions take the form '` not operator value`' where `not` is an optional modifier that negates the `operator`. Note: the space before the conditional expression is required if you use a conditional expression. Valid comparison operators are: `conditional`: optional conditional expression that is evaluated as boolean (True/False) for use with the `?bool_value` modifier. Conditional expressions take the form '` not operator value`' where `not` is an optional modifier that negates the `operator`. Note: the space before the conditional expression is required if you use a conditional expression. Valid comparison operators are:
@@ -2892,6 +2902,7 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{searchinfo.venue}|Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).| |{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).| |{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.| |{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.|
|{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 --> <!-- OSXPHOTOS-TEMPLATE-TABLE:END -->
### Utility Functions ### Utility Functions

View File

@@ -1,4 +1,4 @@
# Sphinx build info version 1 # Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. # This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 3a4409f9ef528dee2e4ce25074f0e795 config: 456ab9bf5a36a59a5d7b68a97d5f6583
tags: 645f666f9bcd5a90fca523b33c5a78b7 tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@@ -5,7 +5,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overview: module code &#8212; osxphotos 0.42.00 documentation</title> <title>Overview: module code &#8212; osxphotos 0.42.1 documentation</title>
<link rel="stylesheet" href="../_static/pygments.css" type="text/css" /> <link rel="stylesheet" href="../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../_static/alabaster.css" type="text/css" /> <link rel="stylesheet" href="../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../" src="../_static/documentation_options.js"></script> <script id="documentation_options" data-url_root="../" src="../_static/documentation_options.js"></script>

View File

@@ -1,6 +1,6 @@
var DOCUMENTATION_OPTIONS = { var DOCUMENTATION_OPTIONS = {
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'), URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
VERSION: '0.42.00', VERSION: '0.42.1',
LANGUAGE: 'None', LANGUAGE: 'None',
COLLAPSE_INDEX: false, COLLAPSE_INDEX: false,
BUILDER: 'html', BUILDER: 'html',

View File

@@ -5,7 +5,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.42.00 documentation</title> <title>osxphotos command line interface (CLI) &#8212; osxphotos 0.42.1 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" /> <link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" /> <link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script> <script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &#8212; osxphotos 0.42.00 documentation</title> <title>Index &#8212; osxphotos 0.42.1 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" /> <link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" /> <link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script> <script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.42.00 documentation</title> <title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.42.1 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" /> <link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" /> <link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script> <script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos &#8212; osxphotos 0.42.00 documentation</title> <title>osxphotos &#8212; osxphotos 0.42.1 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" /> <link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" /> <link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script> <script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

Binary file not shown.

View File

@@ -5,7 +5,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos package &#8212; osxphotos 0.42.00 documentation</title> <title>osxphotos package &#8212; osxphotos 0.42.1 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" /> <link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" /> <link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script> <script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; osxphotos 0.42.00 documentation</title> <title>Search &#8212; osxphotos 0.42.1 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" /> <link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" /> <link rel="stylesheet" href="_static/alabaster.css" type="text/css" />

View File

@@ -1,4 +1,8 @@
""" Example showing how to use a custom function for osxphotos {function} template """ """ Example showing how to use a custom function for osxphotos {function} template
Use: osxphotos export /path/to/export --filename "{function:/path/to/template_function.py::example}"
You may place more than one template function in a single file as each is called by name using the {function:file.py::function_name} format
"""
import pathlib import pathlib
from typing import List, Union from typing import List, Union
@@ -7,7 +11,7 @@ import osxphotos
def example(photo: osxphotos.PhotoInfo, **kwargs) -> Union[List, str]: def example(photo: osxphotos.PhotoInfo, **kwargs) -> Union[List, str]:
""" example function for {function} template """ example function for {function} template; adds suffix of # if photo has adjustments and ! if photo is a favorite
Args: Args:
photo: osxphotos.PhotoInfo object photo: osxphotos.PhotoInfo object

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.42.00" __version__ = "0.42.1"

View File

@@ -60,7 +60,7 @@ e.g. If Photo is in `Album1` in `Folder1`:
- `"{folder_album(>)}"` renders to `["Folder1>Album1"]` - `"{folder_album(>)}"` renders to `["Folder1>Album1"]`
- `"{folder_album()}"` renders to `["Folder1Album1"]` - `"{folder_album()}"` renders to `["Folder1Album1"]`
`[find|replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair. `[find,replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair.
`conditional`: optional conditional expression that is evaluated as boolean (True/False) for use with the `?bool_value` modifier. Conditional expressions take the form '` not operator value`' where `not` is an optional modifier that negates the `operator`. Note: the space before the conditional expression is required if you use a conditional expression. Valid comparison operators are: `conditional`: optional conditional expression that is evaluated as boolean (True/False) for use with the `?bool_value` modifier. Conditional expressions take the form '` not operator value`' where `not` is an optional modifier that negates the `operator`. Note: the space before the conditional expression is required if you use a conditional expression. Valid comparison operators are:

View File

@@ -11,6 +11,7 @@ from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifTool from .exiftool import ExifTool
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
from .utils import load_function
# ensure locale set to user's locale # ensure locale set to user's locale
locale.setlocale(locale.LC_ALL, "") locale.setlocale(locale.LC_ALL, "")
@@ -152,6 +153,10 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
+ "For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. " + "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 " + "'{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.", + "the underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
"{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.",
} }
FILTER_VALUES = { FILTER_VALUES = {
@@ -463,6 +468,14 @@ class PhotoTemplate:
vals = self.get_template_value_exiftool( vals = self.get_template_value_exiftool(
subfield, filename=filename, dirname=dirname subfield, filename=filename, dirname=dirname
) )
elif field == "function":
if subfield is None:
raise ValueError(
"SyntaxError: filename and function must not be null with {function::filename.py:function_name}"
)
vals = self.get_template_value_function(
subfield, filename=filename, dirname=dirname
)
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"): elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
vals = self.get_template_value_multi( vals = self.get_template_value_multi(
field, path_sep=path_sep, filename=filename, dirname=dirname field, path_sep=path_sep, filename=filename, dirname=dirname
@@ -1064,6 +1077,39 @@ class PhotoTemplate:
return values return values
def get_template_value_function(self, subfield, filename=None, dirname=None):
"""Get template value from external function """
if "::" not in subfield:
raise ValueError(
f"SyntaxError: could not parse function name from '{subfield}'"
)
filename, funcname = subfield.split("::")
print(filename, funcname)
if not pathlib.Path(filename).is_file():
raise ValueError(f"'{filename}' does not appear to be a file")
template_func = load_function(filename, funcname)
values = template_func(self.photo)
if not isinstance(values, (str, list)):
raise TypeError(
f"Invalid return type for function {funcname}: expected str or list"
)
if type(values) == str:
values = [values]
# sanitize directory names if needed
if filename:
values = [sanitize_pathpart(value) for value in values]
elif dirname:
values = [sanitize_dirname(value) for value in values]
return values
def get_photo_video_type(self, default): def get_photo_video_type(self, default):
""" return media type, e.g. photo or video """ """ return media type, e.g. photo or video """
default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS) default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS)

View File

@@ -51,6 +51,10 @@ Field:
FIELD_WORD+ FIELD_WORD+
; ;
FIELD_WORD:
/[\.\w]+/
;
SubField: SubField:
( (
":"- ":"-
@@ -58,12 +62,8 @@ SubField:
)? )?
; ;
FIELD_WORD:
/[\.\w]+/
;
SUBFIELD_WORD: SUBFIELD_WORD:
/[\.\w:]+/ /[\.\w:\/]+/
; ;
Filter: Filter:

View File

@@ -1,5 +1,8 @@
""" Utility functions used in osxphotos """
import fnmatch import fnmatch
import glob import glob
import importlib
import inspect import inspect
import logging import logging
import os import os
@@ -13,6 +16,7 @@ import sys
import unicodedata import unicodedata
import urllib.parse import urllib.parse
from plistlib import load as plistload from plistlib import load as plistload
from typing import Callable
import CoreFoundation import CoreFoundation
import CoreServices import CoreServices
@@ -401,3 +405,28 @@ def increment_filename(filepath):
count += 1 count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}" dest = dest.parent / f"{dest_new}{dest.suffix}"
return str(dest) return str(dest)
def load_function(pyfile: str, function_name: str) -> Callable:
""" Load function_name from python file pyfile """
module_file = pathlib.Path(pyfile)
if not module_file.is_file():
raise FileNotFoundError(f"module {pyfile} does not appear to exist")
module_dir = module_file.parent or pathlib.Path(os.getcwd())
module_name = module_file.stem
# store old sys.path and ensure module_dir at beginning of path
syspath = sys.path
sys.path = [str(module_dir)] + syspath
module = importlib.import_module(module_name)
try:
func = getattr(module, function_name)
except AttributeError:
raise ValueError(f"'{function_name}' not found in module '{module_name}'")
finally:
# restore sys.path
sys.path = syspath
return func

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,20 @@
""" Example showing how to use a custom function for osxphotos {function} template """
import pathlib
from typing import List, Union
import osxphotos
def foo(photo: osxphotos.PhotoInfo, **kwargs) -> Union[List, str]:
""" example function for {function} template
Args:
photo: osxphotos.PhotoInfo object
**kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function}
Returns:
str or list of str of values that should be substituted for the {function} template
"""
return photo.original_filename + "-FOO"

View File

@@ -381,7 +381,7 @@ def test_lookup_multi(photosdb_places):
for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED: for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED:
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1) lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
if subst in ["{exiftool}", "{photo}"]: if subst in ["{exiftool}", "{photo}", "{function}"]:
continue 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)
assert isinstance(lookup, list) assert isinstance(lookup, list)
@@ -965,3 +965,20 @@ def test_conditional(photosdb):
for template in UUID_CONDITIONAL[uuid]: for template in UUID_CONDITIONAL[uuid]:
rendered, _ = photo.render_template(template) rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(UUID_CONDITIONAL[uuid][template]) assert sorted(rendered) == sorted(UUID_CONDITIONAL[uuid][template])
def test_function(photosdb):
""" Test {function} """
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
rendered, _ = photo.render_template("{function:tests/template_function.py::foo}")
assert rendered == [f"{photo.original_filename}-FOO"]
def test_function_bad(photosdb):
""" Test invalid {function} """
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
with pytest.raises(ValueError):
rendered, _ = photo.render_template(
"{function:tests/template_function.py::foobar}"
)