Merge pull request #290 from RhetTbull/save_config

Added --save-config, --load-config
This commit is contained in:
Rhet Turnbull
2020-12-12 07:58:04 -08:00
committed by GitHub
8 changed files with 629 additions and 93 deletions

View File

@@ -405,6 +405,21 @@ Options:
set. For example, photos which had set. For example, photos which had
previously been exported and were previously been exported and were
subsequently deleted in Photos. subsequently deleted in Photos.
--load-config <config file path>
Load options from file as written with
--save-config. This allows you to save a
complex export command to file for later
reuse. For example: 'osxphotos export <lots
of options here> --save-config
osxphotos.toml' then 'osxphotos export
/path/to/export --load-config
osxphotos.toml'. If any other command line
options are used in conjunction with --load-
config, they will override the corresponding
values in the config file.
--save-config <config file path>
Save options to file for use with --load-
config. File format is TOML.
-h, --help Show this message and exit. -h, --help Show this message and exit.
** Export ** ** Export **
@@ -2240,6 +2255,7 @@ For additional details about how osxphotos is implemented or if you would like t
- [bpylist2](https://pypi.org/project/bpylist2/) - [bpylist2](https://pypi.org/project/bpylist2/)
- [pathvalidate](https://pypi.org/project/pathvalidate/) - [pathvalidate](https://pypi.org/project/pathvalidate/)
- [wurlitzer](https://pypi.org/project/wurlitzer/) - [wurlitzer](https://pypi.org/project/wurlitzer/)
- [toml](https://github.com/uiri/toml)
## Acknowledgements ## Acknowledgements

View File

@@ -19,9 +19,17 @@ from ._constants import (
_EXIF_TOOL_URL, _EXIF_TOOL_URL,
_PHOTOS_4_VERSION, _PHOTOS_4_VERSION,
_UNKNOWN_PLACE, _UNKNOWN_PLACE,
DEFAULT_JPEG_QUALITY,
DEFAULT_EDITED_SUFFIX,
DEFAULT_ORIGINAL_SUFFIX,
UNICODE_FORMAT, UNICODE_FORMAT,
) )
from ._version import __version__ from ._version import __version__
from .configoptions import (
ConfigOptions,
ConfigOptionsInvalidError,
ConfigOptionsLoadError,
)
from .datetime_formatter import DateTimeFormatter from .datetime_formatter import DateTimeFormatter
from .exiftool import get_exiftool_path from .exiftool import get_exiftool_path
from .export_db import ExportDB, ExportDBInMemory from .export_db import ExportDB, ExportDBInMemory
@@ -39,7 +47,7 @@ VERBOSE = False
OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db" OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
def verbose(*args, **kwargs): def verbose_(*args, **kwargs):
""" print output if verbose flag set """ """ print output if verbose flag set """
if VERBOSE: if VERBOSE:
click.echo(*args, **kwargs) click.echo(*args, **kwargs)
@@ -587,14 +595,14 @@ def cli(ctx, db, json_, debug):
help="Use with '--dump photos' to dump only certain UUIDs", help="Use with '--dump photos' to dump only certain UUIDs",
multiple=True, multiple=True,
) )
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.") @click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose_): def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
""" Print out debug info """ """ Print out debug info """
global VERBOSE global VERBOSE
VERBOSE = bool(verbose_) VERBOSE = bool(verbose)
db = get_photos_db(*photos_library, db, cli_obj.db) db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None: if db is None:
@@ -605,7 +613,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose_):
start_t = time.perf_counter() start_t = time.perf_counter()
print(f"Opening database: {db}") print(f"Opening database: {db}")
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose) photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
stop_t = time.perf_counter() stop_t = time.perf_counter()
print(f"Done; took {(stop_t-start_t):.2f} seconds") print(f"Done; took {(stop_t-start_t):.2f} seconds")
@@ -1197,7 +1205,7 @@ def query(
@cli.command(cls=ExportCommand) @cli.command(cls=ExportCommand)
@DB_OPTION @DB_OPTION
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.") @click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
@query_options @query_options
@click.option( @click.option(
"--missing", "--missing",
@@ -1283,11 +1291,10 @@ def query(
@click.option( @click.option(
"--jpeg-quality", "--jpeg-quality",
type=click.FloatRange(0.0, 1.0), type=click.FloatRange(0.0, 1.0),
default=1.0,
help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. " help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
"A value of 1.0 specifies best quality, " "A value of 1.0 specifies best quality, "
"a value of 0.0 specifies maximum compression. " "a value of 0.0 specifies maximum compression. "
"Defaults to 1.0.", f"Defaults to {DEFAULT_JPEG_QUALITY}",
) )
@click.option( @click.option(
"--download-missing", "--download-missing",
@@ -1395,15 +1402,13 @@ def query(
@click.option( @click.option(
"--edited-suffix", "--edited-suffix",
metavar="SUFFIX", metavar="SUFFIX",
default="_edited",
help="Optional suffix for naming edited photos. Default name for edited photos is in form " 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 " "'photoname_edited.ext'. For example, with '--edited-suffix _bearbeiten', the edited photo "
"would be named 'photoname_bearbeiten.ext'. The default suffix is '_edited'.", f"would be named 'photoname_bearbeiten.ext'. The default suffix is '{DEFAULT_EDITED_SUFFIX}'.",
) )
@click.option( @click.option(
"--original-suffix", "--original-suffix",
metavar="SUFFIX", metavar="SUFFIX",
default="",
help="Optional suffix for naming original photos. Default name for original photos is in form " help="Optional suffix for naming original photos. Default name for original photos is in form "
"'filename.ext'. For example, with '--original-suffix _original', the original photo " "'filename.ext'. For example, with '--original-suffix _original', the original photo "
"would be named 'filename_original.ext'. The default suffix is '' (no suffix).", "would be named 'filename_original.ext'. The default suffix is '' (no suffix).",
@@ -1411,20 +1416,18 @@ def query(
@click.option( @click.option(
"--use-photos-export", "--use-photos-export",
is_flag=True, is_flag=True,
default=False,
help="Force the use of AppleScript or PhotoKit to export even if not missing (see also '--download-missing' and '--use-photokit').", help="Force the use of AppleScript or PhotoKit to export even if not missing (see also '--download-missing' and '--use-photokit').",
) )
@click.option( @click.option(
"--use-photokit", "--use-photokit",
is_flag=True, is_flag=True,
default=False,
help="Use with '--download-missing' or '--use-photos-export' to use direct Photos interface instead of AppleScript to export. " help="Use with '--download-missing' or '--use-photos-export' to use direct Photos interface instead of AppleScript to export. "
"Highly experimental alpha feature; does not work with iTerm2 (use with Terminal.app). " "Highly experimental alpha feature; does not work with iTerm2 (use with Terminal.app). "
"This is faster and more reliable than the default AppleScript interface.", "This is faster and more reliable than the default AppleScript interface.",
) )
@click.option( @click.option(
"--report", "--report",
metavar="REPORTNAME.CSV", metavar="<path to export report>",
help="Write a CSV formatted report of all files that were exported.", help="Write a CSV formatted report of all files that were exported.",
type=click.Path(), type=click.Path(),
) )
@@ -1434,6 +1437,29 @@ def query(
help="Cleanup export directory by deleting any files which were not included in this export set. " help="Cleanup export directory by deleting any files which were not included in this export set. "
"For example, photos which had previously been exported and were subsequently deleted in Photos.", "For example, photos which had previously been exported and were subsequently deleted in Photos.",
) )
@click.option(
"--load-config",
required=False,
metavar="<config file path>",
default=None,
help=(
"Load options from file as written with --save-config. "
"This allows you to save a complex export command to file for later reuse. "
"For example: 'osxphotos export <lots of options here> --save-config osxphotos.toml' then "
" 'osxphotos export /path/to/export --load-config osxphotos.toml'. "
"If any other command line options are used in conjunction with --load-config, "
"they will override the corresponding values in the config file."
),
type=click.Path(exists=True),
)
@click.option(
"--save-config",
required=False,
metavar="<config file path>",
default=None,
help=("Save options to file for use with --load-config. File format is TOML."),
type=click.Path(),
)
@DB_ARGUMENT @DB_ARGUMENT
@click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.argument("dest", nargs=1, type=click.Path(exists=True))
@click.pass_obj @click.pass_obj
@@ -1465,7 +1491,7 @@ def export(
not_shared, not_shared,
from_date, from_date,
to_date, to_date,
verbose_, verbose,
missing, missing,
update, update,
dry_run, dry_run,
@@ -1528,6 +1554,8 @@ def export(
use_photokit, use_photokit,
report, report,
cleanup, cleanup,
load_config,
save_config,
): ):
"""Export photos from the Photos database. """Export photos from the Photos database.
Export path DEST is required. Export path DEST is required.
@@ -1541,8 +1569,174 @@ def export(
to modify this behavior. to modify this behavior.
""" """
# NOTE: because of the way ConfigOptions works, Click options must not
# set defaults which are not None or False. If defaults need to be set
# do so below after load_config and save_config are handled.
cfg = ConfigOptions(
"export",
locals(),
ignore=["ctx", "cli_obj", "dest", "load_config", "save_config"],
)
# print(jpeg_quality, edited_suffix, original_suffix)
global VERBOSE global VERBOSE
VERBOSE = bool(verbose_) VERBOSE = bool(verbose)
if load_config:
try:
cfg.load_from_file(load_config)
except ConfigOptionsLoadError as e:
click.echo(
f"Error parsing {load_config} config file: {e.message}", err=True
)
raise click.Abort()
# re-set the local function vars to the corresponding config value
# this isn't elegant but avoids having to rewrite this function to use cfg.varname for every parameter
db = cfg.db
photos_library = cfg.photos_library
keyword = cfg.keyword
person = cfg.person
album = cfg.album
folder = cfg.folder
uuid = cfg.uuid
uuid_from_file = cfg.uuid_from_file
title = cfg.title
no_title = cfg.no_title
description = cfg.description
no_description = cfg.no_description
uti = cfg.uti
ignore_case = cfg.ignore_case
edited = cfg.edited
external_edit = cfg.external_edit
favorite = cfg.favorite
not_favorite = cfg.not_favorite
hidden = cfg.hidden
not_hidden = cfg.not_hidden
shared = cfg.shared
not_shared = cfg.not_shared
from_date = cfg.from_date
to_date = cfg.to_date
verbose = cfg.verbose
missing = cfg.missing
update = cfg.update
dry_run = cfg.dry_run
export_as_hardlink = cfg.export_as_hardlink
touch_file = cfg.touch_file
overwrite = cfg.overwrite
export_by_date = cfg.export_by_date
skip_edited = cfg.skip_edited
skip_original_if_edited = cfg.skip_original_if_edited
skip_bursts = cfg.skip_bursts
skip_live = cfg.skip_live
skip_raw = cfg.skip_raw
person_keyword = cfg.person_keyword
album_keyword = cfg.album_keyword
keyword_template = cfg.keyword_template
description_template = cfg.description_template
current_name = cfg.current_name
convert_to_jpeg = cfg.convert_to_jpeg
jpeg_quality = cfg.jpeg_quality
sidecar = cfg.sidecar
only_photos = cfg.only_photos
only_movies = cfg.only_movies
burst = cfg.burst
not_burst = cfg.not_burst
live = cfg.live
not_live = cfg.not_live
download_missing = cfg.download_missing
exiftool = cfg.exiftool
ignore_date_modified = cfg.ignore_date_modified
portrait = cfg.portrait
not_portrait = cfg.not_portrait
screenshot = cfg.screenshot
not_screenshot = cfg.not_screenshot
slow_mo = cfg.slow_mo
not_slow_mo = cfg.not_slow_mo
time_lapse = cfg.time_lapse
not_time_lapse = cfg.not_time_lapse
hdr = cfg.hdr
not_hdr = cfg.not_hdr
selfie = cfg.selfie
not_selfie = cfg.not_selfie
panorama = cfg.panorama
not_panorama = cfg.not_panorama
has_raw = cfg.has_raw
directory = cfg.directory
filename_template = cfg.filename_template
edited_suffix = cfg.edited_suffix
original_suffix = cfg.original_suffix
place = cfg.place
no_place = cfg.no_place
has_comment = cfg.has_comment
no_comment = cfg.no_comment
has_likes = cfg.has_likes
no_likes = cfg.no_likes
label = cfg.label
deleted = cfg.deleted
deleted_only = cfg.deleted_only
use_photos_export = cfg.use_photos_export
use_photokit = cfg.use_photokit
report = cfg.report
cleanup = cfg.cleanup
# config file might have changed verbose
VERBOSE = bool(verbose)
verbose_(f"Loaded options from file {load_config}")
exclusive_options = [
("favorite", "not_favorite"),
("hidden", "not_hidden"),
("title", "no_title"),
("description", "no_description"),
("only_photos", "only_movies"),
("burst", "not_burst"),
("live", "not_live"),
("portrait", "not_portrait"),
("screenshot", "not_screenshot"),
("slow_mo", "not_slow_mo"),
("time_lapse", "not_time_lapse"),
("hdr", "not_hdr"),
("selfie", "not_selfie"),
("panorama", "not_panorama"),
("export_by_date", "directory"),
("export_as_hardlink", "exiftool"),
("place", "no_place"),
("deleted", "deleted_only"),
("skip_edited", "skip_original_if_edited"),
("export_as_hardlink", "convert_to_jpeg"),
("export_as_hardlink", "download_missing"),
("shared", "not_shared"),
("has_comment", "no_comment"),
("has_likes", "no_likes"),
]
dependent_options = [
("missing", ("download_missing", "use_photos_export")),
("jpeg_quality", ("convert_to_jpeg")),
]
try:
cfg.validate(
exclusive=exclusive_options,
dependent=dependent_options,
cli=True,
)
except ConfigOptionsInvalidError as e:
click.echo(f"Incompatible export options: {e.message}", err=True)
raise click.Abort()
if save_config:
verbose_(f"Saving options to file {save_config}")
cfg.write_to_file(save_config)
# set defaults for options that need them
jpeg_quality = DEFAULT_JPEG_QUALITY if jpeg_quality is None else jpeg_quality
edited_suffix = DEFAULT_EDITED_SUFFIX if edited_suffix is None else edited_suffix
original_suffix = (
DEFAULT_ORIGINAL_SUFFIX if original_suffix is None else original_suffix
)
# print(jpeg_quality, edited_suffix, original_suffix)
if not os.path.isdir(dest): if not os.path.isdir(dest):
click.echo(f"DEST {dest} must be valid path", err=True) click.echo(f"DEST {dest} must be valid path", err=True)
@@ -1554,51 +1748,6 @@ def export(
click.echo(f"report is a directory, must be file name", err=True) click.echo(f"report is a directory, must be file name", err=True)
raise click.Abort() raise click.Abort()
# sanity check input args
exclusive = [
(favorite, not_favorite),
(hidden, not_hidden),
(any(title), no_title),
(any(description), no_description),
(only_photos, only_movies),
(burst, not_burst),
(live, not_live),
(portrait, not_portrait),
(screenshot, not_screenshot),
(slow_mo, not_slow_mo),
(time_lapse, not_time_lapse),
(hdr, not_hdr),
(selfie, not_selfie),
(panorama, not_panorama),
(export_by_date, directory),
(export_as_hardlink, exiftool),
(any(place), no_place),
(deleted, deleted_only),
(skip_edited, skip_original_if_edited),
(export_as_hardlink, convert_to_jpeg),
(shared, not_shared),
(has_comment, no_comment),
(has_likes, no_likes),
]
if any(all(bb) for bb in exclusive):
click.echo("Incompatible export options", err=True)
click.echo(cli.commands["export"].get_help(ctx), err=True)
return
if export_as_hardlink and download_missing:
click.echo(
"Incompatible export options: --export-as-hardlink is not compatible with --download-missing",
err=True,
)
raise click.Abort()
if missing and not download_missing:
click.echo(
"Incompatible export options: --missing must be used with --download-missing",
err=True,
)
raise click.Abort()
# if use_photokit and not check_photokit_authorization(): # if use_photokit and not check_photokit_authorization():
# click.echo( # click.echo(
# "Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access." # "Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access."
@@ -1679,12 +1828,12 @@ def export(
if verbose_: if verbose_:
if export_db.was_created: if export_db.was_created:
verbose(f"Created export database {export_db_path}") verbose_(f"Created export database {export_db_path}")
else: else:
verbose(f"Using export database {export_db_path}") verbose_(f"Using export database {export_db_path}")
upgraded = export_db.was_upgraded upgraded = export_db.was_upgraded
if upgraded: if upgraded:
verbose( verbose_(
f"Upgraded export database {export_db_path} from version {upgraded[0]} to {upgraded[1]}" f"Upgraded export database {export_db_path} from version {upgraded[0]} to {upgraded[1]}"
) )
@@ -1780,12 +1929,12 @@ def export(
results_sidecar_xmp_skipped = [] results_sidecar_xmp_skipped = []
results_missing = [] results_missing = []
results_error = [] results_error = []
if verbose_: if verbose:
for p in photos: for p in photos:
results = export_photo( results = export_photo(
photo=p, photo=p,
dest=dest, dest=dest,
verbose_=verbose_, verbose=verbose,
export_by_date=export_by_date, export_by_date=export_by_date,
sidecar=sidecar, sidecar=sidecar,
update=update, update=update,
@@ -1834,7 +1983,7 @@ def export(
# for photo_file in set( # for photo_file in set(
# results.exported + results.updated + results.exif_updated # results.exported + results.updated + results.exif_updated
# ): # ):
# verbose(f"Converting {photo_file} to jpeg") # verbose_(f"Converting {photo_file} to jpeg")
else: else:
# show progress bar # show progress bar
@@ -1843,7 +1992,7 @@ def export(
results = export_photo( results = export_photo(
photo=p, photo=p,
dest=dest, dest=dest,
verbose_=verbose_, verbose=verbose,
export_by_date=export_by_date, export_by_date=export_by_date,
sidecar=sidecar, sidecar=sidecar,
update=update, update=update,
@@ -1927,7 +2076,7 @@ def export(
click.echo(f"Deleted: {cleaned_files} {file_str}, {cleaned_dirs} {dir_str}") click.echo(f"Deleted: {cleaned_files} {file_str}, {cleaned_dirs} {dir_str}")
if report: if report:
verbose(f"Writing export report to {report}") verbose_(f"Writing export report to {report}")
write_export_report( write_export_report(
report, report,
results_exported=results_exported, results_exported=results_exported,
@@ -2156,7 +2305,7 @@ def _query(
arguments must be passed in same order as query and export arguments must be passed in same order as query and export
if either is modified, need to ensure all three functions are updated""" if either is modified, need to ensure all three functions are updated"""
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose) photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
if deleted or deleted_only: if deleted or deleted_only:
photos = photosdb.photos( photos = photosdb.photos(
uuid=uuid, uuid=uuid,
@@ -2413,7 +2562,7 @@ def get_photos_by_attribute(photos, attribute, values, ignore_case):
def export_photo( def export_photo(
photo=None, photo=None,
dest=None, dest=None,
verbose_=None, verbose=None,
export_by_date=None, export_by_date=None,
sidecar=None, sidecar=None,
update=None, update=None,
@@ -2449,7 +2598,7 @@ def export_photo(
Args: Args:
photo: PhotoInfo object photo: PhotoInfo object
dest: destination path as string dest: destination path as string
verbose_: boolean; print verbose output verbose: boolean; print verbose output
export_by_date: boolean; create export folder in form dest/YYYY/MM/DD export_by_date: boolean; create export folder in form dest/YYYY/MM/DD
sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export
export_as_hardlink: boolean; hardlink files instead of copying them export_as_hardlink: boolean; hardlink files instead of copying them
@@ -2484,7 +2633,7 @@ def export_photo(
ValueError on invalid filename_template ValueError on invalid filename_template
""" """
global VERBOSE global VERBOSE
VERBOSE = bool(verbose_) VERBOSE = bool(verbose)
results_exported = [] results_exported = []
results_new = [] results_new = []
@@ -2514,7 +2663,7 @@ def export_photo(
# requested edited version but it's missing, download original # requested edited version but it's missing, download original
export_original = True export_original = True
export_edited = False export_edited = False
verbose( verbose_(
f"Edited file for {photo.original_filename} is missing, exporting original" f"Edited file for {photo.original_filename} is missing, exporting original"
) )
@@ -2553,7 +2702,7 @@ def export_photo(
else: else:
original_filename = filename original_filename = filename
verbose( verbose_(
f"Exporting {photo.original_filename} ({photo.filename}) as {original_filename}" f"Exporting {photo.original_filename} ({photo.filename}) as {original_filename}"
) )
@@ -2585,8 +2734,8 @@ def export_photo(
# original is missing, in which case we should download the edited version # original is missing, in which case we should download the edited version
if export_original: if export_original:
if missing_original: if missing_original:
space = " " if not verbose_ else "" space = " " if not verbose else ""
verbose(f"{space}Skipping missing photo {photo.original_filename}") verbose_(f"{space}Skipping missing photo {photo.original_filename}")
results_missing.append( results_missing.append(
str(pathlib.Path(dest_path) / original_filename) str(pathlib.Path(dest_path) / original_filename)
) )
@@ -2616,7 +2765,7 @@ def export_photo(
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit, use_photokit=use_photokit,
verbose=verbose, verbose=verbose_,
) )
results_exported.extend(export_results.exported) results_exported.extend(export_results.exported)
@@ -2648,7 +2797,7 @@ def export_photo(
str(pathlib.Path(dest) / original_filename) str(pathlib.Path(dest) / original_filename)
) )
else: else:
verbose(f"Skipping original version of {photo.original_filename}") verbose_(f"Skipping original version of {photo.original_filename}")
# if export-edited, also export the edited version # if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception # verify the photo has adjustments and valid path to avoid raising an exception
@@ -2665,12 +2814,12 @@ def export_photo(
# will be corrected by use_photos_export # will be corrected by use_photos_export
edited_ext = pathlib.Path(photo.filename).suffix edited_ext = pathlib.Path(photo.filename).suffix
edited_filename = f"{edited_filename.stem}{edited_suffix}{edited_ext}" edited_filename = f"{edited_filename.stem}{edited_suffix}{edited_ext}"
verbose( verbose_(
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}" f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
) )
if missing_edited: if missing_edited:
space = " " if not verbose_ else "" space = " " if not verbose else ""
verbose(f"{space}Skipping missing edited photo for {filename}") verbose_(f"{space}Skipping missing edited photo for {filename}")
results_missing.append( results_missing.append(
str(pathlib.Path(dest_path) / edited_filename) str(pathlib.Path(dest_path) / edited_filename)
) )
@@ -2699,7 +2848,7 @@ def export_photo(
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit, use_photokit=use_photokit,
verbose=verbose, verbose=verbose_,
) )
results_exported.extend(export_results_edited.exported) results_exported.extend(export_results_edited.exported)
@@ -2731,19 +2880,19 @@ def export_photo(
) )
results_error.extend(str(pathlib.Path(dest) / edited_filename)) results_error.extend(str(pathlib.Path(dest) / edited_filename))
if verbose_: if verbose:
if update: if update:
for new in results_new: for new in results_new:
verbose(f"Exported new file {new}") verbose_(f"Exported new file {new}")
for updated in results_updated: for updated in results_updated:
verbose(f"Exported updated file {updated}") verbose_(f"Exported updated file {updated}")
for skipped in results_skipped: for skipped in results_skipped:
verbose(f"Skipped up to date file {skipped}") verbose_(f"Skipped up to date file {skipped}")
else: else:
for exported in results_exported: for exported in results_exported:
verbose(f"Exported {exported}") verbose_(f"Exported {exported}")
for touched in results_touched: for touched in results_touched:
verbose(f"Touched date on file {touched}") verbose_(f"Touched date on file {touched}")
return ExportResults( return ExportResults(
exported=results_exported, exported=results_exported,
@@ -3048,7 +3197,7 @@ def cleanup_files(dest_path, files_to_keep, fileutil):
for p in pathlib.Path(dest_path).rglob("*"): for p in pathlib.Path(dest_path).rglob("*"):
path = str(p).lower() path = str(p).lower()
if p.is_file() and path not in keepers: if p.is_file() and path not in keepers:
verbose(f"Deleting {p}") verbose_(f"Deleting {p}")
fileutil.unlink(p) fileutil.unlink(p)
deleted_files += 1 deleted_files += 1
@@ -3058,7 +3207,7 @@ def cleanup_files(dest_path, files_to_keep, fileutil):
path = str(p).lower() path = str(p).lower()
# if directory and directory is empty # if directory and directory is empty
if p.is_dir() and not next(p.iterdir(), False): if p.is_dir() and not next(p.iterdir(), False):
verbose(f"Deleting empty directory {p}") verbose_(f"Deleting empty directory {p}")
fileutil.rmdir(p) fileutil.rmdir(p)
deleted_dirs += 1 deleted_dirs += 1

View File

@@ -109,3 +109,13 @@ MAX_FILENAME_LEN = 255
# Max directory name length on MacOS # Max directory name length on MacOS
MAX_DIRNAME_LEN = 255 MAX_DIRNAME_LEN = 255
# Default JPEG quality when converting to JPEG
DEFAULT_JPEG_QUALITY = 1.0
# Default suffix to add to edited images
DEFAULT_EDITED_SUFFIX = "_edited"
# Default suffix to add to original images
DEFAULT_ORIGINAL_SUFFIX = ""

View File

@@ -1,4 +1,5 @@
""" version info """ """ version info """
__version__ = "0.38.0" __version__ = "0.38.2"

173
osxphotos/configoptions.py Normal file
View File

@@ -0,0 +1,173 @@
""" ConfigOptions class to load/save config settings for osxphotos CLI """
import toml
class ConfigOptionsException(Exception):
""" Invalid combination of options. """
def __init__(self, message):
self.message = message
super().__init__(self.message)
class ConfigOptionsInvalidError(ConfigOptionsException):
pass
class ConfigOptionsLoadError(ConfigOptionsException):
pass
class ConfigOptions:
""" data class to store and load options for osxphotos commands """
def __init__(self, name, attrs, ignore=None):
""" init ConfigOptions class
Args:
name: name for these options, will be used for section heading in TOML file when saving/loading from file
attrs: dict with name and default value for all allowed attributes
ignore: optional list of strings of keys to ignore from attrs dict
"""
self._name = name
self._attrs = attrs.copy()
if ignore:
for attrname in ignore:
self._attrs.pop(attrname, None)
self.set_attributes(attrs)
def set_attributes(self, args):
for attr in self._attrs:
try:
arg = args[attr]
# don't test 'not arg'; need to handle empty strings as valid values
if arg is None or arg == False:
if type(self._attrs[attr]) == tuple:
setattr(self, attr, ())
else:
setattr(self, attr, self._attrs[attr])
else:
setattr(self, attr, arg)
except KeyError:
raise KeyError(f"Missing argument: {attr}")
def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False):
""" validate combinations of otions
Args:
exclusive: list of tuples in form [("option_1", "option_2")...] which are exclusive;
ie. either option_1 can be set or option_2 but not both;
inclusive: list of tuples in form [("option_1", "option_2")...] which are inclusive;
ie. if either option_1 or option_2 is set, the other must be set
dependent: list of tuples in form [("option_1", ("option_2", "option_3"))...]
where if option_1 is set, then at least one of the options in the second tuple must also be set
cli: bool, set to True if called to validate CLI options;
will prepend '--' to option names in InvalidOptions.message and change _ to - in option names
Returns:
True if all options valid
Raises:
InvalidOption if any combination of options is invalid
InvalidOption.message will be descriptive message of invalid options
"""
if not any([exclusive, inclusive, dependent]):
return True
prefix = "--" if cli else ""
if exclusive:
for a, b in exclusive:
vala = getattr(self, a)
valb = getattr(self, b)
vala = any(vala) if isinstance(vala, tuple) else vala
valb = any(valb) if isinstance(valb, tuple) else valb
if vala and valb:
stra = a.replace("_", "-") if cli else a
strb = b.replace("_", "-") if cli else b
raise ConfigOptionsInvalidError(
f"{prefix}{stra} and {prefix}{strb} options cannot be used together."
)
if inclusive:
for a, b in inclusive:
vala = getattr(self, a)
valb = getattr(self, b)
vala = any(vala) if isinstance(vala, tuple) else vala
valb = any(valb) if isinstance(valb, tuple) else valb
if any([vala, valb]) and not all([vala, valb]):
stra = a.replace("_", "-") if cli else a
strb = b.replace("_", "-") if cli else b
raise ConfigOptionsInvalidError(
f"{prefix}{stra} and {prefix}{strb} options must be used together."
)
if dependent:
for a, b in dependent:
vala = getattr(self, a)
if not isinstance(b, tuple):
# python unrolls the tuple if there's a single element
b = (b,)
valb = [getattr(self, x) for x in b]
valb = [any(x) if isinstance(x, tuple) else x for x in valb]
if vala and not any(valb):
if cli:
stra = prefix + a.replace("_", "-")
strb = ", ".join(prefix + x.replace("_", "-") for x in b)
else:
stra = a
strb = ", ".join(b)
raise ConfigOptionsInvalidError(
f"{stra} must be used with at least one of: {strb}."
)
return True
def write_to_file(self, filename):
""" Write self to TOML file
Args:
filename: full path to TOML file to write; filename will be overwritten if it exists
"""
# todo: add overwrite and option to merge contents already in TOML file (under different [section] with new content)
data = {}
for attr in sorted(self._attrs.keys()):
val = getattr(self, attr)
if val in [False, ()]:
val = None
else:
val = list(val) if type(val) == tuple else val
data[attr] = val
with open(filename, "w") as fd:
toml.dump({self._name: data}, fd)
def load_from_file(self, filename, override=False):
""" Load options from a TOML file.
Args:
filename: full path to TOML file
override: bool; if True, values in the TOML file will override values already set in the instance
Raises:
ConfigOptionsLoadError if there are any errors during the parsing of the TOML file
"""
loaded = toml.load(filename)
name = self._name
if name not in loaded:
raise ConfigOptionsLoadError(f"[{name}] section missing from {filename}")
for attr in loaded[name]:
if attr not in self._attrs:
raise ConfigOptionsLoadError(
f"Unknown option: {attr} = {loaded[name][attr]}"
)
val = loaded[name][attr]
if not override:
# use value from self if set
val = getattr(self, attr) or val
if type(self._attrs[attr]) == tuple:
val = tuple(val)
setattr(self, attr, val)
return self
def asdict(self):
return {attr: getattr(self, attr) for attr in sorted(self._attrs.keys())}

View File

@@ -80,6 +80,7 @@ setup(
"dataclasses==0.7;python_version<'3.7'", "dataclasses==0.7;python_version<'3.7'",
"wurlitzer>=2.0.1", "wurlitzer>=2.0.1",
"photoscript>=0.1.0", "photoscript>=0.1.0",
"toml>=0.10.0",
], ],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]}, entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True, include_package_data=True,

View File

@@ -875,7 +875,7 @@ def test_export_using_hardlinks_incompat_options():
"-V", "-V",
], ],
) )
assert result.exit_code == 0 assert result.exit_code == 1
assert "Incompatible export options" in result.output assert "Incompatible export options" in result.output
@@ -3961,3 +3961,103 @@ def test_export_cleanup():
assert not pathlib.Path("./delete_me.txt").is_file() assert not pathlib.Path("./delete_me.txt").is_file()
assert not pathlib.Path("./foo/delete_me_too.txt").is_file() assert not pathlib.Path("./foo/delete_me_too.txt").is_file()
def test_save_load_config():
""" test --save-config, --load-config """
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():
# test save config file
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--sidecar",
"XMP",
"--touch-file",
"--update",
"--save-config",
"config.toml",
],
)
assert result.exit_code == 0
assert "Saving options to file" in result.output
files = glob.glob("*")
assert "config.toml" in files
# test load config file
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--load-config",
"config.toml",
],
)
assert result.exit_code == 0
assert "Loaded options from file" in result.output
assert "Skipped up to date XMP sidecar" in result.output
# test overwrite existing config file
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--sidecar",
"XMP",
"--touch-file",
"--not-live",
"--update",
"--save-config",
"config.toml",
],
)
assert result.exit_code == 0
assert "Saving options to file" in result.output
files = glob.glob("*")
assert "config.toml" in files
# test load config file with incompat command line option
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--load-config",
"config.toml",
"--live",
],
)
assert result.exit_code != 0
assert "Incompatible export options" in result.output
# test load config file with command line override
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--load-config",
"config.toml",
"--sidecar",
"json",
],
)
assert result.exit_code == 0
assert "Writing exiftool JSON sidecar" in result.output
assert "Writing XMP sidecar" not in result.output

View File

@@ -0,0 +1,86 @@
""" test ConfigOptions class """
import pathlib
import pytest
import toml
from osxphotos.configoptions import (
ConfigOptions,
ConfigOptionsInvalidError,
ConfigOptionsLoadError,
)
VARS = {"foo": "bar", "bar": False, "test1": (), "test2": None, "test2_setting": False}
def test_init():
cfg = ConfigOptions("test", VARS)
assert isinstance(cfg, ConfigOptions)
assert cfg.foo is "bar"
assert cfg.bar == False
assert type(cfg.test1) == tuple
def test_init_with_ignore():
cfg = ConfigOptions("test", VARS, ignore=["test2"])
assert isinstance(cfg, ConfigOptions)
assert hasattr(cfg, "test1")
assert not hasattr(cfg, "test2")
def test_write_to_file_load_from_file(tmpdir):
cfg = ConfigOptions("test", VARS)
cfg.bar = True
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
cfg.write_to_file(str(cfg_file))
assert cfg_file.is_file()
cfg_dict = toml.load(str(cfg_file))
assert cfg_dict["test"]["foo"] == "bar"
cfg2 = ConfigOptions("test", VARS).load_from_file(str(cfg_file))
assert cfg2.foo == "bar"
assert cfg2.bar
def test_load_from_file_error(tmpdir):
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
cfg = ConfigOptions("test", VARS)
cfg.write_to_file(str(cfg_file))
# try to load with a section that doesn't exist in the TOML file
with pytest.raises(ConfigOptionsLoadError):
cfg2 = ConfigOptions("FOO", VARS).load_from_file(str(cfg_file))
def test_asdict():
cfg = ConfigOptions("test", VARS)
cfg_dict = cfg.asdict()
assert cfg_dict["foo"] == "bar"
assert cfg_dict["bar"] == False
assert cfg_dict["test1"] == ()
def test_validate():
cfg = ConfigOptions("test", VARS)
# test exclusive
assert cfg.validate(exclusive=[("foo", "bar")])
cfg.bar = True
with pytest.raises(ConfigOptionsInvalidError):
assert cfg.validate(exclusive=[("foo", "bar")])
# test dependent
cfg.test2 = True
cfg.test2_setting = 1.0
assert cfg.validate(dependent=[("test2_setting", ("test2"))])
cfg.test2 = False
with pytest.raises(ConfigOptionsInvalidError):
assert cfg.validate(dependent=[("test2_setting", ("test2"))])
# test inclusive
cfg.foo = "foo"
cfg.bar = True
assert cfg.validate(inclusive=[("foo", "bar")])
cfg.foo = None
with pytest.raises(ConfigOptionsInvalidError):
assert cfg.validate(inclusive=[("foo", "bar")])