diff --git a/examples/template_function_import.py b/examples/template_function_import.py new file mode 100644 index 00000000..306496c2 --- /dev/null +++ b/examples/template_function_import.py @@ -0,0 +1,27 @@ +""" Example showing how to use a custom function for osxphotos {function} template with the `osxphotos import` command + Use: osxphotos import /path/to/import/*.jpg --album "{function:/path/to/template_function_import.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 +from typing import List, Optional, Union + + +def example( + filepath: pathlib.Path, args: Optional[str] = None, **kwargs +) -> Union[List, str]: + """example function for {function} template for use with `osxphotos import` + + This example parses filenames in format album_img_123.jpg and returns the album name + + Args: + filepath: pathlib.Path object of file being imported + args: optional str of arguments passed to template function + **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 + """ + filename = filepath.stem + fields = filename.split("_") + return fields[0] if len(fields) > 1 else "" diff --git a/osxphotos/cli/import_cli.py b/osxphotos/cli/import_cli.py index 06be90f4..09bbe537 100644 --- a/osxphotos/cli/import_cli.py +++ b/osxphotos/cli/import_cli.py @@ -119,7 +119,7 @@ class PhotoInfoFromFile: Returns: ([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values """ - options = options or RenderOptions() + options = options or RenderOptions(caller="import") template = PhotoTemplate(self, exiftool_path=self._exiftool_path) return template.render(template_str, options) @@ -163,7 +163,7 @@ def render_photo_template( photoinfo = PhotoInfoFromFile(filepath, exiftool=exiftool_path) options = RenderOptions( - none_str=_OSXPHOTOS_NONE_SENTINEL, filepath=relative_filepath + none_str=_OSXPHOTOS_NONE_SENTINEL, filepath=relative_filepath, caller="import" ) template_values, _ = photoinfo.render_template(template, options=options) # filter out empty strings diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index 95c5524e..3c01a50b 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -329,6 +329,7 @@ class RenderOptions: dest_path: set to the destination path of the photo (for use by {function} template), only valid with --filename filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template quote: quote path templates for execution in the shell + caller: which command is calling the template (e.g. 'export') """ none_str: str = "_" @@ -343,6 +344,7 @@ class RenderOptions: dest_path: Optional[str] = None filepath: Optional[str] = None quote: bool = False + caller: str = "export" class PhotoTemplateParser: @@ -788,7 +790,9 @@ class PhotoTemplate: raise ValueError( "SyntaxError: filename and function must not be null with {function::filename.py:function_name}" ) - vals = self.get_template_value_function(subfield, field_arg) + vals = self.get_template_value_function( + subfield, field_arg, self.options.caller + ) elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"): vals = self.get_template_value_multi( field, subfield, path_sep=field_arg, default=default @@ -1459,10 +1463,17 @@ class PhotoTemplate: def get_template_value_function( self, - subfield, - field_arg, + subfield: str, + field_arg: Optional[str], + caller: str, ): - """Get template value from external function""" + """Get template value from external function + + Args: + subfield: the filename and function name in for filename.py::function + field_arg: the argument to pass to the function + caller: the calling source of the template ('export' or 'import') + """ if "::" not in subfield: raise ValueError( @@ -1481,8 +1492,17 @@ class PhotoTemplate: # if no uuid, then template is being validated but not actually run # so don't run the function values = [] - else: + elif caller == "export": + # function signature is: + # def example(photo: PhotoInfo, options: ExportOptions, args: Optional[str] = None, **kwargs) -> Union[List, str]: values = template_func(self.photo, options=self.options, args=field_arg) + elif caller == "import": + # function signature is: + # def example(filepath: pathlib.Path, args: Optional[str] = None, **kwargs) -> Union[List, str]: + # the PhotoInfoFromFile class used by import sets `path` to the path of the file being imported + values = template_func(pathlib.Path(self.photo.path), args=field_arg) + else: + raise ValueError(f"Unhandled caller: {caller}") if not isinstance(values, (str, list)): raise TypeError( diff --git a/tests/test_cli_import.py b/tests/test_cli_import.py index 25e791ef..b593da3a 100644 --- a/tests/test_cli_import.py +++ b/tests/test_cli_import.py @@ -4,7 +4,9 @@ import os import os.path import pathlib import re +import shutil import time +from tempfile import TemporaryDirectory from typing import Dict import pytest @@ -13,7 +15,7 @@ from photoscript import Photo from pytest import approx from osxphotos.cli.import_cli import import_cli -from osxphotos.exiftool import ExifTool, get_exiftool_path +from osxphotos.exiftool import get_exiftool_path from tests.conftest import get_os_version TERMINAL_WIDTH = 250 @@ -644,3 +646,37 @@ def test_import_check_templates(): for idx, line in enumerate(output): assert line == TEST_DATA[TEST_IMAGE_1]["check_templates"][idx] + + +@pytest.mark.test_import +def test_import_function_template(): + """Test import with a function template""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + function = os.path.join(cwd, "examples/template_function_import.py") + with TemporaryDirectory() as tempdir: + test_image = shutil.copy( + test_image_1, os.path.join(tempdir, "MyAlbum_IMG_0001.jpg") + ) + runner = CliRunner() + result = runner.invoke( + import_cli, + [ + "--verbose", + "--album", + "{function:" + function + "::example}", + test_image, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + albums = [a.title for a in photo_1.albums] + assert albums == ["MyAlbum"]