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)