Bug fix for template functions #477
This commit is contained in:
parent
5ea01df69b
commit
49317582c4
@ -1,3 +1,3 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.42.45"
|
__version__ = "0.42.46"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -63,7 +63,8 @@ SubField:
|
|||||||
;
|
;
|
||||||
|
|
||||||
SUBFIELD_WORD:
|
SUBFIELD_WORD:
|
||||||
/[\.\w:\/]+/
|
/[\.\w:\/\-\~\'\"\%\@\#\^\’]+/
|
||||||
|
/\\\s/?
|
||||||
;
|
;
|
||||||
|
|
||||||
Filter:
|
Filter:
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
3
tests/hyphen-dir/README.md
Normal file
3
tests/hyphen-dir/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Contents
|
||||||
|
|
||||||
|
This directory used by test_template.py for testing {function} templates with hyphenated directory names
|
||||||
20
tests/hyphen-dir/template_function.py
Normal file
20
tests/hyphen-dir/template_function.py
Normal 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"
|
||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user