diff --git a/README.md b/README.md index a4f8523c..199b13c5 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,12 @@ Options: output directory in the form '{name,DEFAULT}'. See below for additional details on templating system. + --filename FILENAME Optional template for specifying name of + output file in the form '{name,DEFAULT}'. + File extension will be added automatically-- + do not include an extension in the FILENAME + template. See below for additional details + on templating system. --edited-suffix SUFFIX Optional suffix for naming edited photos. Default name for edited photos is in form 'photoname_edited.ext'. For example, with ' @@ -326,13 +332,16 @@ option to re-export the entire library thus rebuilding the ** Templating System ** -With the --directory option you may specify a template for the export -directory. This directory will be appended to the export path specified in -the export DEST argument to export. For example, if template is -'{created.year}/{created.month}', and export desitnation DEST is -'/Users/maria/Pictures/export', the actual export directory for a photo would -be '/Users/maria/Pictures/export/2020/March' if the photo was created in March -2020. +With the --directory and --filename options you may specify a template for the +export directory or filename, respectively. The directory will be appended to +the export path specified in the export DEST argument to export. For example, +if template is '{created.year}/{created.month}', and export desitnation DEST +is '/Users/maria/Pictures/export', the actual export directory for a photo +would be '/Users/maria/Pictures/export/2020/March' if the photo was created in +March 2020. Some template substitutions may result in more than one value, for +example '{album}' if photo is in more than one album or '{keyword}' if photo +has more than one keyword. In this case, more than one copy of the photo will +be exported, each in a separate directory or with a different filename. The templating system may also be used with the --keyword-template option to set keywords on export (with --exiftool or --sidecar), for example, to set a @@ -358,9 +367,7 @@ contain a brace symbol ('{' or '}'). If you do not specify a default value and the template substitution has no value, '_' (underscore) will be used as the default value. For example, in the -above example, this would result in '2020/_/photoname.jpg' if address was null -I plan to eventually extend the templating system to the exported filename so -you can specify the filename using a template. +above example, this would result in '2020/_/photoname.jpg' if address was null. Substitution Description {name} Current filename of the photo diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 7fff6a3b..e584637a 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -15,23 +15,20 @@ import yaml from pathvalidate import ( is_valid_filename, is_valid_filepath, - sanitize_filepath, sanitize_filename, + sanitize_filepath, ) import osxphotos from ._constants import _EXIF_TOOL_URL, _PHOTOS_4_VERSION, _UNKNOWN_PLACE -from .datetime_formatter import DateTimeFormatter +from ._export_db import ExportDB, ExportDBInMemory from ._version import __version__ +from .datetime_formatter import DateTimeFormatter from .exiftool import get_exiftool_path from .fileutil import FileUtil, FileUtilNoOp from .photoinfo import ExportResults -from .phototemplate import ( - TEMPLATE_SUBSTITUTIONS, - TEMPLATE_SUBSTITUTIONS_MULTI_VALUED, -) -from ._export_db import ExportDB, ExportDBInMemory +from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED # global variable to control verbose output # set via --verbose/-V @@ -134,13 +131,18 @@ class ExportCommand(click.Command): formatter.write_text("** Templating System **") formatter.write("\n") formatter.write_text( - "With the --directory option you may specify a template for the " - + "export directory. This directory will be appended to the export path specified " + "With the --directory and --filename options you may specify a template for the " + + "export directory or filename, respectively. " + + "The directory will be appended to the export path specified " + "in the export DEST argument to export. For example, if template is " + "'{created.year}/{created.month}', and export desitnation DEST is " + "'/Users/maria/Pictures/export', " + "the actual export directory for a photo would be '/Users/maria/Pictures/export/2020/March' " + "if the photo was created in March 2020. " + + "Some template substitutions may result in more than one value, for example '{album}' if " + + "photo is in more than one album or '{keyword}' if photo has more than one keyword. " + + "In this case, more than one copy of the photo will be exported, each in a separate directory " + + "or with a different filename." ) formatter.write("\n") formatter.write_text( @@ -176,11 +178,7 @@ class ExportCommand(click.Command): formatter.write_text( "If you do not specify a default value and the template substitution " + "has no value, '_' (underscore) will be used as the default value. For example, in the " - + "above example, this would result in '2020/_/photoname.jpg' if address was null" - ) - formatter.write_text( - "I plan to eventually extend the templating system " - + "to the exported filename so you can specify the filename using a template." + + "above example, this would result in '2020/_/photoname.jpg' if address was null." ) formatter.write("\n") @@ -1038,13 +1036,22 @@ def query( help="Optional template for specifying name of output directory in the form '{name,DEFAULT}'. " "See below for additional details on templating system.", ) +@click.option( + "--filename", + "filename_template", + metavar="FILENAME", + default=None, + help="Optional template for specifying name of output file in the form '{name,DEFAULT}'. " + "File extension will be added automatically--do not include an extension in the FILENAME template. " + "See below for additional details on templating system.", +) @click.option( "--edited-suffix", metavar="SUFFIX", default="_edited", help="Optional suffix for naming edited photos. Default name for edited photos is in form " "'photoname_edited.ext'. For example, with '--edited-suffix _bearbeiten', the edited photo " - "would be named 'photoname_bearbeiten.ext'. The default suffix is '_edited'." + "would be named 'photoname_bearbeiten.ext'. The default suffix is '_edited'.", ) @click.option( "--no-extended-attributes", @@ -1124,6 +1131,7 @@ def export( not_panorama, has_raw, directory, + filename_template, edited_suffix, place, no_place, @@ -1288,6 +1296,7 @@ def export( photos.extend(burst_set) num_photos = len(photos) + # TODO: photos or photo appears several times, pull into a separate function photo_str = "photos" if num_photos > 1 else "photo" click.echo(f"Exporting {num_photos} {photo_str} to {dest}...") start_time = time.perf_counter() @@ -1310,6 +1319,7 @@ def export( download_missing=download_missing, exiftool=exiftool, directory=directory, + filename_template=filename_template, no_extended_attributes=no_extended_attributes, export_raw=export_raw, album_keyword=album_keyword, @@ -1317,7 +1327,7 @@ def export( keyword_template=keyword_template, export_db=export_db, fileutil=fileutil, - dry_run = dry_run, + dry_run=dry_run, edited_suffix=edited_suffix, ) results_exported.extend(results.exported) @@ -1342,6 +1352,7 @@ def export( download_missing=download_missing, exiftool=exiftool, directory=directory, + filename_template=filename_template, no_extended_attributes=no_extended_attributes, export_raw=export_raw, album_keyword=album_keyword, @@ -1350,7 +1361,7 @@ def export( export_db=export_db, fileutil=fileutil, dry_run=dry_run, - edited_suffix=edited_suffix + edited_suffix=edited_suffix, ) results_exported.extend(results.exported) results_new.extend(results.new) @@ -1762,6 +1773,7 @@ def export_photo( download_missing=None, exiftool=None, directory=None, + filename_template=None, no_extended_attributes=None, export_raw=None, album_keyword=None, @@ -1770,9 +1782,11 @@ def export_photo( export_db=None, fileutil=FileUtil, dry_run=None, - edited_suffix="_edited" + edited_suffix="_edited", ): """ Helper function for export that does the actual export + + Args: photo: PhotoInfo object dest: destination path as string verbose_: boolean; print verbose output @@ -1786,6 +1800,7 @@ def export_photo( download_missing: attempt download of missing iCloud photos exiftool: use exiftool to write EXIF metadata directly to exported photo directory: template used to determine output directory + filename_template: template use to determine output file no_extended_attributes: boolean; if True, exports photo without preserving extended attributes export_raw: boolean; if True exports RAW image associate with the photo album_keyword: boolean; if True, exports album names as keywords in metadata @@ -1794,15 +1809,16 @@ def export_photo( export_db: export database instance compatible with ExportDB_ABC fileutil: file util class compatible with FileUtilABC dry_run: boolean; if True, doesn't actually export or update any files - returns list of path(s) of exported photo or None if photo was missing + + Returns: + list of path(s) of exported photo or None if photo was missing + + Raises: + ValueError on invalid filename_template """ global VERBOSE VERBOSE = True if verbose_ else False - # Can export to multiple paths - # Start with single path [dest] but direcotry and export_by_date will modify dest_paths - dest_paths = [dest] - if not download_missing: if photo.ismissing: space = " " if not verbose_ else "" @@ -1821,149 +1837,125 @@ def export_photo( ) return ExportResults([], [], [], [], []) - filename = None - if original_name: - filename = photo.original_filename - else: - filename = photo.filename + filenames = get_filenames_from_template(photo, filename_template, original_name) + for filename in filenames: + verbose(f"Exporting {photo.filename} as {filename}") - verbose(f"Exporting {photo.filename} as {filename}") - - if export_by_date: - date_created = DateTimeFormatter(photo.date) - dest_path = os.path.join(dest, date_created.year, date_created.mm, date_created.dd) - if not dry_run and not os.path.isdir(dest_path): - os.makedirs(dest_path) - dest_paths = [dest_path] - elif directory: - # got a directory template, render it and check results are valid - dirnames, unmatched = photo.render_template(directory) - if unmatched: - raise click.BadOptionUsage( - "directory", - f"Invalid substitution in template '{directory}': {unmatched}", - ) - dest_paths = [] - for dirname in dirnames: - dirname = sanitize_filepath(dirname, platform="auto") - dest_path = os.path.join(dest, dirname) - if not is_valid_filepath(dest_path, platform="auto"): - raise ValueError(f"Invalid file path: '{dest_path}'") - if not dry_run and not os.path.isdir(dest_path): - os.makedirs(dest_path) - dest_paths.append(dest_path) - - sidecar = [s.lower() for s in sidecar] - sidecar_json = sidecar_xmp = False - if "json" in sidecar: - sidecar_json = True - if "xmp" in sidecar: - sidecar_xmp = True - - # if download_missing and the photo is missing or path doesn't exist, - # try to download with Photos - use_photos_export = download_missing and ( - photo.ismissing or not os.path.exists(photo.path) - ) - - # export the photo to each path in dest_paths - results_exported = [] - results_new = [] - results_updated = [] - results_skipped = [] - results_exif_updated = [] - for dest_path in dest_paths: - export_results = photo.export2( - dest_path, - filename, - sidecar_json=sidecar_json, - sidecar_xmp=sidecar_xmp, - live_photo=export_live, - raw_photo=export_raw, - export_as_hardlink=export_as_hardlink, - overwrite=overwrite, - use_photos_export=use_photos_export, - exiftool=exiftool, - no_xattr=no_extended_attributes, - use_albums_as_keywords=album_keyword, - use_persons_as_keywords=person_keyword, - keyword_template=keyword_template, - update=update, - export_db=export_db, - fileutil=fileutil, - dry_run = dry_run, + dest_paths = get_dirnames_from_template( + photo, directory, export_by_date, dest, dry_run ) - results_exported.extend(export_results.exported) - results_new.extend(export_results.new) - results_updated.extend(export_results.updated) - results_skipped.extend(export_results.skipped) - results_exif_updated.extend(export_results.exif_updated) + sidecar = [s.lower() for s in sidecar] + sidecar_json = sidecar_xmp = False + if "json" in sidecar: + sidecar_json = True + if "xmp" in sidecar: + sidecar_xmp = True - if verbose_: - for exported in export_results.exported: - verbose(f"Exported {exported}") - for new in export_results.new: - verbose(f"Exported new file {new}") - for updated in export_results.updated: - verbose(f"Exported updated file {updated}") - for skipped in export_results.skipped: - verbose(f"Skipped up to date file {skipped}") + # if download_missing and the photo is missing or path doesn't exist, + # try to download with Photos + use_photos_export = download_missing and ( + photo.ismissing or not os.path.exists(photo.path) + ) - # if export-edited, also export the edited version - # verify the photo has adjustments and valid path to avoid raising an exception - if export_edited and photo.hasadjustments: - # if download_missing and the photo is missing or path doesn't exist, - # try to download with Photos - use_photos_export = download_missing and photo.path_edited is None - if not download_missing and photo.path_edited is None: - verbose(f"Skipping missing edited photo for {filename}") - else: - edited_name = pathlib.Path(filename) - # check for correct edited suffix - if photo.path_edited is not None: - edited_ext = pathlib.Path(photo.path_edited).suffix + # export the photo to each path in dest_paths + results_exported = [] + results_new = [] + results_updated = [] + results_skipped = [] + results_exif_updated = [] + for dest_path in dest_paths: + export_results = photo.export2( + dest_path, + filename, + sidecar_json=sidecar_json, + sidecar_xmp=sidecar_xmp, + live_photo=export_live, + raw_photo=export_raw, + export_as_hardlink=export_as_hardlink, + overwrite=overwrite, + use_photos_export=use_photos_export, + exiftool=exiftool, + no_xattr=no_extended_attributes, + use_albums_as_keywords=album_keyword, + use_persons_as_keywords=person_keyword, + keyword_template=keyword_template, + update=update, + export_db=export_db, + fileutil=fileutil, + dry_run=dry_run, + ) + + results_exported.extend(export_results.exported) + results_new.extend(export_results.new) + results_updated.extend(export_results.updated) + results_skipped.extend(export_results.skipped) + results_exif_updated.extend(export_results.exif_updated) + + if verbose_: + for exported in export_results.exported: + verbose(f"Exported {exported}") + for new in export_results.new: + verbose(f"Exported new file {new}") + for updated in export_results.updated: + verbose(f"Exported updated file {updated}") + for skipped in export_results.skipped: + verbose(f"Skipped up to date file {skipped}") + + # if export-edited, also export the edited version + # verify the photo has adjustments and valid path to avoid raising an exception + if export_edited and photo.hasadjustments: + # if download_missing and the photo is missing or path doesn't exist, + # try to download with Photos + use_photos_export = download_missing and photo.path_edited is None + if not download_missing and photo.path_edited is None: + verbose(f"Skipping missing edited photo for {filename}") else: - # use filename suffix which might be wrong, - # will be corrected by use_photos_export - edited_ext = pathlib.Path(photo.filename).suffix - edited_name = f"{edited_name.stem}{edited_suffix}{edited_ext}" - verbose(f"Exporting edited version of {filename} as {edited_name}") - export_results_edited = photo.export2( - dest_path, - edited_name, - sidecar_json=sidecar_json, - sidecar_xmp=sidecar_xmp, - export_as_hardlink=export_as_hardlink, - overwrite=overwrite, - edited=True, - use_photos_export=use_photos_export, - exiftool=exiftool, - no_xattr=no_extended_attributes, - use_albums_as_keywords=album_keyword, - use_persons_as_keywords=person_keyword, - keyword_template=keyword_template, - update=update, - export_db=export_db, - fileutil=fileutil, - dry_run = dry_run, - ) + edited_name = pathlib.Path(filename) + # check for correct edited suffix + if photo.path_edited is not None: + edited_ext = pathlib.Path(photo.path_edited).suffix + else: + # use filename suffix which might be wrong, + # will be corrected by use_photos_export + edited_ext = pathlib.Path(photo.filename).suffix + edited_name = f"{edited_name.stem}{edited_suffix}{edited_ext}" + verbose(f"Exporting edited version of {filename} as {edited_name}") + export_results_edited = photo.export2( + dest_path, + edited_name, + sidecar_json=sidecar_json, + sidecar_xmp=sidecar_xmp, + export_as_hardlink=export_as_hardlink, + overwrite=overwrite, + edited=True, + use_photos_export=use_photos_export, + exiftool=exiftool, + no_xattr=no_extended_attributes, + use_albums_as_keywords=album_keyword, + use_persons_as_keywords=person_keyword, + keyword_template=keyword_template, + update=update, + export_db=export_db, + fileutil=fileutil, + dry_run=dry_run, + ) - results_exported.extend(export_results_edited.exported) - results_new.extend(export_results_edited.new) - results_updated.extend(export_results_edited.updated) - results_skipped.extend(export_results_edited.skipped) - results_exif_updated.extend(export_results_edited.exif_updated) + results_exported.extend(export_results_edited.exported) + results_new.extend(export_results_edited.new) + results_updated.extend(export_results_edited.updated) + results_skipped.extend(export_results_edited.skipped) + results_exif_updated.extend(export_results_edited.exif_updated) - if verbose_: - for exported in export_results_edited.exported: - verbose(f"Exported {exported}") - for new in export_results_edited.new: - verbose(f"Exported new file {new}") - for updated in export_results_edited.updated: - verbose(f"Exported updated file {updated}") - for skipped in export_results_edited.skipped: - verbose(f"Skipped up to date file {skipped}") + if verbose_: + for exported in export_results_edited.exported: + verbose(f"Exported {exported}") + for new in export_results_edited.new: + verbose(f"Exported new file {new}") + for updated in export_results_edited.updated: + verbose(f"Exported updated file {updated}") + for skipped in export_results_edited.skipped: + verbose(f"Skipped up to date file {skipped}") return ExportResults( results_exported, @@ -1974,5 +1966,83 @@ def export_photo( ) +def get_filenames_from_template(photo, filename_template, original_name): + """ get list of export filenames for a photo + + Args: + photo: a PhotoInfo instance + filename_template: a PhotoTemplate template string, may be None + original_name: boolean; if True, use photo's original filename instead of current filename + + Returns: + list of filenames + + Raises: + click.BadOptionUsage if template is invalid + """ + if filename_template: + photo_ext = pathlib.Path(photo.original_filename).suffix + filenames, unmatched = photo.render_template(filename_template, path_sep="_") + if not filenames or unmatched: + raise click.BadOptionUsage( + "filename_template", + f"Invalid template '{filename_template}': results={filenames} unmatched={unmatched}", + ) + filenames = [f"{file_}{photo_ext}" for file_ in filenames] + else: + if original_name: + filenames = [photo.original_filename] + else: + filenames = [photo.filename] + return filenames + + +def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run): + """ get list of directories to export a photo into, creates directories if they don't exist + + Args: + photo: a PhotoInstance object + directory: a PhotoTemplate template string, may be None + export_by_date: boolean; if True, creates output directories in form YYYY-MM-DD + dest: top-level destination directory + dry_run: boolean; if True, runs in dry-run mode and does not create output directories + + Returns: + list of export directories + + Raises: + click.BadOptionUsage if template is invalid + """ + + if export_by_date: + date_created = DateTimeFormatter(photo.date) + dest_path = os.path.join( + dest, date_created.year, date_created.mm, date_created.dd + ) + if not dry_run and not os.path.isdir(dest_path): + os.makedirs(dest_path) + dest_paths = [dest_path] + elif directory: + # got a directory template, render it and check results are valid + dirnames, unmatched = photo.render_template(directory) + if not dirnames or unmatched: + raise click.BadOptionUsage( + "directory", + f"Invalid template '{directory}': results={dirnames} unmatched={unmatched}", + ) + dest_paths = [] + for dirname in dirnames: + dirname = sanitize_filepath(dirname, platform="auto") + dest_path = os.path.join(dest, dirname) + if not is_valid_filepath(dest_path, platform="auto"): + raise ValueError(f"Invalid file path: '{dest_path}'") + if not dry_run and not os.path.isdir(dest_path): + os.makedirs(dest_path) + dest_paths.append(dest_path) + else: + dest_paths = [dest] + return dest_paths + + if __name__ == "__main__": cli() # pylint: disable=no-value-for-parameter diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 8424be87..e5ae0f3b 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.29.8" +__version__ = "0.29.9" diff --git a/osxphotos/photoinfo/photoinfo.py b/osxphotos/photoinfo/photoinfo.py index 8264b777..75651b5f 100644 --- a/osxphotos/photoinfo/photoinfo.py +++ b/osxphotos/photoinfo/photoinfo.py @@ -29,6 +29,7 @@ from ..placeinfo import PlaceInfo4, PlaceInfo5 from ..phototemplate import PhotoTemplate from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension + class PhotoInfo: """ Info about a specific photo, contains all the details about the photo diff --git a/tests/test_cli.py b/tests/test_cli.py index 2e644ace..90b84534 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import os + import pytest from click.testing import CliRunner @@ -10,6 +11,7 @@ RAW_PHOTOS_DB = "tests/Test-RAW-10.15.1.photoslibrary" PLACES_PHOTOS_DB = "tests/Test-Places-Catalina-10_15_1.photoslibrary" PLACES_PHOTOS_DB_13 = "tests/Test-Places-High-Sierra-10.13.6.photoslibrary" PHOTOS_DB_15_4 = "tests/Test-10.15.4.photoslibrary" +PHOTOS_DB_15_5 = "tests/Test-10.15.5.photoslibrary" PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary" CLI_OUTPUT_NO_SUBCOMMAND = [ @@ -132,6 +134,38 @@ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES3 = [ "2018/{foo}/Pumkins1.jpg", ] + +CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1 = [ + "2019-wedding.jpg", + "2019-wedding_edited.jpeg", + "2019-Tulips.jpg", + "2018-St James Park.jpg", + "2018-St James Park_edited.jpeg", + "2018-Pumpkins3.jpg", + "2018-Pumkins2.jpg", + "2018-Pumkins1.jpg", +] + +CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2 = [ + "Folder1_SubFolder2_AlbumInFolder-IMG_4547.jpg", + "Folder1_SubFolder2_AlbumInFolder-wedding.jpg", + "Folder1_SubFolder2_AlbumInFolder-wedding_edited.jpeg", + "Folder2_Raw-DSC03584.dng", + "Folder2_Raw-IMG_1994.cr2", + "Folder2_Raw-IMG_1994.JPG", + "Folder2_Raw-IMG_1997.cr2", + "Folder2_Raw-IMG_1997.JPG", + "None-St James Park.jpg", + "None-St James Park_edited.jpeg", + "None-Tulips.jpg", + "None-Tulips_edited.jpeg", + "Pumpkin Farm-Pumkins1.jpg", + "Pumpkin Farm-Pumkins2.jpg", + "Pumpkin Farm-Pumpkins3.jpg", + "Test Album-Pumkins1.jpg", + "Test Album-Pumkins2.jpg", +] + CLI_EXPORT_UUID = "D79B8D77-BFFC-460B-9312-034F2877D35B" CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg" @@ -706,7 +740,7 @@ def test_export_directory_template_2(): def test_export_directory_template_3(): - # test export using directory template with unmatched substituion value + # test export using directory template with unmatched substitution value import glob import os import os.path @@ -728,7 +762,7 @@ def test_export_directory_template_3(): ], ) assert result.exit_code == 2 - assert "Error: Invalid substitution in template" in result.output + assert "Error: Invalid template" in result.output def test_export_directory_template_album_1(): @@ -825,6 +859,93 @@ def test_export_directory_template_locale(): assert os.path.isfile(os.path.join(workdir, filepath)) +def test_export_filename_template_1(): + """ export photos using filename template """ + import glob + import locale + import os + import os.path + import osxphotos + from osxphotos.__main__ import export + + locale.setlocale(locale.LC_ALL, "en_US") + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + export, + [ + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "-V", + "--filename", + "{created.year}-{original_name}", + ], + ) + assert result.exit_code == 0 + workdir = os.getcwd() + files = glob.glob("*.*") + assert sorted(files) == sorted(CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1) + + +def test_export_filename_template_2(): + """ export photos using filename template with folder_album and path_sep """ + import glob + import locale + import os + import os.path + import osxphotos + from osxphotos.__main__ import export + + locale.setlocale(locale.LC_ALL, "en_US") + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_5), + ".", + "-V", + "--filename", + "{folder_album,None}-{original_name}", + ], + ) + assert result.exit_code == 0 + files = glob.glob("*.*") + assert sorted(files) == sorted(CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2) + + +def test_export_filename_template_3(): + """ test --filename with invalid template """ + import glob + import os + import os.path + import osxphotos + from osxphotos.__main__ import export + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + export, + [ + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "-V", + "--directory", + "{foo}-{original_filename}", + ], + ) + assert result.exit_code == 2 + assert "Error: Invalid template" in result.output + + def test_places(): import json import os