Bug fix for template functions #477

This commit is contained in:
Rhet Turnbull 2021-06-23 22:36:58 -07:00
parent 5ea01df69b
commit 49317582c4
8 changed files with 99 additions and 45 deletions

View File

@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.42.45" __version__ = "0.42.46"

View File

@ -57,7 +57,7 @@ from .photokit import check_photokit_authorization, request_photokit_authorizati
from .photosalbum import PhotosAlbum from .photosalbum import PhotosAlbum
from .phototemplate import PhotoTemplate, RenderOptions from .phototemplate import PhotoTemplate, RenderOptions
from .queryoptions import QueryOptions from .queryoptions import QueryOptions
from .utils import get_preferred_uti_extension, load_function from .utils import get_preferred_uti_extension, load_function, expand_and_validate_filepath
# global variable to control verbose output # global variable to control verbose output
# set via --verbose/-V # set via --verbose/-V
@ -172,13 +172,14 @@ class FunctionCall(click.ParamType):
filename, funcname = value.split("::") filename, funcname = value.split("::")
if not pathlib.Path(filename).is_file(): filename_validated = expand_and_validate_filepath(filename)
if not filename_validated:
self.fail(f"'{filename}' does not appear to be a file") self.fail(f"'{filename}' does not appear to be a file")
try: try:
function = load_function(filename, funcname) function = load_function(filename_validated, funcname)
except Exception as e: except Exception as e:
self.fail(f"Could not load function {funcname} from {filename}") self.fail(f"Could not load function {funcname} from {filename_validated}")
return (function, value) return (function, value)

View File

@ -4,8 +4,10 @@ import datetime
import locale import locale
import os import os
import pathlib import pathlib
import sys
import shlex import shlex
import sys
from dataclasses import dataclass
from typing import Optional
from textx import TextXSyntaxError, metamodel_from_file from textx import TextXSyntaxError, metamodel_from_file
@ -14,9 +16,7 @@ from ._version import __version__
from .datetime_formatter import DateTimeFormatter from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifToolCaching from .exiftool import ExifToolCaching
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 from .utils import expand_and_validate_filepath, load_function
from dataclasses import dataclass
from typing import Optional
# TODO: a lot of values are passed from function to function like path_sep--make these all class properties # TODO: a lot of values are passed from function to function like path_sep--make these all class properties
@ -1177,10 +1177,11 @@ class PhotoTemplate:
filename, funcname = subfield.split("::") filename, funcname = subfield.split("::")
if not pathlib.Path(filename).is_file(): filename_validated = expand_and_validate_filepath(filename)
if not filename_validated:
raise ValueError(f"'{filename}' does not appear to be a file") raise ValueError(f"'{filename}' does not appear to be a file")
template_func = load_function(filename, funcname) template_func = load_function(filename_validated, funcname)
values = template_func(self.photo) values = template_func(self.photo)
if not isinstance(values, (str, list)): if not isinstance(values, (str, list)):
@ -1211,10 +1212,11 @@ class PhotoTemplate:
filename, funcname = filter_.split("::") filename, funcname = filter_.split("::")
if not pathlib.Path(filename).is_file(): filename_validated = expand_and_validate_filepath(filename)
if not filename_validated:
raise ValueError(f"'{filename}' does not appear to be a file") raise ValueError(f"'{filename}' does not appear to be a file")
template_func = load_function(filename, funcname) template_func = load_function(filename_validated, funcname)
if not isinstance(values, (list, tuple)): if not isinstance(values, (list, tuple)):
values = [values] values = [values]

View File

@ -63,7 +63,8 @@ SubField:
; ;
SUBFIELD_WORD: SUBFIELD_WORD:
/[\.\w:\/]+/ /[\.\w:\/\-\~\'\"\%\@\#\^\]+/
/\\\s/?
; ;
Filter: Filter:

View File

@ -38,7 +38,7 @@ if not _DEBUG:
def _get_logger(): def _get_logger():
"""Used only for testing """Used only for testing
Returns: Returns:
logging.Logger object -- logging.Logger object for osxphotos logging.Logger object -- logging.Logger object for osxphotos
""" """
@ -46,7 +46,7 @@ def _get_logger():
def _set_debug(debug): def _set_debug(debug):
""" Enable or disable debug logging """ """Enable or disable debug logging"""
global _DEBUG global _DEBUG
_DEBUG = debug _DEBUG = debug
if debug: if debug:
@ -56,18 +56,18 @@ def _set_debug(debug):
def _debug(): def _debug():
""" returns True if debugging turned on (via _set_debug), otherwise, false """ """returns True if debugging turned on (via _set_debug), otherwise, false"""
return _DEBUG return _DEBUG
def noop(*args, **kwargs): def noop(*args, **kwargs):
""" do nothing (no operation) """ """do nothing (no operation)"""
pass pass
def lineno(filename): def lineno(filename):
""" Returns string with filename and current line number in caller as '(filename): line_num' """Returns string with filename and current line number in caller as '(filename): line_num'
Will trim filename to just the name, dropping path, if any. """ Will trim filename to just the name, dropping path, if any."""
line = inspect.currentframe().f_back.f_lineno line = inspect.currentframe().f_back.f_lineno
filename = pathlib.Path(filename).name filename = pathlib.Path(filename).name
return f"{filename}: {line}" return f"{filename}: {line}"
@ -92,14 +92,14 @@ def _get_os_version():
def _check_file_exists(filename): def _check_file_exists(filename):
""" returns true if file exists and is not a directory """returns true if file exists and is not a directory
otherwise returns false """ otherwise returns false"""
filename = os.path.abspath(filename) filename = os.path.abspath(filename)
return os.path.exists(filename) and not os.path.isdir(filename) return os.path.exists(filename) and not os.path.isdir(filename)
def _get_resource_loc(model_id): def _get_resource_loc(model_id):
""" returns folder_id and file_id needed to find location of edited photo """ """returns folder_id and file_id needed to find location of edited photo"""
""" and live photos for version <= Photos 4.0 """ """ and live photos for version <= Photos 4.0 """
# determine folder where Photos stores edited version # determine folder where Photos stores edited version
# edited images are stored in: # edited images are stored in:
@ -117,7 +117,7 @@ def _get_resource_loc(model_id):
def _dd_to_dms(dd): def _dd_to_dms(dd):
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """ """convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds"""
""" return tuple of int(deg), int(min), float(sec) """ """ return tuple of int(deg), int(min), float(sec) """
dd = float(dd) dd = float(dd)
negative = dd < 0 negative = dd < 0
@ -136,7 +136,7 @@ def _dd_to_dms(dd):
def dd_to_dms_str(lat, lon): def dd_to_dms_str(lat, lon):
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """ """convert latitude, longitude in degrees to degrees, minutes, seconds as string"""
""" lat: latitude in degrees """ """ lat: latitude in degrees """
""" lon: longitude in degrees """ """ lon: longitude in degrees """
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """ """ returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
@ -165,7 +165,7 @@ def dd_to_dms_str(lat, lon):
def get_system_library_path(): def get_system_library_path():
""" return the path to the system Photos library as string """ """return the path to the system Photos library as string"""
""" only works on MacOS 10.15 """ """ only works on MacOS 10.15 """
""" on earlier versions, returns None """ """ on earlier versions, returns None """
_, major, _ = _get_os_version() _, major, _ = _get_os_version()
@ -190,8 +190,8 @@ def get_system_library_path():
def get_last_library_path(): def get_last_library_path():
""" returns the path to the last opened Photos library """returns the path to the last opened Photos library
If a library has never been opened, returns None """ If a library has never been opened, returns None"""
plist_file = pathlib.Path( plist_file = pathlib.Path(
str(pathlib.Path.home()) str(pathlib.Path.home())
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist" + "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
@ -241,7 +241,7 @@ def get_last_library_path():
def list_photo_libraries(): def list_photo_libraries():
""" returns list of Photos libraries found on the system """ """returns list of Photos libraries found on the system"""
""" on MacOS < 10.15, this may omit some libraries """ """ on MacOS < 10.15, this may omit some libraries """
# On 10.15, mdfind appears to find all libraries # On 10.15, mdfind appears to find all libraries
@ -266,9 +266,9 @@ def list_photo_libraries():
def get_preferred_uti_extension(uti): def get_preferred_uti_extension(uti):
""" get preferred extension for a UTI type """get preferred extension for a UTI type
uti: UTI str, e.g. 'public.jpeg' uti: UTI str, e.g. 'public.jpeg'
returns: preferred extension as str or None if cannot be determined """ returns: preferred extension as str or None if cannot be determined"""
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc # reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
with objc.autorelease_pool(): with objc.autorelease_pool():
@ -288,8 +288,8 @@ def get_preferred_uti_extension(uti):
def findfiles(pattern, path_): def findfiles(pattern, path_):
"""Returns list of filenames from path_ matched by pattern """Returns list of filenames from path_ matched by pattern
shell pattern. Matching is case-insensitive. shell pattern. Matching is case-insensitive.
If 'path_' is invalid/doesn't exist, returns [].""" If 'path_' is invalid/doesn't exist, returns []."""
if not os.path.isdir(path_): if not os.path.isdir(path_):
return [] return []
# See: https://gist.github.com/techtonik/5694830 # See: https://gist.github.com/techtonik/5694830
@ -316,8 +316,8 @@ def findfiles(pattern, path_):
def _open_sql_file(dbname): def _open_sql_file(dbname):
""" opens sqlite file dbname in read-only mode """opens sqlite file dbname in read-only mode
returns tuple of (connection, cursor) """ returns tuple of (connection, cursor)"""
try: try:
dbpath = pathlib.Path(dbname).resolve() dbpath = pathlib.Path(dbname).resolve()
conn = sqlite3.connect(f"{dbpath.as_uri()}?mode=ro", timeout=1, uri=True) conn = sqlite3.connect(f"{dbpath.as_uri()}?mode=ro", timeout=1, uri=True)
@ -328,9 +328,9 @@ def _open_sql_file(dbname):
def _db_is_locked(dbname): def _db_is_locked(dbname):
""" check to see if a sqlite3 db is locked """check to see if a sqlite3 db is locked
returns True if database is locked, otherwise False returns True if database is locked, otherwise False
dbname: name of database to test """ dbname: name of database to test"""
# first, check to see if lock file exists, if so, assume the file is locked # first, check to see if lock file exists, if so, assume the file is locked
lock_name = f"{dbname}.lock" lock_name = f"{dbname}.lock"
@ -381,7 +381,7 @@ def _db_is_locked(dbname):
def normalize_unicode(value): def normalize_unicode(value):
""" normalize unicode data """ """normalize unicode data"""
if value is not None: if value is not None:
if isinstance(value, (tuple, list)): if isinstance(value, (tuple, list)):
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value) return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
@ -394,9 +394,9 @@ def normalize_unicode(value):
def increment_filename(filepath): def increment_filename(filepath):
""" Return filename (1).ext, etc if filename.ext exists """Return filename (1).ext, etc if filename.ext exists
If file exists in filename's parent folder with same stem as filename, If file exists in filename's parent folder with same stem as filename,
add (1), (2), etc. until a non-existing filename is found. add (1), (2), etc. until a non-existing filename is found.
Args: Args:
@ -419,8 +419,22 @@ def increment_filename(filepath):
return str(dest) return str(dest)
def expand_and_validate_filepath(path: str) -> str:
"""validate and expand ~ in filepath, also un-escapes "\ "
Returns:
expanded path if path is valid file, else None
"""
path = re.sub(r"\\ ", " ", path)
path = pathlib.Path(path).expanduser()
if path.is_file():
return str(path)
return None
def load_function(pyfile: str, function_name: str) -> Callable: def load_function(pyfile: str, function_name: str) -> Callable:
""" Load function_name from python file pyfile """ """Load function_name from python file pyfile"""
module_file = pathlib.Path(pyfile) module_file = pathlib.Path(pyfile)
if not module_file.is_file(): if not module_file.is_file():
raise FileNotFoundError(f"module {pyfile} does not appear to exist") raise FileNotFoundError(f"module {pyfile} does not appear to exist")

View File

@ -0,0 +1,3 @@
# Contents
This directory used by test_template.py for testing {function} templates with hyphenated directory names

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

@ -82,7 +82,9 @@ TEMPLATE_VALUES_TITLE = {
"{title|titlecase}": ["Tulips Tied Together At A Flower Shop"], "{title|titlecase}": ["Tulips Tied Together At A Flower Shop"],
"{title|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"], "{title|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"],
"{title|titlecase|lower|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"], "{title|titlecase|lower|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"],
"{title|titlecase|lower|upper|shell_quote}": ["'TULIPS TIED TOGETHER AT A FLOWER SHOP'"], "{title|titlecase|lower|upper|shell_quote}": [
"'TULIPS TIED TOGETHER AT A FLOWER SHOP'"
],
"{title|upper|titlecase}": ["Tulips Tied Together At A Flower Shop"], "{title|upper|titlecase}": ["Tulips Tied Together At A Flower Shop"],
"{title|capitalize}": ["Tulips tied together at a flower shop"], "{title|capitalize}": ["Tulips tied together at a flower shop"],
"{title[ ,_]}": ["Tulips_tied_together_at_a_flower_shop"], "{title[ ,_]}": ["Tulips_tied_together_at_a_flower_shop"],
@ -388,7 +390,9 @@ def test_lookup_multi(photosdb_places):
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1) lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
if subst in ["{exiftool}", "{photo}", "{function}"]: if subst in ["{exiftool}", "{photo}", "{function}"]:
continue continue
lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep, default=[]) lookup = template.get_template_value_multi(
lookup_str, path_sep=os.path.sep, default=[]
)
assert isinstance(lookup, list) assert isinstance(lookup, list)
@ -975,6 +979,15 @@ def test_conditional(photosdb):
assert sorted(rendered) == sorted(UUID_CONDITIONAL[uuid][template]) assert sorted(rendered) == sorted(UUID_CONDITIONAL[uuid][template])
def test_function_hyphen_dir(photosdb):
"""Test {function} with a hyphenated directory (#477)"""
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
rendered, _ = photo.render_template(
"{function:tests/hyphen-dir/template_function.py::foo}"
)
assert rendered == [f"{photo.original_filename}-FOO"]
def test_function(photosdb): def test_function(photosdb):
"""Test {function}""" """Test {function}"""
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS) photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)