Added --filename to CLI, closes #89

This commit is contained in:
Rhet Turnbull
2020-05-31 11:25:34 -07:00
parent d47fd46a21
commit 6c84827ec7
5 changed files with 370 additions and 171 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.29.8"
__version__ = "0.29.9"

View File

@@ -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

View File

@@ -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