diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 789e9078..6e6d9290 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.42.45" +__version__ = "0.42.46" diff --git a/osxphotos/cli.py b/osxphotos/cli.py index d915cd0c..ed8ea1ec 100644 --- a/osxphotos/cli.py +++ b/osxphotos/cli.py @@ -57,7 +57,7 @@ from .photokit import check_photokit_authorization, request_photokit_authorizati from .photosalbum import PhotosAlbum from .phototemplate import PhotoTemplate, RenderOptions 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 # set via --verbose/-V @@ -172,13 +172,14 @@ class FunctionCall(click.ParamType): 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") try: - function = load_function(filename, funcname) + function = load_function(filename_validated, funcname) 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) diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index a6125d6c..507590a8 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -4,8 +4,10 @@ import datetime import locale import os import pathlib -import sys import shlex +import sys +from dataclasses import dataclass +from typing import Optional from textx import TextXSyntaxError, metamodel_from_file @@ -14,9 +16,7 @@ from ._version import __version__ from .datetime_formatter import DateTimeFormatter from .exiftool import ExifToolCaching from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart -from .utils import load_function -from dataclasses import dataclass -from typing import Optional +from .utils import expand_and_validate_filepath, load_function # 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("::") - 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") - template_func = load_function(filename, funcname) + template_func = load_function(filename_validated, funcname) values = template_func(self.photo) if not isinstance(values, (str, list)): @@ -1211,10 +1212,11 @@ class PhotoTemplate: 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") - template_func = load_function(filename, funcname) + template_func = load_function(filename_validated, funcname) if not isinstance(values, (list, tuple)): values = [values] diff --git a/osxphotos/phototemplate.tx b/osxphotos/phototemplate.tx index 795f4a7a..7423c154 100644 --- a/osxphotos/phototemplate.tx +++ b/osxphotos/phototemplate.tx @@ -63,7 +63,8 @@ SubField: ; SUBFIELD_WORD: - /[\.\w:\/]+/ + /[\.\w:\/\-\~\'\"\%\@\#\^\’]+/ + /\\\s/? ; Filter: diff --git a/osxphotos/utils.py b/osxphotos/utils.py index 0c857975..361dccec 100644 --- a/osxphotos/utils.py +++ b/osxphotos/utils.py @@ -38,7 +38,7 @@ if not _DEBUG: def _get_logger(): """Used only for testing - + Returns: logging.Logger object -- logging.Logger object for osxphotos """ @@ -46,7 +46,7 @@ def _get_logger(): def _set_debug(debug): - """ Enable or disable debug logging """ + """Enable or disable debug logging""" global _DEBUG _DEBUG = debug if debug: @@ -56,18 +56,18 @@ def _set_debug(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 def noop(*args, **kwargs): - """ do nothing (no operation) """ + """do nothing (no operation)""" pass def lineno(filename): - """ 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. """ + """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.""" line = inspect.currentframe().f_back.f_lineno filename = pathlib.Path(filename).name return f"{filename}: {line}" @@ -92,14 +92,14 @@ def _get_os_version(): def _check_file_exists(filename): - """ returns true if file exists and is not a directory - otherwise returns false """ + """returns true if file exists and is not a directory + otherwise returns false""" filename = os.path.abspath(filename) return os.path.exists(filename) and not os.path.isdir(filename) 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 """ # determine folder where Photos stores edited version # edited images are stored in: @@ -117,7 +117,7 @@ def _get_resource_loc(model_id): 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) """ dd = float(dd) negative = dd < 0 @@ -136,7 +136,7 @@ def _dd_to_dms(dd): 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 """ """ lon: longitude in degrees """ """ 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(): - """ 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 """ """ on earlier versions, returns None """ _, major, _ = _get_os_version() @@ -190,8 +190,8 @@ def get_system_library_path(): def get_last_library_path(): - """ returns the path to the last opened Photos library - If a library has never been opened, returns None """ + """returns the path to the last opened Photos library + If a library has never been opened, returns None""" plist_file = pathlib.Path( str(pathlib.Path.home()) + "/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(): - """ 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 10.15, mdfind appears to find all libraries @@ -266,9 +266,9 @@ def list_photo_libraries(): def get_preferred_uti_extension(uti): - """ get preferred extension for a UTI type - uti: UTI str, e.g. 'public.jpeg' - returns: preferred extension as str or None if cannot be determined """ + """get preferred extension for a UTI type + uti: UTI str, e.g. 'public.jpeg' + returns: preferred extension as str or None if cannot be determined""" # reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc with objc.autorelease_pool(): @@ -288,8 +288,8 @@ def get_preferred_uti_extension(uti): def findfiles(pattern, path_): """Returns list of filenames from path_ matched by pattern - shell pattern. Matching is case-insensitive. - If 'path_' is invalid/doesn't exist, returns [].""" + shell pattern. Matching is case-insensitive. + If 'path_' is invalid/doesn't exist, returns [].""" if not os.path.isdir(path_): return [] # See: https://gist.github.com/techtonik/5694830 @@ -316,8 +316,8 @@ def findfiles(pattern, path_): def _open_sql_file(dbname): - """ opens sqlite file dbname in read-only mode - returns tuple of (connection, cursor) """ + """opens sqlite file dbname in read-only mode + returns tuple of (connection, cursor)""" try: dbpath = pathlib.Path(dbname).resolve() 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): - """ check to see if a sqlite3 db is locked - returns True if database is locked, otherwise False - dbname: name of database to test """ + """check to see if a sqlite3 db is locked + returns True if database is locked, otherwise False + dbname: name of database to test""" # first, check to see if lock file exists, if so, assume the file is locked lock_name = f"{dbname}.lock" @@ -381,7 +381,7 @@ def _db_is_locked(dbname): def normalize_unicode(value): - """ normalize unicode data """ + """normalize unicode data""" if value is not None: if isinstance(value, (tuple, list)): return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value) @@ -394,9 +394,9 @@ def normalize_unicode(value): 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. Args: @@ -419,8 +419,22 @@ def increment_filename(filepath): 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: - """ Load function_name from python file pyfile """ + """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") diff --git a/tests/hyphen-dir/README.md b/tests/hyphen-dir/README.md new file mode 100644 index 00000000..32dd5d3e --- /dev/null +++ b/tests/hyphen-dir/README.md @@ -0,0 +1,3 @@ +# Contents + +This directory used by test_template.py for testing {function} templates with hyphenated directory names \ No newline at end of file diff --git a/tests/hyphen-dir/template_function.py b/tests/hyphen-dir/template_function.py new file mode 100644 index 00000000..849d61f5 --- /dev/null +++ b/tests/hyphen-dir/template_function.py @@ -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" diff --git a/tests/test_template.py b/tests/test_template.py index 86c6206f..646de0a3 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -82,7 +82,9 @@ TEMPLATE_VALUES_TITLE = { "{title|titlecase}": ["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|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|capitalize}": ["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) if subst in ["{exiftool}", "{photo}", "{function}"]: 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) @@ -975,6 +979,15 @@ def test_conditional(photosdb): 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): """Test {function}""" photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)