Refactoring of save-config/load-config code

This commit is contained in:
Rhet Turnbull
2020-12-08 08:32:37 -08:00
parent 22355fd446
commit 37b1e5ca47
3 changed files with 112 additions and 316 deletions

View File

@@ -25,7 +25,11 @@ from ._constants import (
UNICODE_FORMAT, UNICODE_FORMAT,
) )
from ._version import __version__ from ._version import __version__
from .configoptions import ExportOptions, InvalidOptions 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
@@ -1572,12 +1576,15 @@ def export(
See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options
to modify this behavior. to modify this behavior.
""" """
# NOTE: because of the way ExportOptions works, Click options must not # 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 # 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. # do so below after load_config and save_config are handled.
cfg = ConfigOptions(
cfg = ExportOptions(**locals()) "export",
locals(),
ignore=["ctx", "cli_obj", "dest", "load_config", "save_config"],
)
# print(jpeg_quality, edited_suffix, original_suffix) # print(jpeg_quality, edited_suffix, original_suffix)
@@ -1585,11 +1592,14 @@ def export(
VERBOSE = bool(verbose) VERBOSE = bool(verbose)
if load_config: if load_config:
cfg, error = ExportOptions().load_from_file(load_config, cfg) try:
# print(cfg.asdict()) cfg.load_from_file(load_config)
if error: except ConfigOptionsLoadError as e:
click.echo(f"Error parsing {load_config} config file: {error}", err=True) click.echo(
f"Error parsing {load_config} config file: {e.message}", err=True
)
raise click.Abort() raise click.Abort()
# re-set the local function vars to the corresponding config value # 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 # this isn't elegant but avoids having to rewrite this function to use cfg.varname for every parameter
db = cfg.db db = cfg.db
@@ -1679,15 +1689,48 @@ def export(
use_photokit = cfg.use_photokit use_photokit = cfg.use_photokit
report = cfg.report report = cfg.report
cleanup = cfg.cleanup cleanup = cfg.cleanup
# config file might have changed verbose # config file might have changed verbose
VERBOSE = bool(verbose) VERBOSE = bool(verbose)
verbose_(f"Loaded options from file {load_config}") 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"),
]
try: try:
cfg.validate(cli=True) cfg.validate(exclusive_options, cli=True)
except InvalidOptions as e: except ConfigOptionsInvalidError as e:
click.echo(f"{e.message}") click.echo(f"Incompatible export options: {e.message}", 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() raise click.Abort()
if save_config: if save_config:
@@ -1697,7 +1740,9 @@ def export(
# set defaults for options that need them # set defaults for options that need them
jpeg_quality = DEFAULT_JPEG_QUALITY if jpeg_quality is None else jpeg_quality 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 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 original_suffix = (
DEFAULT_ORIGINAL_SUFFIX if original_suffix is None else original_suffix
)
# print(jpeg_quality, edited_suffix, original_suffix) # print(jpeg_quality, edited_suffix, original_suffix)
@@ -1711,52 +1756,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),
(export_as_hardlink, cleanup),
]
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."

View File

@@ -1,8 +1,8 @@
""" Classes to load/save config settings for osxphotos CLI """ """ ConfigOptions class to load/save config settings for osxphotos CLI """
import toml import toml
class InvalidOptions(Exception): class ConfigOptionsException(Exception):
""" Invalid combination of options. """ """ Invalid combination of options. """
def __init__(self, message): def __init__(self, message):
@@ -10,16 +10,31 @@ class InvalidOptions(Exception):
super().__init__(self.message) super().__init__(self.message)
class OSXPhotosOptions: class ConfigOptionsInvalidError(ConfigOptionsException):
pass
class ConfigOptionsLoadError(ConfigOptionsException):
pass
class ConfigOptions:
""" data class to store and load options for osxphotos commands """ """ data class to store and load options for osxphotos commands """
def __init__(self, **kwargs): def __init__(self, name, attrs, ignore=None):
args = locals() """ init ConfigOptions class
self._attrs = {} Args:
self._exclusive = [] 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
"""
self._name = name
self._attrs = attrs.copy()
if ignore:
for attrname in ignore:
self._attrs.pop(attrname, None)
self.set_attributes(args) self.set_attributes(attrs)
def set_attributes(self, args): def set_attributes(self, args):
for attr in self._attrs: for attr in self._attrs:
@@ -27,7 +42,7 @@ class OSXPhotosOptions:
arg = args[attr] arg = args[attr]
# don't test 'not arg'; need to handle empty strings as valid values # don't test 'not arg'; need to handle empty strings as valid values
if arg is None or arg == False: if arg is None or arg == False:
if self._attrs[attr] == (): if type(self._attrs[attr]) == tuple:
setattr(self, attr, ()) setattr(self, attr, ())
else: else:
setattr(self, attr, self._attrs[attr]) setattr(self, attr, self._attrs[attr])
@@ -36,7 +51,7 @@ class OSXPhotosOptions:
except KeyError: except KeyError:
raise KeyError(f"Missing argument: {attr}") raise KeyError(f"Missing argument: {attr}")
def validate(self, cli=False): def validate(self, exclusive, cli=False):
""" validate combinations of otions """ validate combinations of otions
Args: Args:
@@ -49,14 +64,17 @@ class OSXPhotosOptions:
InvalidOption if any combination of options is invalid InvalidOption if any combination of options is invalid
InvalidOption.message will be descriptive message of invalid options InvalidOption.message will be descriptive message of invalid options
""" """
if not exclusive:
return True
prefix = "--" if cli else "" prefix = "--" if cli else ""
for opt_pair in self._exclusive: for opt_pair in exclusive:
val0 = getattr(self, opt_pair[0]) val0 = getattr(self, opt_pair[0])
val1 = getattr(self, opt_pair[1]) val1 = getattr(self, opt_pair[1])
val0 = any(val0) if self._attrs[opt_pair[0]] == () else val0 val0 = any(val0) if self._attrs[opt_pair[0]] == () else val0
val1 = any(val1) if self._attrs[opt_pair[1]] == () else val1 val1 = any(val1) if self._attrs[opt_pair[1]] == () else val1
if val0 and val1: if val0 and val1:
raise InvalidOptions( raise ConfigOptionsInvalidError(
f"{prefix}{opt_pair[0]} and {prefix}{opt_pair[1]} options cannot be used together" f"{prefix}{opt_pair[0]} and {prefix}{opt_pair[1]} options cannot be used together"
) )
return True return True
@@ -78,234 +96,36 @@ class OSXPhotosOptions:
data[attr] = val data[attr] = val
with open(filename, "w") as fd: with open(filename, "w") as fd:
toml.dump({"export": data}, fd) toml.dump({self._name: data}, fd)
def load_from_file(self, filename, override=None): def load_from_file(self, filename, override=False):
""" Load options from a TOML file. """ Load options from a TOML file.
Args: Args:
filename: full path to TOML file filename: full path to TOML file
override: optional ExportOptions object; override: bool; if True, values in the TOML file will override values already set in the instance
if provided, any value that's set in override will be used
to override what's in the TOML file
Returns:
(ExportOptions, error): tuple of ExportOption object and error string;
if there are any errors during the parsing of the TOML file, error will be set
to a descriptive error message otherwise it will be None
"""
override = override or ExportOptions()
loaded = toml.load(filename)
options = ExportOptions()
if "export" not in loaded:
return options, f"[export] section missing from {filename}"
for attr in loaded["export"]: 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: if attr not in self._attrs:
return options, f"Unknown option: {attr}: {loaded['export'][attr]}" raise ConfigOptionsLoadError(
val = loaded["export"][attr] f"Unknown option: {attr} = {loaded[name][attr]}"
val = getattr(override, attr) or val )
if self._attrs[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) val = tuple(val)
setattr(options, attr, val) setattr(self, attr, val)
return options, None return self, None
def asdict(self): def asdict(self):
return {attr: getattr(self, attr) for attr in sorted(self._attrs.keys())} return {attr: getattr(self, attr) for attr in sorted(self._attrs.keys())}
class ExportOptions(OSXPhotosOptions):
""" data class to store and load options for export command """
def __init__(
self,
db=None,
photos_library=None,
keyword=None,
person=None,
album=None,
folder=None,
uuid=None,
uuid_from_file=None,
title=None,
no_title=False,
description=None,
no_description=False,
uti=None,
ignore_case=False,
edited=False,
external_edit=False,
favorite=False,
not_favorite=False,
hidden=False,
not_hidden=False,
shared=False,
not_shared=False,
from_date=None,
to_date=None,
verbose=False,
missing=False,
update=True,
dry_run=False,
export_as_hardlink=False,
touch_file=False,
overwrite=False,
export_by_date=False,
skip_edited=False,
skip_original_if_edited=False,
skip_bursts=False,
skip_live=False,
skip_raw=False,
person_keyword=False,
album_keyword=False,
keyword_template=None,
description_template=None,
current_name=False,
convert_to_jpeg=False,
jpeg_quality=None,
sidecar=None,
only_photos=False,
only_movies=False,
burst=False,
not_burst=False,
live=False,
not_live=False,
download_missing=False,
exiftool=False,
ignore_date_modified=False,
portrait=False,
not_portrait=False,
screenshot=False,
not_screenshot=False,
slow_mo=False,
not_slow_mo=False,
time_lapse=False,
not_time_lapse=False,
hdr=False,
not_hdr=False,
selfie=False,
not_selfie=False,
panorama=False,
not_panorama=False,
has_raw=False,
directory=None,
filename_template=None,
edited_suffix=None,
original_suffix=None,
place=None,
no_place=False,
has_comment=False,
no_comment=False,
has_likes=False,
no_likes=False,
no_extended_attributes=False,
label=None,
deleted=False,
deleted_only=False,
use_photos_export=False,
use_photokit=False,
report=None,
cleanup=False,
**kwargs,
):
args = locals()
# valid attributes and default values
self._attrs = {
"db": None,
"photos_library": (),
"keyword": (),
"person": (),
"album": (),
"folder": (),
"uuid": (),
"uuid_from_file": None,
"title": (),
"no_title": False,
"description": (),
"no_description": False,
"uti": None,
"ignore_case": False,
"edited": False,
"external_edit": False,
"favorite": False,
"not_favorite": False,
"hidden": False,
"not_hidden": False,
"shared": False,
"not_shared": False,
"from_date": None,
"to_date": None,
"verbose": False,
"missing": False,
"update": False,
"dry_run": False,
"export_as_hardlink": False,
"touch_file": False,
"overwrite": False,
"export_by_date": False,
"skip_edited": False,
"skip_original_if_edited": False,
"skip_bursts": False,
"skip_live": False,
"skip_raw": False,
"person_keyword": False,
"album_keyword": False,
"keyword_template": (),
"description_template": None,
"current_name": False,
"convert_to_jpeg": False,
"jpeg_quality": None,
"sidecar": (),
"only_photos": False,
"only_movies": False,
"burst": False,
"not_burst": False,
"live": False,
"not_live": False,
"download_missing": False,
"exiftool": False,
"ignore_date_modified": False,
"portrait": False,
"not_portrait": False,
"screenshot": False,
"not_screenshot": False,
"slow_mo": False,
"not_slow_mo": False,
"time_lapse": False,
"not_time_lapse": False,
"hdr": False,
"not_hdr": False,
"selfie": False,
"not_selfie": False,
"panorama": False,
"not_panorama": False,
"has_raw": False,
"directory": None,
"filename_template": None,
"edited_suffix": None,
"original_suffix": None,
"place": (),
"no_place": False,
"has_comment": False,
"no_comment": False,
"has_likes": False,
"no_likes": False,
"no_extended_attributes": False,
"label": (),
"deleted": False,
"deleted_only": False,
"use_photos_export": False,
"use_photokit": False,
"report": None,
"cleanup": False,
}
self._exclusive = [
["favorite", "not_favorite"],
["hidden", "not_hidden"],
["title", "no_title"],
["description", "no_description"],
]
self.set_attributes(args)

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,26 +3961,3 @@ 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_export_cleanup_export_as_hardling():
""" test export with incompatible option """
import os
import os.path
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",
"--export-as-hardlink",
"--cleanup",
],
)
assert "Incompatible export options" in result.output