Merge pull request #290 from RhetTbull/save_config
Added --save-config, --load-config
This commit is contained in:
16
README.md
16
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.38.0"
|
__version__ = "0.38.2"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
173
osxphotos/configoptions.py
Normal file
173
osxphotos/configoptions.py
Normal 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())}
|
||||||
1
setup.py
1
setup.py
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
86
tests/test_configoptions.py
Normal file
86
tests/test_configoptions.py
Normal 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")])
|
||||||
Reference in New Issue
Block a user