From 22355fd44609f42e412c580dfc9e5e0b7cf6c464 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Tue, 8 Dec 2020 07:22:40 -0800 Subject: [PATCH 1/8] Initial implementation of configoptions for --save-config, --load-config --- osxphotos/__main__.py | 242 +++++++++++++++++++++++------ osxphotos/_constants.py | 10 ++ osxphotos/_version.py | 2 +- osxphotos/configoptions.py | 311 +++++++++++++++++++++++++++++++++++++ 4 files changed, 517 insertions(+), 48 deletions(-) create mode 100644 osxphotos/configoptions.py diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index c48800d8..7bc74941 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -19,9 +19,13 @@ from ._constants import ( _EXIF_TOOL_URL, _PHOTOS_4_VERSION, _UNKNOWN_PLACE, + DEFAULT_JPEG_QUALITY, + DEFAULT_EDITED_SUFFIX, + DEFAULT_ORIGINAL_SUFFIX, UNICODE_FORMAT, ) from ._version import __version__ +from .configoptions import ExportOptions, InvalidOptions from .datetime_formatter import DateTimeFormatter from .exiftool import get_exiftool_path from .export_db import ExportDB, ExportDBInMemory @@ -39,7 +43,7 @@ VERBOSE = False OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db" -def verbose(*args, **kwargs): +def verbose_(*args, **kwargs): """ print output if verbose flag set """ if VERBOSE: click.echo(*args, **kwargs) @@ -587,14 +591,14 @@ def cli(ctx, db, json_, debug): help="Use with '--dump photos' to dump only certain UUIDs", 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_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 """ global VERBOSE - VERBOSE = bool(verbose_) + VERBOSE = bool(verbose) db = get_photos_db(*photos_library, db, cli_obj.db) if db is None: @@ -605,7 +609,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose_): start_t = time.perf_counter() print(f"Opening database: {db}") - photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose) + photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_) stop_t = time.perf_counter() print(f"Done; took {(stop_t-start_t):.2f} seconds") @@ -1197,7 +1201,7 @@ def query( @cli.command(cls=ExportCommand) @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 @click.option( "--missing", @@ -1283,11 +1287,10 @@ def query( @click.option( "--jpeg-quality", 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. " "A value of 1.0 specifies best quality, " "a value of 0.0 specifies maximum compression. " - "Defaults to 1.0.", + f"Defaults to {DEFAULT_JPEG_QUALITY}", ) @click.option( "--download-missing", @@ -1395,15 +1398,13 @@ def query( @click.option( "--edited-suffix", metavar="SUFFIX", - default="_edited", help="Optional suffix for naming edited photos. Default name for edited photos is in form " "'photoname_edited.ext'. For example, with '--edited-suffix _bearbeiten', the edited photo " - "would be named 'photoname_bearbeiten.ext'. The default suffix is '_edited'.", + f"would be named 'photoname_bearbeiten.ext'. The default suffix is '{DEFAULT_EDITED_SUFFIX}'.", ) @click.option( "--original-suffix", metavar="SUFFIX", - default="", 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 " "would be named 'filename_original.ext'. The default suffix is '' (no suffix).", @@ -1411,7 +1412,6 @@ def query( @click.option( "--no-extended-attributes", is_flag=True, - default=False, help="Don't copy extended attributes when exporting. You only need this if exporting " "to a filesystem that doesn't support Mac OS extended attributes. Only use this if you get " "an error while exporting.", @@ -1419,20 +1419,18 @@ def query( @click.option( "--use-photos-export", 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').", ) @click.option( "--use-photokit", is_flag=True, - default=False, 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). " "This is faster and more reliable than the default AppleScript interface.", ) @click.option( "--report", - metavar="REPORTNAME.CSV", + metavar="", help="Write a CSV formatted report of all files that were exported.", type=click.Path(), ) @@ -1442,6 +1440,29 @@ def query( 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.", ) +@click.option( + "--load-config", + required=False, + metavar="", + 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 --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="", + default=None, + help=("Save options to file for use with --load-config. File format is TOML."), + type=click.Path(), +) @DB_ARGUMENT @click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.pass_obj @@ -1473,7 +1494,7 @@ def export( not_shared, from_date, to_date, - verbose_, + verbose, missing, update, dry_run, @@ -1537,6 +1558,8 @@ def export( use_photokit, report, cleanup, + load_config, + save_config, ): """Export photos from the Photos database. Export path DEST is required. @@ -1549,9 +1572,134 @@ def export( See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options to modify this behavior. """ + + # NOTE: because of the way ExportOptions 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 = ExportOptions(**locals()) + + # print(jpeg_quality, edited_suffix, original_suffix) global VERBOSE - VERBOSE = bool(verbose_) + VERBOSE = bool(verbose) + + if load_config: + cfg, error = ExportOptions().load_from_file(load_config, cfg) + # print(cfg.asdict()) + if error: + click.echo(f"Error parsing {load_config} config file: {error}", 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 + no_extended_attributes = cfg.no_extended_attributes + 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}") + + try: + cfg.validate(cli=True) + except InvalidOptions as e: + click.echo(f"{e.message}") + 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): click.echo(f"DEST {dest} must be valid path", err=True) @@ -1689,12 +1837,12 @@ def export( if verbose_: if export_db.was_created: - verbose(f"Created export database {export_db_path}") + verbose_(f"Created export database {export_db_path}") else: - verbose(f"Using export database {export_db_path}") + verbose_(f"Using export database {export_db_path}") upgraded = export_db.was_upgraded if upgraded: - verbose( + verbose_( f"Upgraded export database {export_db_path} from version {upgraded[0]} to {upgraded[1]}" ) @@ -1790,12 +1938,12 @@ def export( results_sidecar_xmp_skipped = [] results_missing = [] results_error = [] - if verbose_: + if verbose: for p in photos: results = export_photo( photo=p, dest=dest, - verbose_=verbose_, + verbose=verbose, export_by_date=export_by_date, sidecar=sidecar, update=update, @@ -1845,7 +1993,7 @@ def export( # for photo_file in set( # results.exported + results.updated + results.exif_updated # ): - # verbose(f"Converting {photo_file} to jpeg") + # verbose_(f"Converting {photo_file} to jpeg") else: # show progress bar @@ -1854,7 +2002,7 @@ def export( results = export_photo( photo=p, dest=dest, - verbose_=verbose_, + verbose=verbose, export_by_date=export_by_date, sidecar=sidecar, update=update, @@ -1939,7 +2087,7 @@ def export( click.echo(f"Deleted: {cleaned_files} {file_str}, {cleaned_dirs} {dir_str}") if report: - verbose(f"Writing export report to {report}") + verbose_(f"Writing export report to {report}") write_export_report( report, results_exported=results_exported, @@ -2168,7 +2316,7 @@ def _query( arguments must be passed in same order as query and export 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: photos = photosdb.photos( uuid=uuid, @@ -2425,7 +2573,7 @@ def get_photos_by_attribute(photos, attribute, values, ignore_case): def export_photo( photo=None, dest=None, - verbose_=None, + verbose=None, export_by_date=None, sidecar=None, update=None, @@ -2462,7 +2610,7 @@ def export_photo( Args: photo: PhotoInfo object 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 sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export export_as_hardlink: boolean; hardlink files instead of copying them @@ -2498,7 +2646,7 @@ def export_photo( ValueError on invalid filename_template """ global VERBOSE - VERBOSE = bool(verbose_) + VERBOSE = bool(verbose) results_exported = [] results_new = [] @@ -2528,7 +2676,7 @@ def export_photo( # requested edited version but it's missing, download original export_original = True export_edited = False - verbose( + verbose_( f"Edited file for {photo.original_filename} is missing, exporting original" ) @@ -2567,7 +2715,7 @@ def export_photo( else: original_filename = filename - verbose( + verbose_( f"Exporting {photo.original_filename} ({photo.filename}) as {original_filename}" ) @@ -2599,8 +2747,8 @@ def export_photo( # original is missing, in which case we should download the edited version if export_original: if missing_original: - space = " " if not verbose_ else "" - verbose(f"{space}Skipping missing photo {photo.original_filename}") + space = " " if not verbose else "" + verbose_(f"{space}Skipping missing photo {photo.original_filename}") results_missing.append( str(pathlib.Path(dest_path) / original_filename) ) @@ -2631,7 +2779,7 @@ def export_photo( jpeg_quality=jpeg_quality, ignore_date_modified=ignore_date_modified, use_photokit=use_photokit, - verbose=verbose, + verbose=verbose_, ) results_exported.extend(export_results.exported) @@ -2663,7 +2811,7 @@ def export_photo( str(pathlib.Path(dest) / original_filename) ) 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 # verify the photo has adjustments and valid path to avoid raising an exception @@ -2680,12 +2828,12 @@ def export_photo( # will be corrected by use_photos_export edited_ext = pathlib.Path(photo.filename).suffix 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}" ) if missing_edited: - space = " " if not verbose_ else "" - verbose(f"{space}Skipping missing edited photo for {filename}") + space = " " if not verbose else "" + verbose_(f"{space}Skipping missing edited photo for {filename}") results_missing.append( str(pathlib.Path(dest_path) / edited_filename) ) @@ -2715,7 +2863,7 @@ def export_photo( jpeg_quality=jpeg_quality, ignore_date_modified=ignore_date_modified, use_photokit=use_photokit, - verbose=verbose, + verbose=verbose_, ) results_exported.extend(export_results_edited.exported) @@ -2747,19 +2895,19 @@ def export_photo( ) results_error.extend(str(pathlib.Path(dest) / edited_filename)) - if verbose_: + if verbose: if update: for new in results_new: - verbose(f"Exported new file {new}") + verbose_(f"Exported new file {new}") for updated in results_updated: - verbose(f"Exported updated file {updated}") + verbose_(f"Exported updated file {updated}") for skipped in results_skipped: - verbose(f"Skipped up to date file {skipped}") + verbose_(f"Skipped up to date file {skipped}") else: for exported in results_exported: - verbose(f"Exported {exported}") + verbose_(f"Exported {exported}") for touched in results_touched: - verbose(f"Touched date on file {touched}") + verbose_(f"Touched date on file {touched}") return ExportResults( exported=results_exported, @@ -3064,7 +3212,7 @@ def cleanup_files(dest_path, files_to_keep, fileutil): for p in pathlib.Path(dest_path).rglob("*"): path = str(p).lower() if p.is_file() and path not in keepers: - verbose(f"Deleting {p}") + verbose_(f"Deleting {p}") fileutil.unlink(p) deleted_files += 1 @@ -3074,7 +3222,7 @@ def cleanup_files(dest_path, files_to_keep, fileutil): path = str(p).lower() # if directory and directory is empty 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) deleted_dirs += 1 diff --git a/osxphotos/_constants.py b/osxphotos/_constants.py index 56587836..1efa667b 100644 --- a/osxphotos/_constants.py +++ b/osxphotos/_constants.py @@ -109,3 +109,13 @@ MAX_FILENAME_LEN = 255 # Max directory name length on MacOS 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 = "" + + diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 8c4789cc..df8be1a5 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,4 +1,4 @@ """ version info """ -__version__ = "0.37.6" +__version__ = "0.37.8" diff --git a/osxphotos/configoptions.py b/osxphotos/configoptions.py new file mode 100644 index 00000000..611d4213 --- /dev/null +++ b/osxphotos/configoptions.py @@ -0,0 +1,311 @@ +""" Classes to load/save config settings for osxphotos CLI """ +import toml + + +class InvalidOptions(Exception): + """ Invalid combination of options. """ + + def __init__(self, message): + self.message = message + super().__init__(self.message) + + +class OSXPhotosOptions: + """ data class to store and load options for osxphotos commands """ + + def __init__(self, **kwargs): + args = locals() + + self._attrs = {} + self._exclusive = [] + + self.set_attributes(args) + + 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 self._attrs[attr] == (): + 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, cli=False): + """ validate combinations of otions + + Args: + cli: bool, set to True if called to validate CLI options; will prepend '--' to option names in InvalidOptions.message + + Returns: + True if all options valid + + Raises: + InvalidOption if any combination of options is invalid + InvalidOption.message will be descriptive message of invalid options + """ + prefix = "--" if cli else "" + for opt_pair in self._exclusive: + val0 = getattr(self, opt_pair[0]) + val1 = getattr(self, opt_pair[1]) + val0 = any(val0) if self._attrs[opt_pair[0]] == () else val0 + val1 = any(val1) if self._attrs[opt_pair[1]] == () else val1 + if val0 and val1: + raise InvalidOptions( + f"{prefix}{opt_pair[0]} and {prefix}{opt_pair[1]} options cannot be used together" + ) + 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 + """ + 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({"export": data}, fd) + + def load_from_file(self, filename, override=None): + """ Load options from a TOML file. + + Args: + filename: full path to TOML file + override: optional ExportOptions object; + 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"]: + if attr not in self._attrs: + return options, f"Unknown option: {attr}: {loaded['export'][attr]}" + val = loaded["export"][attr] + val = getattr(override, attr) or val + if self._attrs[attr] == (): + val = tuple(val) + setattr(options, attr, val) + return options, None + + def asdict(self): + 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) From 37b1e5ca472e9679301fa96d2b7fdd8c4ad438b2 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Tue, 8 Dec 2020 08:32:37 -0800 Subject: [PATCH 2/8] Refactoring of save-config/load-config code --- osxphotos/__main__.py | 119 ++++++++-------- osxphotos/configoptions.py | 284 +++++++------------------------------ tests/test_cli.py | 25 +--- 3 files changed, 112 insertions(+), 316 deletions(-) diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 7bc74941..44198921 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -25,7 +25,11 @@ from ._constants import ( UNICODE_FORMAT, ) from ._version import __version__ -from .configoptions import ExportOptions, InvalidOptions +from .configoptions import ( + ConfigOptions, + ConfigOptionsInvalidError, + ConfigOptionsLoadError, +) from .datetime_formatter import DateTimeFormatter from .exiftool import get_exiftool_path from .export_db import ExportDB, ExportDBInMemory @@ -1572,12 +1576,15 @@ def export( See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options 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 # do so below after load_config and save_config are handled. - - cfg = ExportOptions(**locals()) + cfg = ConfigOptions( + "export", + locals(), + ignore=["ctx", "cli_obj", "dest", "load_config", "save_config"], + ) # print(jpeg_quality, edited_suffix, original_suffix) @@ -1585,11 +1592,14 @@ def export( VERBOSE = bool(verbose) if load_config: - cfg, error = ExportOptions().load_from_file(load_config, cfg) - # print(cfg.asdict()) - if error: - click.echo(f"Error parsing {load_config} config file: {error}", err=True) + 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 @@ -1679,15 +1689,48 @@ def 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"), + ] try: - cfg.validate(cli=True) - except InvalidOptions as e: - click.echo(f"{e.message}") + cfg.validate(exclusive_options, cli=True) + except ConfigOptionsInvalidError as e: + 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() if save_config: @@ -1697,7 +1740,9 @@ def export( # 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 + original_suffix = ( + DEFAULT_ORIGINAL_SUFFIX if original_suffix is None else 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) 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(): # click.echo( # "Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access." diff --git a/osxphotos/configoptions.py b/osxphotos/configoptions.py index 611d4213..3afb4ecd 100644 --- a/osxphotos/configoptions.py +++ b/osxphotos/configoptions.py @@ -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 -class InvalidOptions(Exception): +class ConfigOptionsException(Exception): """ Invalid combination of options. """ def __init__(self, message): @@ -10,16 +10,31 @@ class InvalidOptions(Exception): 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 """ - def __init__(self, **kwargs): - args = locals() + def __init__(self, name, attrs, ignore=None): + """ init ConfigOptions class - self._attrs = {} - self._exclusive = [] + 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 + """ + 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): for attr in self._attrs: @@ -27,7 +42,7 @@ class OSXPhotosOptions: arg = args[attr] # don't test 'not arg'; need to handle empty strings as valid values if arg is None or arg == False: - if self._attrs[attr] == (): + if type(self._attrs[attr]) == tuple: setattr(self, attr, ()) else: setattr(self, attr, self._attrs[attr]) @@ -36,7 +51,7 @@ class OSXPhotosOptions: except KeyError: raise KeyError(f"Missing argument: {attr}") - def validate(self, cli=False): + def validate(self, exclusive, cli=False): """ validate combinations of otions Args: @@ -49,14 +64,17 @@ class OSXPhotosOptions: InvalidOption if any combination of options is invalid InvalidOption.message will be descriptive message of invalid options """ + if not exclusive: + return True + prefix = "--" if cli else "" - for opt_pair in self._exclusive: + for opt_pair in exclusive: val0 = getattr(self, opt_pair[0]) val1 = getattr(self, opt_pair[1]) val0 = any(val0) if self._attrs[opt_pair[0]] == () else val0 val1 = any(val1) if self._attrs[opt_pair[1]] == () else val1 if val0 and val1: - raise InvalidOptions( + raise ConfigOptionsInvalidError( f"{prefix}{opt_pair[0]} and {prefix}{opt_pair[1]} options cannot be used together" ) return True @@ -78,234 +96,36 @@ class OSXPhotosOptions: data[attr] = val 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. Args: filename: full path to TOML file - override: optional ExportOptions object; - 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}" + override: bool; if True, values in the TOML file will override values already set in the instance - 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: - return options, f"Unknown option: {attr}: {loaded['export'][attr]}" - val = loaded["export"][attr] - val = getattr(override, attr) or val - if self._attrs[attr] == (): + 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(options, attr, val) - return options, None + setattr(self, attr, val) + return self, None def asdict(self): 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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 10871d0a..a2db68c7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -875,7 +875,7 @@ def test_export_using_hardlinks_incompat_options(): "-V", ], ) - assert result.exit_code == 0 + assert result.exit_code == 1 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("./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 - From d7c81adae8ece76cb624a55ae54349746eebd63a Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Wed, 9 Dec 2020 20:17:49 -0800 Subject: [PATCH 3/8] Updated validate code --- osxphotos/__main__.py | 17 +++++----- osxphotos/configoptions.py | 65 +++++++++++++++++++++++++++++++------- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 44198921..3f878426 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -1720,19 +1720,20 @@ def export( ("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_options, cli=True) + 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 missing and not download_missing: - click.echo( - "Incompatible export options: --missing must be used with --download-missing", - err=True, - ) - raise click.Abort() - if save_config: verbose_(f"Saving options to file {save_config}") cfg.write_to_file(save_config) diff --git a/osxphotos/configoptions.py b/osxphotos/configoptions.py index 3afb4ecd..7bacbf7b 100644 --- a/osxphotos/configoptions.py +++ b/osxphotos/configoptions.py @@ -51,11 +51,18 @@ class ConfigOptions: except KeyError: raise KeyError(f"Missing argument: {attr}") - def validate(self, exclusive, cli=False): + def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False): """ validate combinations of otions Args: - cli: bool, set to True if called to validate CLI options; will prepend '--' to option names in InvalidOptions.message + 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 @@ -64,19 +71,52 @@ class ConfigOptions: InvalidOption if any combination of options is invalid InvalidOption.message will be descriptive message of invalid options """ - if not exclusive: + if not any([exclusive, inclusive, dependent]): return True prefix = "--" if cli else "" - for opt_pair in exclusive: - val0 = getattr(self, opt_pair[0]) - val1 = getattr(self, opt_pair[1]) - val0 = any(val0) if self._attrs[opt_pair[0]] == () else val0 - val1 = any(val1) if self._attrs[opt_pair[1]] == () else val1 - if val0 and val1: - raise ConfigOptionsInvalidError( - f"{prefix}{opt_pair[0]} and {prefix}{opt_pair[1]} options cannot be used together" - ) + 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(x for x in b) + raise ConfigOptionsInvalidError( + f"{stra} must be used with at least one of: {strb}." + ) return True def write_to_file(self, filename): @@ -85,6 +125,7 @@ class ConfigOptions: 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) From 09687cfca43104045e2a649af9f717775f2c0228 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Fri, 11 Dec 2020 06:12:32 -0800 Subject: [PATCH 4/8] Initial test for --save-config, --load-config --- tests/test_cli.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index a2db68c7..df5b4ee2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3961,3 +3961,49 @@ def test_export_cleanup(): assert not pathlib.Path("./delete_me.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(): + 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 + + 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 + From 0262e0d97e06ee36786b4491efa178608afb5de5 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sat, 12 Dec 2020 07:25:50 -0800 Subject: [PATCH 5/8] Added tests for configoptions.py --- README.md | 1 + osxphotos/configoptions.py | 5 ++- setup.py | 1 + tests/test_cli.py | 54 +++++++++++++++++++++++ tests/test_configoptions.py | 86 +++++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 tests/test_configoptions.py diff --git a/README.md b/README.md index 94deb39b..64f0a86b 100644 --- a/README.md +++ b/README.md @@ -2247,6 +2247,7 @@ For additional details about how osxphotos is implemented or if you would like t - [bpylist2](https://pypi.org/project/bpylist2/) - [pathvalidate](https://pypi.org/project/pathvalidate/) - [wurlitzer](https://pypi.org/project/wurlitzer/) +- [toml](https://github.com/uiri/toml) ## Acknowledgements diff --git a/osxphotos/configoptions.py b/osxphotos/configoptions.py index 7bacbf7b..679c6b59 100644 --- a/osxphotos/configoptions.py +++ b/osxphotos/configoptions.py @@ -27,6 +27,7 @@ class ConfigOptions: 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() @@ -113,7 +114,7 @@ class ConfigOptions: strb = ", ".join(prefix + x.replace("_", "-") for x in b) else: stra = a - strb = ", ".join(x for x in b) + strb = ", ".join(b) raise ConfigOptionsInvalidError( f"{stra} must be used with at least one of: {strb}." ) @@ -166,7 +167,7 @@ class ConfigOptions: if type(self._attrs[attr]) == tuple: val = tuple(val) setattr(self, attr, val) - return self, None + return self def asdict(self): return {attr: getattr(self, attr) for attr in sorted(self._attrs.keys())} diff --git a/setup.py b/setup.py index 20b10ffd..631f8907 100755 --- a/setup.py +++ b/setup.py @@ -80,6 +80,7 @@ setup( "dataclasses==0.7;python_version<'3.7'", "wurlitzer>=2.0.1", "photoscript>=0.1.0", + "toml>=0.10.0", ], entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]}, include_package_data=True, diff --git a/tests/test_cli.py b/tests/test_cli.py index df5b4ee2..4c9469e6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3974,6 +3974,7 @@ def test_save_load_config(): cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): + # test save config file result = runner.invoke( export, [ @@ -3993,6 +3994,7 @@ def test_save_load_config(): files = glob.glob("*") assert "config.toml" in files + # test load config file result = runner.invoke( export, [ @@ -4007,3 +4009,55 @@ def test_save_load_config(): 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 diff --git a/tests/test_configoptions.py b/tests/test_configoptions.py new file mode 100644 index 00000000..3ec8a59c --- /dev/null +++ b/tests/test_configoptions.py @@ -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")]) From 182b816e3433264f27fe02c77b5be3840111f368 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sat, 12 Dec 2020 07:29:16 -0800 Subject: [PATCH 6/8] Updated README.md for --save-config, --load-config --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 64f0a86b..111c0ecf 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,21 @@ Options: set. For example, photos which had previously been exported and were subsequently deleted in Photos. + --load-config + 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 --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 + Save options to file for use with --load- + config. File format is TOML. -h, --help Show this message and exit. ** Export ** From aca85ee2aa01fcdece0224332584082280a3f62c Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sat, 12 Dec 2020 07:45:24 -0800 Subject: [PATCH 7/8] Version bump --- osxphotos/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osxphotos/_version.py b/osxphotos/_version.py index df8be1a5..a6ed4824 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,4 +1,4 @@ """ version info """ -__version__ = "0.37.8" +__version__ = "0.38.2" From 6559c4d8f64ad41df925182f9f24f6f67eecd1df Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sat, 12 Dec 2020 07:53:18 -0800 Subject: [PATCH 8/8] removed extended_attributes reference --- osxphotos/__main__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 56a21156..200f1aa6 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -1673,7 +1673,6 @@ def export( no_comment = cfg.no_comment has_likes = cfg.has_likes no_likes = cfg.no_likes - no_extended_attributes = cfg.no_extended_attributes label = cfg.label deleted = cfg.deleted deleted_only = cfg.deleted_only