diff --git a/README.md b/README.md index a433f4d7..bb8e0421 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) through mac This package will read Photos databases for any supported version on any supported macOS version. E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running macOS 10.12 and vice versa. -Requires python >= `3.7`. +Requires python >= `3.8`. ## Installation diff --git a/README.rst b/README.rst index 7602e548..7824a9ed 100644 --- a/README.rst +++ b/README.rst @@ -23,7 +23,7 @@ If you have access to macOS 12 / Monterey beta and would like to help ensure osx This package will read Photos databases for any supported version on any supported macOS version. E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running macOS 10.12 and vice versa. -Requires python >= ``3.7``. +Requires python >= ``3.8``. Installation ------------ diff --git a/cli.py b/cli.py index d9bf43c3..6e2faae8 100644 --- a/cli.py +++ b/cli.py @@ -12,7 +12,7 @@ """ -from osxphotos.cli import cli +from osxphotos.cli.cli import cli_main if __name__ == "__main__": - cli() + cli_main() diff --git a/dev_requirements.txt b/dev_requirements.txt index 1a4efa9b..b75c8453 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -3,7 +3,7 @@ m2r2 pdbpp pyinstaller==4.4 pytest-mock -pytest==6.2.4 +pytest==7.0.1 Sphinx sphinx_click sphinx_rtd_theme diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 2738753f..060a9e93 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -1,6 +1,6 @@ """Command line interface for osxphotos """ -from .cli import cli +from .cli.cli import cli_main if __name__ == "__main__": - cli() # pylint: disable=no-value-for-parameter + cli_main() diff --git a/osxphotos/_constants.py b/osxphotos/_constants.py index 53a34c2a..6385b36a 100644 --- a/osxphotos/_constants.py +++ b/osxphotos/_constants.py @@ -233,10 +233,6 @@ DEFAULT_ORIGINAL_SUFFIX = "" # Default suffix to add to preview images DEFAULT_PREVIEW_SUFFIX = "_preview" -# Colors for print CLI messages -CLI_COLOR_ERROR = "red" -CLI_COLOR_WARNING = "yellow" - # Bit masks for --sidecar SIDECAR_JSON = 0x1 SIDECAR_EXIFTOOL = 0x2 @@ -261,6 +257,7 @@ EXTENDED_ATTRIBUTE_NAMES = [ ] EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES] + # name of export DB OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db" diff --git a/osxphotos/_version.py b/osxphotos/_version.py index db451b4a..2312328f 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.46.6" +__version__ = "0.47.0" diff --git a/osxphotos/cli/__init__.py b/osxphotos/cli/__init__.py new file mode 100644 index 00000000..4dccd2e7 --- /dev/null +++ b/osxphotos/cli/__init__.py @@ -0,0 +1,56 @@ +"""cli package for osxphotos""" + +from rich.traceback import install as install_traceback + +from .about import about +from .albums import albums +from .cli import cli_main +from .common import get_photos_db, load_uuid_from_file, set_debug +from .debug_dump import debug_dump +from .dump import dump +from .export import export +from .exportdb import exportdb +from .grep import grep +from .help import help +from .info import info +from .install_uninstall_run import install, run, uninstall +from .keywords import keywords +from .labels import labels +from .list import _list_libraries, list_libraries +from .persons import persons +from .places import places +from .query import query +from .repl import repl +from .snap_diff import diff, snap +from .tutorial import tutorial +from .uuid import uuid + +install_traceback() + +__all__ = [ + "about", + "albums", + "cli_main", + "debug_dump", + "diff", + "dump", + "export", + "exportdb", + "grep", + "help", + "info", + "install", + "keywords", + "labels", + "list_libraries", + "list_libraries", + "load_uuid_from_file", + "persons", + "places", + "query", + "repl", + "run", + "snap", + "tutorial", + "uuid", +] diff --git a/osxphotos/cli/about.py b/osxphotos/cli/about.py new file mode 100644 index 00000000..b7fdee27 --- /dev/null +++ b/osxphotos/cli/about.py @@ -0,0 +1,66 @@ +"""about command for osxphotos CLI""" + +import click + +from osxphotos._constants import OSXPHOTOS_URL +from osxphotos._version import __version__ + + +@click.command(name="about") +@click.pass_obj +@click.pass_context +def about(ctx, cli_obj): + """Print information about osxphotos including license.""" + license = """ +MIT License + +Copyright (c) 2019-2021 Rhet Turnbull + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +osxphotos uses the following 3rd party software licensed under the BSD-3-Clause License: +Click (Copyright 2014 Pallets), ptpython (Copyright (c) 2015, Jonathan Slenders) + +Redistribution and use in source and binary forms, with or without modification, are +permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list +of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list +of conditions and the following disclaimer in the documentation and/or other materials +provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be +used to endorse or promote products derived from this software without specific prior +written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" + click.echo(f"osxphotos, version {__version__}") + click.echo("") + click.echo(f"Source code available at: {OSXPHOTOS_URL}") + click.echo(license) diff --git a/osxphotos/cli/albums.py b/osxphotos/cli/albums.py new file mode 100644 index 00000000..efcb19ab --- /dev/null +++ b/osxphotos/cli/albums.py @@ -0,0 +1,42 @@ +"""albums command for osxphotos CLI""" + +import json + +import click +import yaml + +import osxphotos + +from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db +from .list import _list_libraries + +from osxphotos._constants import _PHOTOS_4_VERSION + + +@click.command() +@DB_OPTION +@JSON_OPTION +@DB_ARGUMENT +@click.pass_obj +@click.pass_context +def albums(ctx, cli_obj, db, json_, photos_library): + """Print out albums found in the Photos library.""" + + # below needed for to make CliRunner work for testing + cli_db = cli_obj.db if cli_obj is not None else None + db = get_photos_db(*photos_library, db, cli_db) + if db is None: + click.echo(ctx.obj.group.commands["albums"].get_help(ctx), err=True) + click.echo("\n\nLocated the following Photos library databases: ", err=True) + _list_libraries() + return + + photosdb = osxphotos.PhotosDB(dbfile=db) + albums = {"albums": photosdb.albums_as_dict} + if photosdb.db_version > _PHOTOS_4_VERSION: + albums["shared albums"] = photosdb.albums_shared_as_dict + + if json_ or cli_obj.json: + click.echo(json.dumps(albums, ensure_ascii=False)) + else: + click.echo(yaml.dump(albums, sort_keys=False, allow_unicode=True)) diff --git a/osxphotos/cli/cli.py b/osxphotos/cli/cli.py new file mode 100644 index 00000000..c9cd44db --- /dev/null +++ b/osxphotos/cli/cli.py @@ -0,0 +1,85 @@ +"""Command line interface for osxphotos """ + +import click + +import osxphotos +from osxphotos._version import __version__ + +from .about import about +from .albums import albums +from .common import DB_OPTION, JSON_OPTION, OSXPHOTOS_HIDDEN +from .debug_dump import debug_dump +from .dump import dump +from .export import export +from .exportdb import exportdb +from .grep import grep +from .help import help +from .info import info +from .install_uninstall_run import install, run, uninstall +from .keywords import keywords +from .labels import labels +from .list import _list_libraries, list_libraries +from .persons import persons +from .places import places +from .query import query +from .repl import repl +from .snap_diff import diff, snap +from .tutorial import tutorial +from .uuid import uuid + + +# Click CLI object & context settings +class CLI_Obj: + def __init__(self, db=None, json=False, debug=False, group=None): + if debug: + osxphotos._set_debug(True) + self.db = db + self.json = json + self.group = group + + +CTX_SETTINGS = dict(help_option_names=["-h", "--help"]) + + +@click.group(context_settings=CTX_SETTINGS) +@DB_OPTION +@JSON_OPTION +@click.option( + "--debug", + required=False, + is_flag=True, + help="Enable debug output", + hidden=OSXPHOTOS_HIDDEN, +) +@click.version_option(__version__, "--version", "-v") +@click.pass_context +def cli_main(ctx, db, json_, debug): + ctx.obj = CLI_Obj(db=db, json=json_, group=cli_main) + + +# install CLI commands +for command in [ + about, + albums, + debug_dump, + diff, + dump, + export, + exportdb, + grep, + help, + info, + install, + keywords, + labels, + list_libraries, + persons, + places, + query, + repl, + snap, + tutorial, + uninstall, + uuid, +]: + cli_main.add_command(command) diff --git a/osxphotos/cli/common.py b/osxphotos/cli/common.py new file mode 100644 index 00000000..df0d7047 --- /dev/null +++ b/osxphotos/cli/common.py @@ -0,0 +1,538 @@ +"""Globals and constants use by the CLI commands""" + +import datetime +import os +import pathlib +from typing import Callable + +import click + +import osxphotos +from osxphotos._version import __version__ + +from .param_types import * + +from rich import print as rprint + +# global variable to control debug output +# set via --debug +DEBUG = False + +# used to show/hide hidden commands +OSXPHOTOS_HIDDEN = not bool(os.getenv("OSXPHOTOS_SHOW_HIDDEN", default=False)) + +# used by snap and diff commands +OSXPHOTOS_SNAPSHOT_DIR = "/private/tmp/osxphotos_snapshots" + +# where to write the crash report if osxphotos crashes +OSXPHOTOS_CRASH_LOG = os.getcwd() + "/osxphotos_crash.log" + +CLI_COLOR_ERROR = "red" +CLI_COLOR_WARNING = "yellow" + + +def set_debug(debug: bool): + """set debug flag""" + global DEBUG + DEBUG = debug + + +def is_debug(): + """return debug flag""" + return DEBUG + + +def noop(*args, **kwargs): + """no-op function""" + pass + + +def verbose_print( + verbose: bool = True, timestamp: bool = False, rich=False +) -> Callable: + """Create verbose function to print output + + Args: + verbose: if True, returns verbose print function otherwise returns no-op function + timestamp: if True, includes timestamp in verbose output + rich: use rich.print instead of click.echo + + Returns: + function to print output + """ + if not verbose: + return noop + + # closure to capture timestamp + def verbose_(*args, **kwargs): + """print output if verbose flag set""" + styled_args = [] + timestamp_str = str(datetime.datetime.now()) + " -- " if timestamp else "" + for arg in args: + if type(arg) == str: + arg = timestamp_str + arg + if "error" in arg.lower(): + arg = click.style(arg, fg=CLI_COLOR_ERROR) + elif "warning" in arg.lower(): + arg = click.style(arg, fg=CLI_COLOR_WARNING) + styled_args.append(arg) + click.echo(*styled_args, **kwargs) + + def rich_verbose_(*args, **kwargs): + """print output if verbose flag set using rich.print""" + timestamp_str = str(datetime.datetime.now()) + " -- " if timestamp else "" + for arg in args: + if type(arg) == str: + arg = timestamp_str + arg + if "error" in arg.lower(): + arg = f"[{CLI_COLOR_ERROR}]{arg}[/{CLI_COLOR_ERROR}]" + elif "warning" in arg.lower(): + arg = f"[{CLI_COLOR_WARNING}]{arg}[/{CLI_COLOR_WARNING}]" + rprint(arg, **kwargs) + + return rich_verbose_ if rich else verbose_ + + +def get_photos_db(*db_options): + """Return path to photos db, select first non-None db_options + If no db_options are non-None, try to find library to use in + the following order: + - last library opened + - system library + - ~/Pictures/Photos Library.photoslibrary + - failing above, returns None + """ + if db_options: + for db in db_options: + if db is not None: + return db + + # if get here, no valid database paths passed, so try to figure out which to use + db = osxphotos.utils.get_last_library_path() + if db is not None: + click.echo(f"Using last opened Photos library: {db}", err=True) + return db + + db = osxphotos.utils.get_system_library_path() + if db is not None: + click.echo(f"Using system Photos library: {db}", err=True) + return db + + db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary") + if os.path.isdir(db): + click.echo(f"Using Photos library: {db}", err=True) + return db + else: + return None + + +DB_OPTION = click.option( + "--db", + required=False, + metavar="", + default=None, + help=( + "Specify Photos database path. " + "Path to Photos library/database can be specified using either --db " + "or directly as PHOTOS_LIBRARY positional argument. " + "If neither --db or PHOTOS_LIBRARY provided, will attempt to find the library " + "to use in the following order: 1. last opened library, 2. system library, 3. ~/Pictures/Photos Library.photoslibrary" + ), + type=click.Path(exists=True), +) + +DB_ARGUMENT = click.argument("photos_library", nargs=-1, type=click.Path(exists=True)) + +JSON_OPTION = click.option( + "--json", + "json_", + required=False, + is_flag=True, + default=False, + help="Print output in JSON format.", +) + + +def DELETED_OPTIONS(f): + o = click.option + options = [ + o( + "--deleted", + is_flag=True, + help="Include photos from the 'Recently Deleted' folder.", + ), + o( + "--deleted-only", + is_flag=True, + help="Include only photos from the 'Recently Deleted' folder.", + ), + ] + for o in options[::-1]: + f = o(f) + return f + + +def QUERY_OPTIONS(f): + o = click.option + options = [ + o( + "--keyword", + metavar="KEYWORD", + default=None, + multiple=True, + help="Search for photos with keyword KEYWORD. " + 'If more than one keyword, treated as "OR", e.g. find photos matching any keyword', + ), + o( + "--person", + metavar="PERSON", + default=None, + multiple=True, + help="Search for photos with person PERSON. " + 'If more than one person, treated as "OR", e.g. find photos matching any person', + ), + o( + "--album", + metavar="ALBUM", + default=None, + multiple=True, + help="Search for photos in album ALBUM. " + 'If more than one album, treated as "OR", e.g. find photos matching any album', + ), + o( + "--folder", + metavar="FOLDER", + default=None, + multiple=True, + help="Search for photos in an album in folder FOLDER. " + 'If more than one folder, treated as "OR", e.g. find photos in any FOLDER. ' + "Only searches top level folders (e.g. does not look at subfolders)", + ), + o( + "--name", + metavar="FILENAME", + default=None, + multiple=True, + help="Search for photos with filename matching FILENAME. " + 'If more than one --name options is specified, they are treated as "OR", ' + "e.g. find photos matching any FILENAME. ", + ), + o( + "--uuid", + metavar="UUID", + default=None, + multiple=True, + help="Search for photos with UUID(s). " + "May be repeated to include multiple UUIDs.", + ), + o( + "--uuid-from-file", + metavar="FILE", + default=None, + multiple=False, + help="Search for photos with UUID(s) loaded from FILE. " + "Format is a single UUID per line. Lines preceded with # are ignored.", + type=click.Path(exists=True), + ), + o( + "--title", + metavar="TITLE", + default=None, + multiple=True, + help="Search for TITLE in title of photo.", + ), + o("--no-title", is_flag=True, help="Search for photos with no title."), + o( + "--description", + metavar="DESC", + default=None, + multiple=True, + help="Search for DESC in description of photo.", + ), + o( + "--no-description", + is_flag=True, + help="Search for photos with no description.", + ), + o( + "--place", + metavar="PLACE", + default=None, + multiple=True, + help="Search for PLACE in photo's reverse geolocation info", + ), + o( + "--no-place", + is_flag=True, + help="Search for photos with no associated place name info (no reverse geolocation info)", + ), + o( + "--location", + is_flag=True, + help="Search for photos with associated location info (e.g. GPS coordinates)", + ), + o( + "--no-location", + is_flag=True, + help="Search for photos with no associated location info (e.g. no GPS coordinates)", + ), + o( + "--label", + metavar="LABEL", + multiple=True, + help="Search for photos with image classification label LABEL (Photos 5 only). " + 'If more than one label, treated as "OR", e.g. find photos matching any label', + ), + o( + "--uti", + metavar="UTI", + default=None, + multiple=False, + help="Search for photos whose uniform type identifier (UTI) matches UTI", + ), + o( + "-i", + "--ignore-case", + is_flag=True, + help="Case insensitive search for title, description, place, keyword, person, or album.", + ), + o("--edited", is_flag=True, help="Search for photos that have been edited."), + o( + "--external-edit", + is_flag=True, + help="Search for photos edited in external editor.", + ), + o("--favorite", is_flag=True, help="Search for photos marked favorite."), + o( + "--not-favorite", + is_flag=True, + help="Search for photos not marked favorite.", + ), + o("--hidden", is_flag=True, help="Search for photos marked hidden."), + o("--not-hidden", is_flag=True, help="Search for photos not marked hidden."), + o( + "--shared", + is_flag=True, + help="Search for photos in shared iCloud album (Photos 5 only).", + ), + o( + "--not-shared", + is_flag=True, + help="Search for photos not in shared iCloud album (Photos 5 only).", + ), + o( + "--burst", + is_flag=True, + help="Search for photos that were taken in a burst.", + ), + o( + "--not-burst", + is_flag=True, + help="Search for photos that are not part of a burst.", + ), + o("--live", is_flag=True, help="Search for Apple live photos"), + o( + "--not-live", + is_flag=True, + help="Search for photos that are not Apple live photos.", + ), + o("--portrait", is_flag=True, help="Search for Apple portrait mode photos."), + o( + "--not-portrait", + is_flag=True, + help="Search for photos that are not Apple portrait mode photos.", + ), + o("--screenshot", is_flag=True, help="Search for screenshot photos."), + o( + "--not-screenshot", + is_flag=True, + help="Search for photos that are not screenshot photos.", + ), + o("--slow-mo", is_flag=True, help="Search for slow motion videos."), + o( + "--not-slow-mo", + is_flag=True, + help="Search for photos that are not slow motion videos.", + ), + o("--time-lapse", is_flag=True, help="Search for time lapse videos."), + o( + "--not-time-lapse", + is_flag=True, + help="Search for photos that are not time lapse videos.", + ), + o("--hdr", is_flag=True, help="Search for high dynamic range (HDR) photos."), + o("--not-hdr", is_flag=True, help="Search for photos that are not HDR photos."), + o( + "--selfie", + is_flag=True, + help="Search for selfies (photos taken with front-facing cameras).", + ), + o("--not-selfie", is_flag=True, help="Search for photos that are not selfies."), + o("--panorama", is_flag=True, help="Search for panorama photos."), + o( + "--not-panorama", + is_flag=True, + help="Search for photos that are not panoramas.", + ), + o( + "--has-raw", + is_flag=True, + help="Search for photos with both a jpeg and raw version", + ), + o( + "--only-movies", + is_flag=True, + help="Search only for movies (default searches both images and movies).", + ), + o( + "--only-photos", + is_flag=True, + help="Search only for photos/images (default searches both images and movies).", + ), + o( + "--from-date", + help="Search by item start date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).", + type=DateTimeISO8601(), + ), + o( + "--to-date", + help="Search by item end date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).", + type=DateTimeISO8601(), + ), + o( + "--from-time", + help="Search by item start time of day, e.g. 12:00, or 12:00:00.", + type=TimeISO8601(), + ), + o( + "--to-time", + help="Search by item end time of day, e.g. 12:00 or 12:00:00.", + type=TimeISO8601(), + ), + o("--has-comment", is_flag=True, help="Search for photos that have comments."), + o("--no-comment", is_flag=True, help="Search for photos with no comments."), + o("--has-likes", is_flag=True, help="Search for photos that have likes."), + o("--no-likes", is_flag=True, help="Search for photos with no likes."), + o( + "--is-reference", + is_flag=True, + help="Search for photos that were imported as referenced files (not copied into Photos library).", + ), + o( + "--in-album", + is_flag=True, + help="Search for photos that are in one or more albums.", + ), + o( + "--not-in-album", + is_flag=True, + help="Search for photos that are not in any albums.", + ), + o( + "--duplicate", + is_flag=True, + help="Search for photos with possible duplicates. osxphotos will compare signatures of photos, " + "evaluating date created, size, height, width, and edited status to find *possible* duplicates. " + "This does not compare images byte-for-byte nor compare hashes but should find photos imported multiple " + "times or duplicated within Photos.", + ), + o( + "--min-size", + metavar="SIZE", + type=BitMathSize(), + help="Search for photos with size >= SIZE bytes. " + "The size evaluated is the photo's original size (when imported to Photos). " + "Size may be specified as integer bytes or using SI or NIST units. " + "For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.", + ), + o( + "--max-size", + metavar="SIZE", + type=BitMathSize(), + help="Search for photos with size <= SIZE bytes. " + "The size evaluated is the photo's original size (when imported to Photos). " + "Size may be specified as integer bytes or using SI or NIST units. " + "For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.", + ), + o( + "--regex", + metavar="REGEX TEMPLATE", + nargs=2, + multiple=True, + help="Search for photos where TEMPLATE matches regular expression REGEX. " + "For example, to find photos in an album that begins with 'Beach': '--regex \"^Beach\" \"{album}\"'. " + "You may specify more than one regular expression match by repeating '--regex' with different arguments.", + ), + o( + "--selected", + is_flag=True, + help="Filter for photos that are currently selected in Photos.", + ), + o( + "--exif", + metavar="EXIF_TAG VALUE", + nargs=2, + multiple=True, + help="Search for photos where EXIF_TAG exists in photo's EXIF data and contains VALUE. " + "For example, to find photos created by Adobe Photoshop: `--exif Software 'Adobe Photoshop' `" + "or to find all photos shot on a Canon camera: `--exif Make Canon`. " + "EXIF_TAG can be any valid exiftool tag, with or without group name, e.g. `EXIF:Make` or `Make`. " + "To use --exif, exiftool must be installed and in the path.", + ), + o( + "--query-eval", + metavar="CRITERIA", + multiple=True, + help="Evaluate CRITERIA to filter photos. " + "CRITERIA will be evaluated in context of the following python list comprehension: " + "`photos = [photo for photo in photos if CRITERIA]` " + "where photo represents a PhotoInfo object. " + "For example: `--query-eval photo.favorite` returns all photos that have been " + "favorited and is equivalent to --favorite. " + "You may specify more than one CRITERIA by using --query-eval multiple times. " + "CRITERIA must be a valid python expression. " + "See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.", + ), + o( + "--query-function", + metavar="filename.py::function", + multiple=True, + type=FunctionCall(), + help="Run function to filter photos. Use this in format: --query-function filename.py::function where filename.py is a python " + + "file you've created and function is the name of the function in the python file you want to call. " + + "Your function will be passed a list of PhotoInfo objects and is expected to return a filtered list of PhotoInfo objects. " + + "You may use more than one function by repeating the --query-function option with a different value. " + + "Your query function will be called after all other query options have been evaluated. " + + "See https://github.com/RhetTbull/osxphotos/blob/master/examples/query_function.py for example of how to use this option.", + ), + ] + for o in options[::-1]: + f = o(f) + return f + + +def load_uuid_from_file(filename): + """Load UUIDs from file. Does not validate UUIDs. + Format is 1 UUID per line, any line beginning with # is ignored. + Whitespace is stripped. + + Arguments: + filename: file name of the file containing UUIDs + + Returns: + list of UUIDs or empty list of no UUIDs in file + + Raises: + FileNotFoundError if file does not exist + """ + + if not pathlib.Path(filename).is_file(): + raise FileNotFoundError(f"Could not find file {filename}") + + uuid = [] + with open(filename, "r") as uuid_file: + for line in uuid_file: + line = line.strip() + if len(line) and line[0] != "#": + uuid.append(line) + return uuid diff --git a/osxphotos/cli/debug_dump.py b/osxphotos/cli/debug_dump.py new file mode 100644 index 00000000..f93102d3 --- /dev/null +++ b/osxphotos/cli/debug_dump.py @@ -0,0 +1,103 @@ +"""debug-dump command for osxphotos CLI""" + +import pprint +import time + +import click +from rich import print + +import osxphotos +from osxphotos._constants import _PHOTOS_4_VERSION, _UNKNOWN_PLACE + +from .common import ( + DB_ARGUMENT, + DB_OPTION, + JSON_OPTION, + OSXPHOTOS_HIDDEN, + get_photos_db, + verbose_print, +) +from .list import _list_libraries + + +@click.command(hidden=OSXPHOTOS_HIDDEN) +@DB_OPTION +@DB_ARGUMENT +@click.option( + "--dump", + metavar="ATTR", + help="Name of PhotosDB attribute to print; " + + "can also use albums, persons, keywords, photos to dump related attributes.", + multiple=True, +) +@click.option( + "--uuid", + metavar="UUID", + help="Use with '--dump photos' to dump only certain UUIDs. " + "May be repeated to include multiple UUIDs.", + multiple=True, +) +@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): + """Print out debug info""" + + verbose_ = verbose_print(verbose, rich=True) + db = get_photos_db(*photos_library, db, cli_obj.db) + if db is None: + click.echo(ctx.obj.group.commands["debug-dump"].get_help(ctx), err=True) + click.echo("\n\nLocated the following Photos library databases: ", err=True) + _list_libraries() + return + + start_t = time.perf_counter() + print(f"Opening database: {db}") + photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_) + stop_t = time.perf_counter() + print(f"Done; took {(stop_t-start_t):.2f} seconds") + + for attr in dump: + if attr == "albums": + print("_dbalbums_album:") + pprint.pprint(photosdb._dbalbums_album) + print("_dbalbums_uuid:") + pprint.pprint(photosdb._dbalbums_uuid) + print("_dbalbum_details:") + pprint.pprint(photosdb._dbalbum_details) + print("_dbalbum_folders:") + pprint.pprint(photosdb._dbalbum_folders) + print("_dbfolder_details:") + pprint.pprint(photosdb._dbfolder_details) + elif attr == "keywords": + print("_dbkeywords_keyword:") + pprint.pprint(photosdb._dbkeywords_keyword) + print("_dbkeywords_uuid:") + pprint.pprint(photosdb._dbkeywords_uuid) + elif attr == "persons": + print("_dbfaces_uuid:") + pprint.pprint(photosdb._dbfaces_uuid) + print("_dbfaces_pk:") + pprint.pprint(photosdb._dbfaces_pk) + print("_dbpersons_pk:") + pprint.pprint(photosdb._dbpersons_pk) + print("_dbpersons_fullname:") + pprint.pprint(photosdb._dbpersons_fullname) + elif attr == "photos": + if uuid: + for uuid_ in uuid: + print(f"_dbphotos['{uuid_}']:") + try: + pprint.pprint(photosdb._dbphotos[uuid_]) + except KeyError: + print(f"Did not find uuid {uuid_} in _dbphotos") + else: + print("_dbphotos:") + pprint.pprint(photosdb._dbphotos) + else: + try: + val = getattr(photosdb, attr) + print(f"{attr}:") + pprint.pprint(val) + except Exception: + print(f"Did not find attribute {attr} in PhotosDB") diff --git a/osxphotos/cli/dump.py b/osxphotos/cli/dump.py new file mode 100644 index 00000000..fea3fa1c --- /dev/null +++ b/osxphotos/cli/dump.py @@ -0,0 +1,44 @@ +"""dump command for osxphotos CLI """ + +import click + +import osxphotos +from osxphotos.queryoptions import QueryOptions + +from .common import DB_ARGUMENT, DB_OPTION, DELETED_OPTIONS, JSON_OPTION, get_photos_db +from .list import _list_libraries +from .print_photo_info import print_photo_info + + +@click.command() +@DB_OPTION +@JSON_OPTION +@DELETED_OPTIONS +@DB_ARGUMENT +@click.pass_obj +@click.pass_context +def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library): + """Print list of all photos & associated info from the Photos library.""" + + db = get_photos_db(*photos_library, db, cli_obj.db) + if db is None: + click.echo(ctx.obj.group.commands["dump"].get_help(ctx), err=True) + click.echo("\n\nLocated the following Photos library databases: ", err=True) + _list_libraries() + return + + # check exclusive options + if deleted and deleted_only: + click.echo("Incompatible dump options", err=True) + click.echo(ctx.obj.group.commands["dump"].get_help(ctx), err=True) + return + + photosdb = osxphotos.PhotosDB(dbfile=db) + if deleted or deleted_only: + photos = photosdb.photos(movies=True, intrash=True) + else: + photos = [] + if not deleted_only: + photos += photosdb.photos(movies=True) + + print_photo_info(photos, json_ or cli_obj.json) diff --git a/osxphotos/cli.py b/osxphotos/cli/export.py similarity index 56% rename from osxphotos/cli.py rename to osxphotos/cli/export.py index b87c6b00..2261a46e 100644 --- a/osxphotos/cli.py +++ b/osxphotos/cli/export.py @@ -1,45 +1,25 @@ -"""Command line interface for osxphotos """ +"""export command for osxphotos CLI""" import atexit -import code import cProfile import csv -import dataclasses -import datetime import io -import json import os -import os.path import pathlib -import pprint import pstats import shlex -import shutil import subprocess import sys import time -from runpy import run_module, run_path -from typing import Dict, List +from typing import Dict -import bitmath import click import osxmetadata -import photoscript -import rich.traceback -import yaml -from rich import pretty, print -from rich.console import Console -from rich.syntax import Syntax import osxphotos - -from ._constants import ( +from osxphotos._constants import ( _EXIF_TOOL_URL, _OSXPHOTOS_NONE_SENTINEL, - _PHOTOS_4_VERSION, - _UNKNOWN_PLACE, - CLI_COLOR_ERROR, - CLI_COLOR_WARNING, DEFAULT_EDITED_SUFFIX, DEFAULT_JPEG_QUALITY, DEFAULT_ORIGINAL_SUFFIX, @@ -47,673 +27,58 @@ from ._constants import ( EXTENDED_ATTRIBUTE_NAMES, EXTENDED_ATTRIBUTE_NAMES_QUOTED, OSXPHOTOS_EXPORT_DB, - OSXPHOTOS_URL, POST_COMMAND_CATEGORIES, PROFILE_SORT_KEYS, SIDECAR_EXIFTOOL, SIDECAR_JSON, SIDECAR_XMP, ) -from ._version import __version__ -from .cli_help import ExportCommand, tutorial_help -from .configoptions import ( +from osxphotos._version import __version__ +from osxphotos.configoptions import ( ConfigOptions, ConfigOptionsInvalidError, ConfigOptionsLoadError, ) -from .crash_reporter import crash_reporter -from .datetime_formatter import DateTimeFormatter -from .exiftool import get_exiftool_path -from .export_db import ExportDB, ExportDBInMemory -from .export_db_utils import ( - OSXPHOTOS_EXPORTDB_VERSION, - export_db_check_signatures, - export_db_get_last_run, - export_db_get_version, - export_db_save_config_to_file, - export_db_touch_files, - export_db_update_signatures, - export_db_vacuum, +from osxphotos.crash_reporter import crash_reporter +from osxphotos.datetime_formatter import DateTimeFormatter +from osxphotos.exiftool import get_exiftool_path +from osxphotos.export_db import ExportDB, ExportDBInMemory +from osxphotos.fileutil import FileUtil, FileUtilNoOp +from osxphotos.path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath +from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter +from osxphotos.photokit import ( + check_photokit_authorization, + request_photokit_authorization, ) -from .fileutil import FileUtil, FileUtilNoOp -from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath -from .photoexporter import ExportOptions, ExportResults, PhotoExporter -from .photoinfo import PhotoInfo -from .photokit import check_photokit_authorization, request_photokit_authorization -from .photosalbum import PhotosAlbum -from .photosdb import PhotosDB -from .photosdb.photosdb_utils import get_photos_library_version -from .phototemplate import PhotoTemplate, RenderOptions -from .pyrepl import embed_repl -from .queryoptions import QueryOptions -from .sqlgrep import sqlgrep -from .uti import get_preferred_uti_extension -from .utils import ( - expand_and_validate_filepath, - format_sec_to_hhmmss, - load_function, - normalize_fs_path, +from osxphotos.photosalbum import PhotosAlbum +from osxphotos.phototemplate import PhotoTemplate, RenderOptions +from osxphotos.queryoptions import QueryOptions +from osxphotos.uti import get_preferred_uti_extension +from osxphotos.utils import format_sec_to_hhmmss, normalize_fs_path + +from .common import ( + CLI_COLOR_ERROR, + CLI_COLOR_WARNING, + DB_ARGUMENT, + DB_OPTION, + DELETED_OPTIONS, + JSON_OPTION, + OSXPHOTOS_CRASH_LOG, + OSXPHOTOS_HIDDEN, + QUERY_OPTIONS, + get_photos_db, + is_debug, + load_uuid_from_file, + noop, + set_debug, + verbose_print, ) - -__all__ = [ - "verbose_", - "get_photos_db", - "DateTimeISO8601", - "BitMathSize", - "TimeISO8601", - "FunctionCall", - "CLI_Obj", - "DELETED_OPTIONS", - "QUERY_OPTIONS", - "cli", - "export", - "help", - "query", - "print_photo_info", - "export_photo", - "export_photo_to_directory", - "get_filenames_from_template", - "get_dirnames_from_template", - "find_files_in_branch", - "load_uuid_from_file", - "write_export_report", - "cleanup_files", - "write_finder_tags", - "write_extended_attributes", - "run_post_command", - "install", - "uninstall", - "keywords", - "albums", - "persons", - "labels", - "info", - "places", - "dump", - "list_libraries", - "uuid", - "about", - "tutorial", - "repl", - "grep", - "debug_dump", - "snap", - "diff", -] - -# global variable to control verbose output -# set via --verbose/-V -VERBOSE = False -VERBOSE_TIMESTAMP = False - -# global variable to control debug output -# set via --debug -DEBUG = False - -# used to show/hide hidden commands -OSXPHOTOS_HIDDEN = not bool(os.getenv("OSXPHOTOS_SHOW_HIDDEN", default=False)) - -# used by snap and diff commands -OSXPHOTOS_SNAPSHOT_DIR = "/private/tmp/osxphotos_snapshots" - -# where to write the crash report if osxphotos crashes -OSXPHOTOS_CRASH_LOG = os.getcwd() + "/osxphotos_crash.log" - -rich.traceback.install() +from .help import ExportCommand, get_help_msg +from .list import _list_libraries +from .param_types import ExportDBType, FunctionCall -def verbose_(*args, **kwargs): - """print output if verbose flag set""" - if VERBOSE: - styled_args = [] - timestamp = str(datetime.datetime.now()) + " -- " if VERBOSE_TIMESTAMP else "" - for arg in args: - if type(arg) == str: - arg = timestamp + arg - if "error" in arg.lower(): - arg = click.style(arg, fg=CLI_COLOR_ERROR) - elif "warning" in arg.lower(): - arg = click.style(arg, fg=CLI_COLOR_WARNING) - styled_args.append(arg) - click.echo(*styled_args, **kwargs) - - -def get_photos_db(*db_options): - """Return path to photos db, select first non-None db_options - If no db_options are non-None, try to find library to use in - the following order: - - last library opened - - system library - - ~/Pictures/Photos Library.photoslibrary - - failing above, returns None - """ - if db_options: - for db in db_options: - if db is not None: - return db - - # if get here, no valid database paths passed, so try to figure out which to use - db = osxphotos.utils.get_last_library_path() - if db is not None: - click.echo(f"Using last opened Photos library: {db}", err=True) - return db - - db = osxphotos.utils.get_system_library_path() - if db is not None: - click.echo(f"Using system Photos library: {db}", err=True) - return db - - db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary") - if os.path.isdir(db): - click.echo(f"Using Photos library: {db}", err=True) - return db - else: - return None - - -class DateTimeISO8601(click.ParamType): - - name = "DATETIME" - - def convert(self, value, param, ctx): - try: - return datetime.datetime.fromisoformat(value) - except Exception: - self.fail( - f"Invalid datetime format {value}. " - "Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]" - ) - - -class BitMathSize(click.ParamType): - - name = "BITMATH" - - def convert(self, value, param, ctx): - try: - value = bitmath.parse_string(value) - except ValueError: - # no units specified - try: - value = int(value) - value = bitmath.Byte(value) - except ValueError as e: - self.fail( - f"{value} must be specified as bytes or using SI/NIST units. " - + "For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'." - ) - return value - - -class TimeISO8601(click.ParamType): - - name = "TIME" - - def convert(self, value, param, ctx): - try: - return datetime.time.fromisoformat(value).replace(tzinfo=None) - except Exception: - self.fail( - f"Invalid time format {value}. " - "Valid format: HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]] " - "however, note that timezone will be ignored." - ) - - -class FunctionCall(click.ParamType): - name = "FUNCTION" - - def convert(self, value, param, ctx): - if "::" not in value: - self.fail( - f"Could not parse function name from '{value}'. " - "Valid format filename.py::function" - ) - - filename, funcname = value.split("::") - - filename_validated = expand_and_validate_filepath(filename) - if not filename_validated: - self.fail(f"'{filename}' does not appear to be a file") - - try: - function = load_function(filename_validated, funcname) - except Exception as e: - self.fail(f"Could not load function {funcname} from {filename_validated}") - - return (function, value) - - -class ExportDBType(click.ParamType): - - name = "EXPORTDB" - - def convert(self, value, param, ctx): - try: - export_db_name = pathlib.Path(value) - if export_db_name.is_dir(): - raise click.BadParameter(f"{value} is a directory") - if export_db_name.is_file(): - # verify it's actually an osxphotos export_db - # export_db_get_version will raise an error if it's not valid - osxphotos_ver, export_db_ver = export_db_get_version(value) - return value - except Exception: - self.fail(f"{value} exists but is not a valid osxphotos export database. ") - - -class IncompatibleQueryOptions(Exception): - pass - - -# Click CLI object & context settings -class CLI_Obj: - def __init__(self, db=None, json=False, debug=False): - if debug: - osxphotos._set_debug(True) - self.db = db - self.json = json - - -CTX_SETTINGS = dict(help_option_names=["-h", "--help"]) -DB_OPTION = click.option( - "--db", - required=False, - metavar="", - default=None, - help=( - "Specify Photos database path. " - "Path to Photos library/database can be specified using either --db " - "or directly as PHOTOS_LIBRARY positional argument. " - "If neither --db or PHOTOS_LIBRARY provided, will attempt to find the library " - "to use in the following order: 1. last opened library, 2. system library, 3. ~/Pictures/Photos Library.photoslibrary" - ), - type=click.Path(exists=True), -) - -DB_ARGUMENT = click.argument("photos_library", nargs=-1, type=click.Path(exists=True)) - -JSON_OPTION = click.option( - "--json", - "json_", - required=False, - is_flag=True, - default=False, - help="Print output in JSON format.", -) - - -def DELETED_OPTIONS(f): - o = click.option - options = [ - o( - "--deleted", - is_flag=True, - help="Include photos from the 'Recently Deleted' folder.", - ), - o( - "--deleted-only", - is_flag=True, - help="Include only photos from the 'Recently Deleted' folder.", - ), - ] - for o in options[::-1]: - f = o(f) - return f - - -def QUERY_OPTIONS(f): - o = click.option - options = [ - o( - "--keyword", - metavar="KEYWORD", - default=None, - multiple=True, - help="Search for photos with keyword KEYWORD. " - 'If more than one keyword, treated as "OR", e.g. find photos matching any keyword', - ), - o( - "--person", - metavar="PERSON", - default=None, - multiple=True, - help="Search for photos with person PERSON. " - 'If more than one person, treated as "OR", e.g. find photos matching any person', - ), - o( - "--album", - metavar="ALBUM", - default=None, - multiple=True, - help="Search for photos in album ALBUM. " - 'If more than one album, treated as "OR", e.g. find photos matching any album', - ), - o( - "--folder", - metavar="FOLDER", - default=None, - multiple=True, - help="Search for photos in an album in folder FOLDER. " - 'If more than one folder, treated as "OR", e.g. find photos in any FOLDER. ' - "Only searches top level folders (e.g. does not look at subfolders)", - ), - o( - "--name", - metavar="FILENAME", - default=None, - multiple=True, - help="Search for photos with filename matching FILENAME. " - 'If more than one --name options is specified, they are treated as "OR", ' - "e.g. find photos matching any FILENAME. ", - ), - o( - "--uuid", - metavar="UUID", - default=None, - multiple=True, - help="Search for photos with UUID(s). " - "May be repeated to include multiple UUIDs.", - ), - o( - "--uuid-from-file", - metavar="FILE", - default=None, - multiple=False, - help="Search for photos with UUID(s) loaded from FILE. " - "Format is a single UUID per line. Lines preceded with # are ignored.", - type=click.Path(exists=True), - ), - o( - "--title", - metavar="TITLE", - default=None, - multiple=True, - help="Search for TITLE in title of photo.", - ), - o("--no-title", is_flag=True, help="Search for photos with no title."), - o( - "--description", - metavar="DESC", - default=None, - multiple=True, - help="Search for DESC in description of photo.", - ), - o( - "--no-description", - is_flag=True, - help="Search for photos with no description.", - ), - o( - "--place", - metavar="PLACE", - default=None, - multiple=True, - help="Search for PLACE in photo's reverse geolocation info", - ), - o( - "--no-place", - is_flag=True, - help="Search for photos with no associated place name info (no reverse geolocation info)", - ), - o( - "--location", - is_flag=True, - help="Search for photos with associated location info (e.g. GPS coordinates)", - ), - o( - "--no-location", - is_flag=True, - help="Search for photos with no associated location info (e.g. no GPS coordinates)", - ), - o( - "--label", - metavar="LABEL", - multiple=True, - help="Search for photos with image classification label LABEL (Photos 5 only). " - 'If more than one label, treated as "OR", e.g. find photos matching any label', - ), - o( - "--uti", - metavar="UTI", - default=None, - multiple=False, - help="Search for photos whose uniform type identifier (UTI) matches UTI", - ), - o( - "-i", - "--ignore-case", - is_flag=True, - help="Case insensitive search for title, description, place, keyword, person, or album.", - ), - o("--edited", is_flag=True, help="Search for photos that have been edited."), - o( - "--external-edit", - is_flag=True, - help="Search for photos edited in external editor.", - ), - o("--favorite", is_flag=True, help="Search for photos marked favorite."), - o( - "--not-favorite", - is_flag=True, - help="Search for photos not marked favorite.", - ), - o("--hidden", is_flag=True, help="Search for photos marked hidden."), - o("--not-hidden", is_flag=True, help="Search for photos not marked hidden."), - o( - "--shared", - is_flag=True, - help="Search for photos in shared iCloud album (Photos 5 only).", - ), - o( - "--not-shared", - is_flag=True, - help="Search for photos not in shared iCloud album (Photos 5 only).", - ), - o( - "--burst", - is_flag=True, - help="Search for photos that were taken in a burst.", - ), - o( - "--not-burst", - is_flag=True, - help="Search for photos that are not part of a burst.", - ), - o("--live", is_flag=True, help="Search for Apple live photos"), - o( - "--not-live", - is_flag=True, - help="Search for photos that are not Apple live photos.", - ), - o("--portrait", is_flag=True, help="Search for Apple portrait mode photos."), - o( - "--not-portrait", - is_flag=True, - help="Search for photos that are not Apple portrait mode photos.", - ), - o("--screenshot", is_flag=True, help="Search for screenshot photos."), - o( - "--not-screenshot", - is_flag=True, - help="Search for photos that are not screenshot photos.", - ), - o("--slow-mo", is_flag=True, help="Search for slow motion videos."), - o( - "--not-slow-mo", - is_flag=True, - help="Search for photos that are not slow motion videos.", - ), - o("--time-lapse", is_flag=True, help="Search for time lapse videos."), - o( - "--not-time-lapse", - is_flag=True, - help="Search for photos that are not time lapse videos.", - ), - o("--hdr", is_flag=True, help="Search for high dynamic range (HDR) photos."), - o("--not-hdr", is_flag=True, help="Search for photos that are not HDR photos."), - o( - "--selfie", - is_flag=True, - help="Search for selfies (photos taken with front-facing cameras).", - ), - o("--not-selfie", is_flag=True, help="Search for photos that are not selfies."), - o("--panorama", is_flag=True, help="Search for panorama photos."), - o( - "--not-panorama", - is_flag=True, - help="Search for photos that are not panoramas.", - ), - o( - "--has-raw", - is_flag=True, - help="Search for photos with both a jpeg and raw version", - ), - o( - "--only-movies", - is_flag=True, - help="Search only for movies (default searches both images and movies).", - ), - o( - "--only-photos", - is_flag=True, - help="Search only for photos/images (default searches both images and movies).", - ), - o( - "--from-date", - help="Search by item start date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).", - type=DateTimeISO8601(), - ), - o( - "--to-date", - help="Search by item end date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).", - type=DateTimeISO8601(), - ), - o( - "--from-time", - help="Search by item start time of day, e.g. 12:00, or 12:00:00.", - type=TimeISO8601(), - ), - o( - "--to-time", - help="Search by item end time of day, e.g. 12:00 or 12:00:00.", - type=TimeISO8601(), - ), - o("--has-comment", is_flag=True, help="Search for photos that have comments."), - o("--no-comment", is_flag=True, help="Search for photos with no comments."), - o("--has-likes", is_flag=True, help="Search for photos that have likes."), - o("--no-likes", is_flag=True, help="Search for photos with no likes."), - o( - "--is-reference", - is_flag=True, - help="Search for photos that were imported as referenced files (not copied into Photos library).", - ), - o( - "--in-album", - is_flag=True, - help="Search for photos that are in one or more albums.", - ), - o( - "--not-in-album", - is_flag=True, - help="Search for photos that are not in any albums.", - ), - o( - "--duplicate", - is_flag=True, - help="Search for photos with possible duplicates. osxphotos will compare signatures of photos, " - "evaluating date created, size, height, width, and edited status to find *possible* duplicates. " - "This does not compare images byte-for-byte nor compare hashes but should find photos imported multiple " - "times or duplicated within Photos.", - ), - o( - "--min-size", - metavar="SIZE", - type=BitMathSize(), - help="Search for photos with size >= SIZE bytes. " - "The size evaluated is the photo's original size (when imported to Photos). " - "Size may be specified as integer bytes or using SI or NIST units. " - "For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.", - ), - o( - "--max-size", - metavar="SIZE", - type=BitMathSize(), - help="Search for photos with size <= SIZE bytes. " - "The size evaluated is the photo's original size (when imported to Photos). " - "Size may be specified as integer bytes or using SI or NIST units. " - "For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.", - ), - o( - "--regex", - metavar="REGEX TEMPLATE", - nargs=2, - multiple=True, - help="Search for photos where TEMPLATE matches regular expression REGEX. " - "For example, to find photos in an album that begins with 'Beach': '--regex \"^Beach\" \"{album}\"'. " - "You may specify more than one regular expression match by repeating '--regex' with different arguments.", - ), - o( - "--selected", - is_flag=True, - help="Filter for photos that are currently selected in Photos.", - ), - o( - "--exif", - metavar="EXIF_TAG VALUE", - nargs=2, - multiple=True, - help="Search for photos where EXIF_TAG exists in photo's EXIF data and contains VALUE. " - "For example, to find photos created by Adobe Photoshop: `--exif Software 'Adobe Photoshop' `" - "or to find all photos shot on a Canon camera: `--exif Make Canon`. " - "EXIF_TAG can be any valid exiftool tag, with or without group name, e.g. `EXIF:Make` or `Make`. " - "To use --exif, exiftool must be installed and in the path.", - ), - o( - "--query-eval", - metavar="CRITERIA", - multiple=True, - help="Evaluate CRITERIA to filter photos. " - "CRITERIA will be evaluated in context of the following python list comprehension: " - "`photos = [photo for photo in photos if CRITERIA]` " - "where photo represents a PhotoInfo object. " - "For example: `--query-eval photo.favorite` returns all photos that have been " - "favorited and is equivalent to --favorite. " - "You may specify more than one CRITERIA by using --query-eval multiple times. " - "CRITERIA must be a valid python expression. " - "See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.", - ), - o( - "--query-function", - metavar="filename.py::function", - multiple=True, - type=FunctionCall(), - help="Run function to filter photos. Use this in format: --query-function filename.py::function where filename.py is a python " - + "file you've created and function is the name of the function in the python file you want to call. " - + "Your function will be passed a list of PhotoInfo objects and is expected to return a filtered list of PhotoInfo objects. " - + "You may use more than one function by repeating the --query-function option with a different value. " - + "Your query function will be called after all other query options have been evaluated. " - + "See https://github.com/RhetTbull/osxphotos/blob/master/examples/query_function.py for example of how to use this option.", - ), - ] - for o in options[::-1]: - f = o(f) - return f - - -@click.group(context_settings=CTX_SETTINGS) -@DB_OPTION -@JSON_OPTION -@click.option( - "--debug", required=False, is_flag=True, default=False, hidden=OSXPHOTOS_HIDDEN -) -@click.version_option(__version__, "--version", "-v") -@click.pass_context -def cli(ctx, db, json_, debug): - ctx.obj = CLI_Obj(db=db, json=json_) - - -@cli.command(cls=ExportCommand) +@click.command(cls=ExportCommand) @DB_OPTION @click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.") @click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output") @@ -1427,9 +792,8 @@ def export( to modify this behavior. """ - global DEBUG - if debug: - DEBUG = True + if is_debug(): + set_debug(True) osxphotos._set_debug(True) if profile: @@ -1459,10 +823,7 @@ def export( ignore=["ctx", "cli_obj", "dest", "load_config", "save_config", "config_only"], ) - global VERBOSE - global VERBOSE_TIMESTAMP - VERBOSE = bool(verbose) - VERBOSE_TIMESTAMP = timestamp + verbose_ = verbose_print(verbose, timestamp) if load_config: try: @@ -1788,8 +1149,8 @@ def export( # below needed for to make CliRunner work for testing cli_db = cli_obj.db if cli_obj is not None else None db = get_photos_db(*photos_library, db, cli_db) - if db is None: - click.echo(cli.commands["export"].get_help(ctx), err=True) + if not db: + click.echo(get_help_msg(export), err=True) click.echo("\n\nLocated the following Photos library databases: ", err=True) _list_libraries() return @@ -2051,7 +1412,7 @@ def export( update=update, use_photokit=use_photokit, use_photos_export=use_photos_export, - verbose=verbose, + verbose_=verbose_, ) if post_function: @@ -2076,6 +1437,7 @@ def export( dry_run=dry_run, exiftool_path=exiftool_path, export_db=export_db, + verbose_=verbose_, ) if album_export and export_results.exported: @@ -2145,6 +1507,7 @@ def export( finder_tag_template=finder_tag_template, strip=strip, export_dir=dest, + verbose_=verbose_, ) results.xattr_written.extend(tags_written) results.xattr_skipped.extend(tags_skipped) @@ -2156,6 +1519,7 @@ def export( xattr_template, strip=strip, export_dir=dest, + verbose_=verbose_, ) results.xattr_written.extend(xattr_written) results.xattr_skipped.extend(xattr_skipped) @@ -2212,7 +1576,9 @@ def export( + db_files ) click.echo(f"Cleaning up {dest}") - cleaned_files, cleaned_dirs = cleanup_files(dest, all_files, fileutil) + cleaned_files, cleaned_dirs = cleanup_files( + dest, all_files, fileutil, verbose_=verbose_ + ) file_str = "files" if len(cleaned_files) != 1 else "file" dir_str = "directories" if len(cleaned_dirs) != 1 else "directory" click.echo( @@ -2244,465 +1610,10 @@ def _export_with_profiler(args: Dict): ) -@cli.command() -@click.argument("topic", default=None, required=False, nargs=1) -@click.pass_context -def help(ctx, topic, **kw): - """Print help; for help on commands: help .""" - if topic is None: - click.echo(ctx.parent.get_help()) - elif topic in cli.commands: - ctx.info_name = topic - click.echo_via_pager(cli.commands[topic].get_help(ctx)) - else: - click.echo(f"Invalid command: {topic}", err=True) - click.echo(ctx.parent.get_help()) - - -@cli.command() -@DB_OPTION -@JSON_OPTION -@QUERY_OPTIONS -@DELETED_OPTIONS -@click.option("--missing", is_flag=True, help="Search for photos missing from disk.") -@click.option( - "--not-missing", - is_flag=True, - help="Search for photos present on disk (e.g. not missing).", -) -@click.option( - "--cloudasset", - is_flag=True, - help="Search for photos that are part of an iCloud library", -) -@click.option( - "--not-cloudasset", - is_flag=True, - help="Search for photos that are not part of an iCloud library", -) -@click.option( - "--incloud", - is_flag=True, - help="Search for photos that are in iCloud (have been synched)", -) -@click.option( - "--not-incloud", - is_flag=True, - help="Search for photos that are not in iCloud (have not been synched)", -) -@click.option( - "--add-to-album", - metavar="ALBUM", - help="Add all photos from query to album ALBUM in Photos. Album ALBUM will be created " - "if it doesn't exist. All photos in the query results will be added to this album. " - "This only works if the Photos library being queried is the last-opened (default) library in Photos. " - "This feature is currently experimental. I don't know how well it will work on large query sets.", -) -@click.option( - "--debug", required=False, is_flag=True, default=False, hidden=OSXPHOTOS_HIDDEN -) -@DB_ARGUMENT -@click.pass_obj -@click.pass_context -def query( - ctx, - cli_obj, - db, - photos_library, - keyword, - person, - album, - folder, - name, - uuid, - uuid_from_file, - title, - no_title, - description, - no_description, - ignore_case, - json_, - edited, - external_edit, - favorite, - not_favorite, - hidden, - not_hidden, - missing, - not_missing, - shared, - not_shared, - only_movies, - only_photos, - uti, - burst, - not_burst, - live, - not_live, - cloudasset, - not_cloudasset, - incloud, - not_incloud, - from_date, - to_date, - from_time, - to_time, - 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, - has_raw, - place, - no_place, - location, - no_location, - label, - deleted, - deleted_only, - has_comment, - no_comment, - has_likes, - no_likes, - is_reference, - in_album, - not_in_album, - duplicate, - min_size, - max_size, - regex, - selected, - exif, - query_eval, - query_function, - add_to_album, - debug, -): - """Query the Photos database using 1 or more search options; - if more than one option is provided, they are treated as "AND" - (e.g. search for photos matching all options). - """ - - global DEBUG - if debug: - DEBUG = True - osxphotos._set_debug(True) - - # if no query terms, show help and return - # sanity check input args - nonexclusive = [ - keyword, - person, - album, - folder, - name, - uuid, - uuid_from_file, - edited, - external_edit, - uti, - has_raw, - from_date, - to_date, - from_time, - to_time, - label, - is_reference, - query_eval, - query_function, - min_size, - max_size, - regex, - selected, - exif, - duplicate, - ] - exclusive = [ - (favorite, not_favorite), - (hidden, not_hidden), - (missing, not_missing), - (any(title), no_title), - (any(description), no_description), - (only_photos, only_movies), - (burst, not_burst), - (live, not_live), - (cloudasset, not_cloudasset), - (incloud, not_incloud), - (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), - (any(place), no_place), - (deleted, deleted_only), - (shared, not_shared), - (has_comment, no_comment), - (has_likes, no_likes), - (in_album, not_in_album), - (location, no_location), - ] - # print help if no non-exclusive term or a double exclusive term is given - if any(all(bb) for bb in exclusive) or not any( - nonexclusive + [b ^ n for b, n in exclusive] - ): - click.echo("Incompatible query options", err=True) - click.echo(cli.commands["query"].get_help(ctx), err=True) - return - - # actually have something to query - # default searches for everything - photos = True - movies = True - if only_movies: - photos = False - if only_photos: - movies = False - - # load UUIDs if necessary and append to any uuids passed with --uuid - if uuid_from_file: - uuid_list = list(uuid) # Click option is a tuple - uuid_list.extend(load_uuid_from_file(uuid_from_file)) - uuid = tuple(uuid_list) - - # below needed for to make CliRunner work for testing - cli_db = cli_obj.db if cli_obj is not None else None - db = get_photos_db(*photos_library, db, cli_db) - if db is None: - click.echo(cli.commands["query"].get_help(ctx), err=True) - click.echo("\n\nLocated the following Photos library databases: ", err=True) - _list_libraries() - return - - photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_) - query_options = QueryOptions( - keyword=keyword, - person=person, - album=album, - folder=folder, - uuid=uuid, - title=title, - no_title=no_title, - description=description, - no_description=no_description, - ignore_case=ignore_case, - edited=edited, - external_edit=external_edit, - favorite=favorite, - not_favorite=not_favorite, - hidden=hidden, - not_hidden=not_hidden, - missing=missing, - not_missing=not_missing, - shared=shared, - not_shared=not_shared, - photos=photos, - movies=movies, - uti=uti, - burst=burst, - not_burst=not_burst, - live=live, - not_live=not_live, - cloudasset=cloudasset, - not_cloudasset=not_cloudasset, - incloud=incloud, - not_incloud=not_incloud, - from_date=from_date, - to_date=to_date, - from_time=from_time, - to_time=to_time, - portrait=portrait, - not_portrait=not_portrait, - screenshot=screenshot, - not_screenshot=not_screenshot, - slow_mo=slow_mo, - not_slow_mo=not_slow_mo, - time_lapse=time_lapse, - not_time_lapse=not_time_lapse, - hdr=hdr, - not_hdr=not_hdr, - selfie=selfie, - not_selfie=not_selfie, - panorama=panorama, - not_panorama=not_panorama, - has_raw=has_raw, - place=place, - no_place=no_place, - location=location, - no_location=no_location, - label=label, - deleted=deleted, - deleted_only=deleted_only, - has_comment=has_comment, - no_comment=no_comment, - has_likes=has_likes, - no_likes=no_likes, - is_reference=is_reference, - in_album=in_album, - not_in_album=not_in_album, - name=name, - min_size=min_size, - max_size=max_size, - query_eval=query_eval, - function=query_function, - regex=regex, - selected=selected, - exif=exif, - duplicate=duplicate, - ) - - try: - photos = photosdb.query(query_options) - except ValueError as e: - if "Invalid query_eval CRITERIA:" in str(e): - msg = str(e).split(":")[1] - raise click.BadOptionUsage( - "query_eval", f"Invalid query-eval CRITERIA: {msg}" - ) - else: - raise ValueError(e) - - # below needed for to make CliRunner work for testing - cli_json = cli_obj.json if cli_obj is not None else None - - if add_to_album and photos: - album_query = PhotosAlbum(add_to_album, verbose=None) - photo_len = len(photos) - photo_word = "photos" if photo_len > 1 else "photo" - click.echo( - f"Adding {photo_len} {photo_word} to album '{album_query.name}'. Note: Photos may prompt you to confirm this action.", - err=True, - ) - try: - album_query.add_list(photos) - except Exception as e: - click.secho( - f"Error adding photos to album {add_to_album}: {e}", - fg=CLI_COLOR_ERROR, - err=True, - ) - - print_photo_info(photos, cli_json or json_) - - -def print_photo_info(photos, json=False): - dump = [] - if json: - for p in photos: - dump.append(p.json()) - click.echo(f"[{', '.join(dump)}]") - else: - # dump as CSV - csv_writer = csv.writer( - sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL - ) - # add headers - dump.append( - [ - "uuid", - "filename", - "original_filename", - "date", - "description", - "title", - "keywords", - "albums", - "persons", - "path", - "ismissing", - "hasadjustments", - "external_edit", - "favorite", - "hidden", - "shared", - "latitude", - "longitude", - "path_edited", - "isphoto", - "ismovie", - "uti", - "burst", - "live_photo", - "path_live_photo", - "iscloudasset", - "incloud", - "date_modified", - "portrait", - "screenshot", - "slow_mo", - "time_lapse", - "hdr", - "selfie", - "panorama", - "has_raw", - "uti_raw", - "path_raw", - "intrash", - ] - ) - for p in photos: - date_modified_iso = p.date_modified.isoformat() if p.date_modified else None - dump.append( - [ - p.uuid, - p.filename, - p.original_filename, - p.date.isoformat(), - p.description, - p.title, - ", ".join(p.keywords), - ", ".join(p.albums), - ", ".join(p.persons), - p.path, - p.ismissing, - p.hasadjustments, - p.external_edit, - p.favorite, - p.hidden, - p.shared, - p._latitude, - p._longitude, - p.path_edited, - p.isphoto, - p.ismovie, - p.uti, - p.burst, - p.live_photo, - p.path_live_photo, - p.iscloudasset, - p.incloud, - date_modified_iso, - p.portrait, - p.screenshot, - p.slow_mo, - p.time_lapse, - p.hdr, - p.selfie, - p.panorama, - p.has_raw, - p.uti_raw, - p.path_raw, - p.intrash, - ] - ) - for row in dump: - csv_writer.writerow(row) - - def export_photo( photo=None, dest=None, - verbose=None, + verbose_=None, export_by_date=None, sidecar=None, sidecar_drop_ext=False, @@ -2794,15 +1705,13 @@ def export_photo( touch_file: bool; sets file's modification time to match photo date update: bool, only export updated photos use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing - verbose: bool; print verbose output + verbose_: callable for verbose output Returns: list of path(s) of exported photo or None if photo was missing Raises: ValueError on invalid filename_template """ - global VERBOSE - VERBOSE = bool(verbose) export_original = not (skip_original_if_edited and photo.hasadjustments) @@ -2960,7 +1869,7 @@ def export_photo( update=update, use_photos_export=use_photos_export, use_photokit=use_photokit, - verbose=verbose, + verbose_=verbose_, ) if export_edited and photo.hasadjustments: @@ -3073,7 +1982,7 @@ def export_photo( update=update, use_photos_export=use_photos_export, use_photokit=use_photokit, - verbose=verbose, + verbose_=verbose_, ) return results @@ -3157,7 +2066,7 @@ def export_photo_to_directory( update, use_photos_export, use_photokit, - verbose, + verbose_, ): """Export photo to directory dest_path""" @@ -3252,7 +2161,7 @@ def export_photo_to_directory( f"Retrying export for photo ({photo.uuid}: {photo.original_filename})" ) except Exception as e: - if DEBUG: + if is_debug(): # if debug mode, don't swallow the exceptions raise e click.echo( @@ -3270,7 +2179,7 @@ def export_photo_to_directory( f"Retrying export for photo ({photo.uuid}: {photo.original_filename})" ) - if verbose: + if verbose_: if update or force_update: for new in results.new: verbose_(f"Exported new file {new}") @@ -3452,33 +2361,6 @@ def find_files_in_branch(pathname, filename): return files -def load_uuid_from_file(filename): - """Load UUIDs from file. Does not validate UUIDs. - Format is 1 UUID per line, any line beginning with # is ignored. - Whitespace is stripped. - - Arguments: - filename: file name of the file containing UUIDs - - Returns: - list of UUIDs or empty list of no UUIDs in file - - Raises: - FileNotFoundError if file does not exist - """ - - if not pathlib.Path(filename).is_file(): - raise FileNotFoundError(f"Could not find file {filename}") - - uuid = [] - with open(filename, "r") as uuid_file: - for line in uuid_file: - line = line.strip() - if len(line) and line[0] != "#": - uuid.append(line) - return uuid - - def write_export_report(report_file, results): """write CSV report with results from export @@ -3626,7 +2508,7 @@ def write_export_report(report_file, results): sys.exit(1) -def cleanup_files(dest_path, files_to_keep, fileutil): +def cleanup_files(dest_path, files_to_keep, fileutil, verbose_): """cleanup dest_path by deleting and files and empty directories not in files_to_keep @@ -3673,6 +2555,7 @@ def write_finder_tags( finder_tag_template=None, strip=False, export_dir=None, + verbose_=noop, ): """Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written @@ -3686,6 +2569,7 @@ def write_finder_tags( exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords finder_tag_template: list of templates to evaluate for determining Finder tags export_dir: value to use for {export_dir} template + verbose_: function to call to print verbose messages Returns: (list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating) @@ -3764,6 +2648,7 @@ def write_extended_attributes( xattr_template, strip=False, export_dir=None, + verbose_=noop, ): """Writes extended attributes to exported files @@ -3835,7 +2720,14 @@ def write_extended_attributes( def run_post_command( - photo, post_command, export_results, export_dir, dry_run, exiftool_path, export_db + photo, + post_command, + export_results, + export_dir, + dry_run, + exiftool_path, + export_db, + verbose_, ): # todo: pass in RenderOptions from export? (e.g. so it contains strip, etc?) # todo: need a shell_quote template type: @@ -3872,1237 +2764,3 @@ def run_post_command( ), err=True, ) - - -@cli.command() -@click.argument("packages", nargs=-1, required=True) -@click.option( - "-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version" -) -def install(packages, upgrade): - """Install Python packages into the same environment as osxphotos""" - args = ["pip", "install"] - if upgrade: - args += ["--upgrade"] - args += list(packages) - sys.argv = args - run_module("pip", run_name="__main__") - - -@cli.command() -@click.argument("packages", nargs=-1, required=True) -@click.option("-y", "--yes", is_flag=True, help="Don't ask for confirmation") -def uninstall(packages, yes): - """Uninstall Python packages from the osxphotos environment""" - sys.argv = ["pip", "uninstall"] + list(packages) + (["-y"] if yes else []) - run_module("pip", run_name="__main__") - - -@cli.command() -@DB_OPTION -@JSON_OPTION -@DB_ARGUMENT -@click.pass_obj -@click.pass_context -def keywords(ctx, cli_obj, db, json_, photos_library): - """Print out keywords found in the Photos library.""" - - # below needed for to make CliRunner work for testing - cli_db = cli_obj.db if cli_obj is not None else None - db = get_photos_db(*photos_library, db, cli_db) - if db is None: - click.echo(cli.commands["keywords"].get_help(ctx), err=True) - click.echo("\n\nLocated the following Photos library databases: ", err=True) - _list_libraries() - return - - photosdb = osxphotos.PhotosDB(dbfile=db) - keywords = {"keywords": photosdb.keywords_as_dict} - if json_ or cli_obj.json: - click.echo(json.dumps(keywords, ensure_ascii=False)) - else: - click.echo(yaml.dump(keywords, sort_keys=False, allow_unicode=True)) - - -@cli.command() -@DB_OPTION -@JSON_OPTION -@DB_ARGUMENT -@click.pass_obj -@click.pass_context -def albums(ctx, cli_obj, db, json_, photos_library): - """Print out albums found in the Photos library.""" - - # below needed for to make CliRunner work for testing - cli_db = cli_obj.db if cli_obj is not None else None - db = get_photos_db(*photos_library, db, cli_db) - if db is None: - click.echo(cli.commands["albums"].get_help(ctx), err=True) - click.echo("\n\nLocated the following Photos library databases: ", err=True) - _list_libraries() - return - - photosdb = osxphotos.PhotosDB(dbfile=db) - albums = {"albums": photosdb.albums_as_dict} - if photosdb.db_version > _PHOTOS_4_VERSION: - albums["shared albums"] = photosdb.albums_shared_as_dict - - if json_ or cli_obj.json: - click.echo(json.dumps(albums, ensure_ascii=False)) - else: - click.echo(yaml.dump(albums, sort_keys=False, allow_unicode=True)) - - -@cli.command() -@DB_OPTION -@JSON_OPTION -@DB_ARGUMENT -@click.pass_obj -@click.pass_context -def persons(ctx, cli_obj, db, json_, photos_library): - """Print out persons (faces) found in the Photos library.""" - - # below needed for to make CliRunner work for testing - cli_db = cli_obj.db if cli_obj is not None else None - db = get_photos_db(*photos_library, db, cli_db) - if db is None: - click.echo(cli.commands["persons"].get_help(ctx), err=True) - click.echo("\n\nLocated the following Photos library databases: ", err=True) - _list_libraries() - return - - photosdb = osxphotos.PhotosDB(dbfile=db) - persons = {"persons": photosdb.persons_as_dict} - if json_ or cli_obj.json: - click.echo(json.dumps(persons, ensure_ascii=False)) - else: - click.echo(yaml.dump(persons, sort_keys=False, allow_unicode=True)) - - -@cli.command() -@DB_OPTION -@JSON_OPTION -@DB_ARGUMENT -@click.pass_obj -@click.pass_context -def labels(ctx, cli_obj, db, json_, photos_library): - """Print out image classification labels found in the Photos library.""" - - # below needed for to make CliRunner work for testing - cli_db = cli_obj.db if cli_obj is not None else None - db = get_photos_db(*photos_library, db, cli_db) - if db is None: - click.echo(cli.commands["labels"].get_help(ctx), err=True) - click.echo("\n\nLocated the following Photos library databases: ", err=True) - _list_libraries() - return - - photosdb = osxphotos.PhotosDB(dbfile=db) - labels = {"labels": photosdb.labels_as_dict} - if json_ or cli_obj.json: - click.echo(json.dumps(labels, ensure_ascii=False)) - else: - click.echo(yaml.dump(labels, sort_keys=False, allow_unicode=True)) - - -@cli.command() -@DB_OPTION -@JSON_OPTION -@DB_ARGUMENT -@click.pass_obj -@click.pass_context -def info(ctx, cli_obj, db, json_, photos_library): - """Print out descriptive info of the Photos library database.""" - - db = get_photos_db(*photos_library, db, cli_obj.db) - if db is None: - click.echo(cli.commands["info"].get_help(ctx), err=True) - click.echo("\n\nLocated the following Photos library databases: ", err=True) - _list_libraries() - return - - photosdb = osxphotos.PhotosDB(dbfile=db) - info = {"database_path": photosdb.db_path, "database_version": photosdb.db_version} - photos = photosdb.photos(movies=False) - not_shared_photos = [p for p in photos if not p.shared] - info["photo_count"] = len(not_shared_photos) - - hidden = [p for p in photos if p.hidden] - info["hidden_photo_count"] = len(hidden) - - movies = photosdb.photos(images=False, movies=True) - not_shared_movies = [p for p in movies if not p.shared] - info["movie_count"] = len(not_shared_movies) - - if photosdb.db_version > _PHOTOS_4_VERSION: - shared_photos = [p for p in photos if p.shared] - info["shared_photo_count"] = len(shared_photos) - - shared_movies = [p for p in movies if p.shared] - info["shared_movie_count"] = len(shared_movies) - - keywords = photosdb.keywords_as_dict - info["keywords_count"] = len(keywords) - info["keywords"] = keywords - - albums = photosdb.albums_as_dict - info["albums_count"] = len(albums) - info["albums"] = albums - - if photosdb.db_version > _PHOTOS_4_VERSION: - albums_shared = photosdb.albums_shared_as_dict - info["shared_albums_count"] = len(albums_shared) - info["shared_albums"] = albums_shared - - persons = photosdb.persons_as_dict - - info["persons_count"] = len(persons) - info["persons"] = persons - - if cli_obj.json or json_: - click.echo(json.dumps(info, ensure_ascii=False)) - else: - click.echo(yaml.dump(info, sort_keys=False, allow_unicode=True)) - - -@cli.command() -@DB_OPTION -@JSON_OPTION -@DB_ARGUMENT -@click.pass_obj -@click.pass_context -def places(ctx, cli_obj, db, json_, photos_library): - """Print out places found in the Photos library.""" - - # below needed for to make CliRunner work for testing - cli_db = cli_obj.db if cli_obj is not None else None - db = get_photos_db(*photos_library, db, cli_db) - if db is None: - click.echo(cli.commands["places"].get_help(ctx), err=True) - click.echo("\n\nLocated the following Photos library databases: ", err=True) - _list_libraries() - return - - photosdb = osxphotos.PhotosDB(dbfile=db) - place_names = {} - for photo in photosdb.photos(movies=True): - if photo.place: - try: - place_names[photo.place.name] += 1 - except Exception: - place_names[photo.place.name] = 1 - else: - try: - place_names[_UNKNOWN_PLACE] += 1 - except Exception: - place_names[_UNKNOWN_PLACE] = 1 - - # sort by place count - places = { - "places": { - name: place_names[name] - for name in sorted( - place_names.keys(), key=lambda key: place_names[key], reverse=True - ) - } - } - - # below needed for to make CliRunner work for testing - cli_json = cli_obj.json if cli_obj is not None else None - if json_ or cli_json: - click.echo(json.dumps(places, ensure_ascii=False)) - else: - click.echo(yaml.dump(places, sort_keys=False, allow_unicode=True)) - - -@cli.command() -@DB_OPTION -@JSON_OPTION -@DELETED_OPTIONS -@DB_ARGUMENT -@click.pass_obj -@click.pass_context -def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library): - """Print list of all photos & associated info from the Photos library.""" - - db = get_photos_db(*photos_library, db, cli_obj.db) - if db is None: - click.echo(cli.commands["dump"].get_help(ctx), err=True) - click.echo("\n\nLocated the following Photos library databases: ", err=True) - _list_libraries() - return - - # check exclusive options - if deleted and deleted_only: - click.echo("Incompatible dump options", err=True) - click.echo(cli.commands["dump"].get_help(ctx), err=True) - return - - photosdb = osxphotos.PhotosDB(dbfile=db) - if deleted or deleted_only: - photos = photosdb.photos(movies=True, intrash=True) - else: - photos = [] - if not deleted_only: - photos += photosdb.photos(movies=True) - - print_photo_info(photos, json_ or cli_obj.json) - - -@cli.command(name="list") -@JSON_OPTION -@click.pass_obj -@click.pass_context -def list_libraries(ctx, cli_obj, json_): - """Print list of Photos libraries found on the system.""" - - # implemented in _list_libraries so it can be called by other CLI functions - # without errors due to passing ctx and cli_obj - _list_libraries(json_=json_ or cli_obj.json, error=False) - - -def _list_libraries(json_=False, error=True): - """Print list of Photos libraries found on the system. - If json_ == True, print output as JSON (default = False)""" - - photo_libs = osxphotos.utils.list_photo_libraries() - sys_lib = osxphotos.utils.get_system_library_path() - last_lib = osxphotos.utils.get_last_library_path() - - if json_: - libs = { - "photo_libraries": photo_libs, - "system_library": sys_lib, - "last_library": last_lib, - } - click.echo(json.dumps(libs, ensure_ascii=False)) - else: - last_lib_flag = sys_lib_flag = False - - for lib in photo_libs: - if lib == sys_lib: - click.echo(f"(*)\t{lib}", err=error) - sys_lib_flag = True - elif lib == last_lib: - click.echo(f"(#)\t{lib}", err=error) - last_lib_flag = True - else: - click.echo(f"\t{lib}", err=error) - - if sys_lib_flag or last_lib_flag: - click.echo("\n", err=error) - if sys_lib_flag: - click.echo("(*)\tSystem Photos Library", err=error) - if last_lib_flag: - click.echo("(#)\tLast opened Photos Library", err=error) - - -@cli.command(name="uuid") -@click.pass_obj -@click.pass_context -@click.option( - "--filename", - "-f", - required=False, - is_flag=True, - default=False, - help="Include filename of selected photos in output", -) -def uuid(ctx, cli_obj, filename): - """Print out unique IDs (UUID) of photos selected in Photos - - Prints outs UUIDs in form suitable for --uuid-from-file and --skip-uuid-from-file - """ - for photo in photoscript.PhotosLibrary().selection: - if filename: - print(f"# {photo.filename}") - print(photo.uuid) - - -@cli.command(name="about") -@click.pass_obj -@click.pass_context -def about(ctx, cli_obj): - """Print information about osxphotos including license.""" - license = """ -MIT License - -Copyright (c) 2019-2021 Rhet Turnbull - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -osxphotos uses the following 3rd party software licensed under the BSD-3-Clause License: -Click (Copyright 2014 Pallets), ptpython (Copyright (c) 2015, Jonathan Slenders) - -Redistribution and use in source and binary forms, with or without modification, are -permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this list -of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, this list -of conditions and the following disclaimer in the documentation and/or other materials -provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors may be -used to endorse or promote products derived from this software without specific prior -written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR -IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER -OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, -WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY -OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -""" - click.echo(f"osxphotos, version {__version__}") - click.echo("") - click.echo(f"Source code available at: {OSXPHOTOS_URL}") - click.echo(license) - - -@cli.command(name="tutorial") -@click.argument( - "WIDTH", - nargs=-1, - type=click.INT, -) -@click.pass_obj -@click.pass_context -def tutorial(ctx, cli_obj, width): - """Display osxphotos tutorial.""" - width = width[0] if width else 100 - click.echo_via_pager(tutorial_help(width=width)) - - -def _show_photo(photo: PhotoInfo): - """open image with default image viewer - - Note: This is for debugging only -- it will actually open any filetype which could - be very, very bad. - - Args: - photo: PhotoInfo object or a path to a photo on disk - """ - photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo - - if not os.path.isfile(photopath): - return f"'{photopath}' does not appear to be a valid photo path" - - os.system(f"open '{photopath}'") - - -def _load_photos_db(dbpath): - print("Loading database") - tic = time.perf_counter() - photosdb = osxphotos.PhotosDB(dbfile=dbpath, verbose=print) - toc = time.perf_counter() - tictoc = toc - tic - print(f"Done: took {tictoc:0.2f} seconds") - return photosdb - - -def _get_all_photos(photosdb): - """get list of all photos in photosdb""" - photos = photosdb.photos(images=True, movies=True) - photos.extend(photosdb.photos(images=True, movies=True, intrash=True)) - return photos - - -def _get_selected(photosdb): - """get list of PhotoInfo objects for photos selected in Photos""" - - def get_selected(): - selected = photoscript.PhotosLibrary().selection - if not selected: - return [] - return photosdb.photos(uuid=[p.uuid for p in selected]) - - return get_selected - - -def _spotlight_photo(photo: PhotoInfo): - photo_ = photoscript.Photo(photo.uuid) - photo_.spotlight() - - -@cli.command(name="repl") -@DB_OPTION -@click.pass_obj -@click.pass_context -@click.option( - "--emacs", - required=False, - is_flag=True, - default=False, - help="Launch REPL with Emacs keybindings (default is vi bindings)", -) -@click.option( - "--beta", - is_flag=True, - default=False, - hidden=True, - help="Enable beta options.", -) -@QUERY_OPTIONS -@DELETED_OPTIONS -@click.option("--missing", is_flag=True, help="Search for photos missing from disk.") -@click.option( - "--not-missing", - is_flag=True, - help="Search for photos present on disk (e.g. not missing).", -) -@click.option( - "--cloudasset", - is_flag=True, - help="Search for photos that are part of an iCloud library", -) -@click.option( - "--not-cloudasset", - is_flag=True, - help="Search for photos that are not part of an iCloud library", -) -@click.option( - "--incloud", - is_flag=True, - help="Search for photos that are in iCloud (have been synched)", -) -@click.option( - "--not-incloud", - is_flag=True, - help="Search for photos that are not in iCloud (have not been synched)", -) -def repl(ctx, cli_obj, db, emacs, beta, **kwargs): - """Run interactive osxphotos REPL shell (useful for debugging, prototyping, and inspecting your Photos library)""" - import logging - - from objexplore import explore - from photoscript import Album, Photo, PhotosLibrary - from rich import inspect as _inspect - - from osxphotos import ExifTool, PhotoInfo, PhotosDB - from osxphotos.albuminfo import AlbumInfo - from osxphotos.momentinfo import MomentInfo - from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter - from osxphotos.placeinfo import PlaceInfo - from osxphotos.queryoptions import QueryOptions - from osxphotos.scoreinfo import ScoreInfo - from osxphotos.searchinfo import SearchInfo - - logger = logging.getLogger() - logger.disabled = True - - pretty.install() - print(f"python version: {sys.version}") - print(f"osxphotos version: {osxphotos._version.__version__}") - db = db or get_photos_db() - photosdb = _load_photos_db(db) - # enable beta features if requested - if beta: - photosdb._beta = beta - print("Beta mode enabled") - print("Getting photos") - tic = time.perf_counter() - try: - query_options = _query_options_from_kwargs(**kwargs) - except IncompatibleQueryOptions: - click.echo("Incompatible query options", err=True) - click.echo(cli.commands["repl"].get_help(ctx), err=True) - sys.exit(1) - photos = _query_photos(photosdb, query_options) - all_photos = _get_all_photos(photosdb) - toc = time.perf_counter() - tictoc = toc - tic - - # shortcut for helper functions - get_photo = photosdb.get_photo - show = _show_photo - spotlight = _spotlight_photo - get_selected = _get_selected(photosdb) - try: - selected = get_selected() - except Exception: - # get_selected sometimes fails - selected = [] - - def inspect(obj): - """inspect object""" - return _inspect(obj, methods=True) - - print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds\n") - print("The following classes have been imported from osxphotos:") - print( - "- AlbumInfo, ExifTool, PhotoInfo, PhotoExporter, ExportOptions, ExportResults, PhotosDB, PlaceInfo, QueryOptions, MomentInfo, ScoreInfo, SearchInfo\n" - ) - print("The following variables are defined:") - print(f"- photosdb: PhotosDB() instance for {photosdb.library_path}") - print( - f"- photos: list of PhotoInfo objects for all photos filtered with any query options passed on command line (len={len(photos)})" - ) - print( - f"- all_photos: list of PhotoInfo objects for all photos in photosdb, including those in the trash (len={len(all_photos)})" - ) - print( - f"- selected: list of PhotoInfo objects for any photos selected in Photos (len={len(selected)})" - ) - print(f"\nThe following functions may be helpful:") - print( - f"- get_photo(uuid): return a PhotoInfo object for photo with uuid; e.g. get_photo('B13F4485-94E0-41CD-AF71-913095D62E31')" - ) - print( - f"- get_selected(); return list of PhotoInfo objects for photos selected in Photos" - ) - print( - f"- show(photo): open a photo object in the default viewer; e.g. show(selected[0])" - ) - print( - f"- show(path): open a file at path in the default viewer; e.g. show('/path/to/photo.jpg')" - ) - print(f"- spotlight(photo): open a photo and spotlight it in Photos") - # print( - # f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)" - # ) - print( - f"- inspect(object): print information about an object; e.g. inspect(PhotoInfo)" - ) - print( - f"- explore(object): interactively explore an object with objexplore; e.g. explore(PhotoInfo)" - ) - print(f"- q, quit, quit(), exit, exit(): exit this interactive shell\n") - - embed_repl( - globals=globals(), - locals=locals(), - history_filename=str(pathlib.Path.home() / ".osxphotos_repl_history"), - quit_words=["q", "quit", "exit"], - vi_mode=not emacs, - ) - - -@cli.command(name="grep", hidden=OSXPHOTOS_HIDDEN) -@DB_OPTION -@click.pass_obj -@click.pass_context -@click.option( - "--ignore-case", - "-i", - required=False, - is_flag=True, - default=False, - help="Ignore case when searching (default is case-sensitive).", -) -@click.option( - "--print-filename", - "-p", - required=False, - is_flag=True, - default=False, - help="Print name of database file when printing results.", -) -@click.argument("pattern", metavar="PATTERN", required=True) -def grep(ctx, cli_obj, db, ignore_case, print_filename, pattern): - """Search for PATTERN in the Photos sqlite database file""" - db = db or get_photos_db() - db = pathlib.Path(db) - if db.is_file(): - # if passed the actual database, really want the parent of the database directory - db = db.parent.parent - photos_ver = get_photos_library_version(str(db)) - if photos_ver < 5: - db_file = db / "database" / "photos.db" - else: - db_file = db / "database" / "Photos.sqlite" - - if not db_file.is_file(): - click.secho(f"Could not find database file {db_file}", fg="red") - ctx.exit(2) - - db_file = str(db_file) - - for table, column, row_id, value in sqlgrep( - db_file, pattern, ignore_case, print_filename, rich_markup=True - ): - print(", ".join([table, column, row_id, value])) - - -@cli.command(hidden=OSXPHOTOS_HIDDEN) -@DB_OPTION -@DB_ARGUMENT -@click.option( - "--dump", - metavar="ATTR", - help="Name of PhotosDB attribute to print; " - + "can also use albums, persons, keywords, photos to dump related attributes.", - multiple=True, -) -@click.option( - "--uuid", - metavar="UUID", - help="Use with '--dump photos' to dump only certain UUIDs. " - "May be repeated to include multiple UUIDs.", - multiple=True, -) -@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): - """Print out debug info""" - - global VERBOSE - VERBOSE = bool(verbose) - - db = get_photos_db(*photos_library, db, cli_obj.db) - if db is None: - click.echo(cli.commands["debug-dump"].get_help(ctx), err=True) - click.echo("\n\nLocated the following Photos library databases: ", err=True) - _list_libraries() - return - - start_t = time.perf_counter() - print(f"Opening database: {db}") - photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_) - stop_t = time.perf_counter() - print(f"Done; took {(stop_t-start_t):.2f} seconds") - - for attr in dump: - if attr == "albums": - print("_dbalbums_album:") - pprint.pprint(photosdb._dbalbums_album) - print("_dbalbums_uuid:") - pprint.pprint(photosdb._dbalbums_uuid) - print("_dbalbum_details:") - pprint.pprint(photosdb._dbalbum_details) - print("_dbalbum_folders:") - pprint.pprint(photosdb._dbalbum_folders) - print("_dbfolder_details:") - pprint.pprint(photosdb._dbfolder_details) - elif attr == "keywords": - print("_dbkeywords_keyword:") - pprint.pprint(photosdb._dbkeywords_keyword) - print("_dbkeywords_uuid:") - pprint.pprint(photosdb._dbkeywords_uuid) - elif attr == "persons": - print("_dbfaces_uuid:") - pprint.pprint(photosdb._dbfaces_uuid) - print("_dbfaces_pk:") - pprint.pprint(photosdb._dbfaces_pk) - print("_dbpersons_pk:") - pprint.pprint(photosdb._dbpersons_pk) - print("_dbpersons_fullname:") - pprint.pprint(photosdb._dbpersons_fullname) - elif attr == "photos": - if uuid: - for uuid_ in uuid: - print(f"_dbphotos['{uuid_}']:") - try: - pprint.pprint(photosdb._dbphotos[uuid_]) - except KeyError: - print(f"Did not find uuid {uuid_} in _dbphotos") - else: - print("_dbphotos:") - pprint.pprint(photosdb._dbphotos) - else: - try: - val = getattr(photosdb, attr) - print(f"{attr}:") - pprint.pprint(val) - except Exception: - print(f"Did not find attribute {attr} in PhotosDB") - - -@cli.command(name="snap") -@click.pass_obj -@click.pass_context -@DB_OPTION -def snap(ctx, cli_obj, db): - """Create snapshot of Photos database to use with diff command - - Snapshots only the database files, not the entire library. If OSXPHOTOS_SNAPSHOT - environment variable is defined, will use that as snapshot directory, otherwise - uses '/private/tmp/osxphotos_snapshots' - - Works only on Photos library versions since Catalina (10.15) or newer. - """ - - db = get_photos_db(db, cli_obj.db) - db_path = pathlib.Path(db) - if db_path.is_file(): - # assume it's the sqlite file - db_path = db_path.parent.parent - db_path = db_path / "database" - - db_folder = os.environ.get("OSXPHOTOS_SNAPSHOT", OSXPHOTOS_SNAPSHOT_DIR) - if not os.path.isdir(db_folder): - click.echo(f"Creating snapshot folder: '{db_folder}'") - os.mkdir(db_folder) - - timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - destination_path = pathlib.Path(db_folder) / timestamp - - # get all the sqlite files including the write ahead log if any - files = db_path.glob("*.sqlite*") - os.makedirs(destination_path) - fu = osxphotos.fileutil.FileUtil() - count = 0 - for file in files: - if file.is_file(): - fu.copy(file, destination_path) - count += 1 - - print(f"Copied {count} files from {db_path} to {destination_path}") - - -@cli.command(name="diff") -@click.pass_obj -@click.pass_context -@DB_OPTION -@click.option( - "--raw-output", - "-r", - is_flag=True, - default=False, - help="Print raw output (don't use syntax highlighting).", -) -@click.option( - "--style", - "-s", - metavar="STYLE", - nargs=1, - default="monokai", - help="Specify style/theme for syntax highlighting. " - "Theme may be any valid pygments style (https://pygments.org/styles/). " - "Default is 'monokai'.", -) -@click.argument("db2", nargs=-1, type=click.Path(exists=True)) -@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.") -def diff(ctx, cli_obj, db, raw_output, style, db2, verbose): - """Compare two Photos databases and print out differences - - To use the diff command, you'll need to install sqldiff via homebrew: - - - Install homebrew (https://brew.sh/) if not already installed - - - Install sqldiff: `brew install sqldiff` - - When run with no arguments, compares the current Photos library to the - most recent snapshot in the the OSXPHOTOS_SNAPSHOT directory. - - If run with the --db option, compares the library specified by --db to the - most recent snapshot in the the OSXPHOTOS_SNAPSHOT directory. - - If run with just the DB2 argument, compares the current Photos library to - the database specified by the DB2 argument. - - If run with both the --db option and the DB2 argument, compares the - library specified by --db to the database specified by DB2 - - See also `osxphotos snap` - - If the OSXPHOTOS_SNAPSHOT environment variable is not set, will use - '/private/tmp/osxphotos_snapshots' - - Works only on Photos library versions since Catalina (10.15) or newer. - """ - - global VERBOSE - VERBOSE = bool(verbose) - - sqldiff = shutil.which("sqldiff") - if not sqldiff: - click.echo( - "sqldiff not found; install via homebrew (https://brew.sh/): `brew install sqldiff`" - ) - ctx.exit(2) - verbose_(f"sqldiff found at '{sqldiff}'") - - db = get_photos_db(db, cli_obj.db) - db_path = pathlib.Path(db) - if db_path.is_file(): - # assume it's the sqlite file - db_path = db_path.parent.parent - db_path = db_path / "database" - db_1 = db_path / "photos.sqlite" - - if db2: - db_2 = pathlib.Path(db2[0]) - else: - # get most recent snapshot - db_folder = os.environ.get("OSXPHOTOS_SNAPSHOT", OSXPHOTOS_SNAPSHOT_DIR) - verbose_(f"Using snapshot folder: '{db_folder}'") - folders = sorted([f for f in pathlib.Path(db_folder).glob("*") if f.is_dir()]) - folder_2 = folders[-1] - db_2 = folder_2 / "Photos.sqlite" - - if not db_1.exists(): - print(f"database file {db_1} missing") - if not db_2.exists(): - print(f"database file {db_2} missing") - - verbose_(f"Comparing databases {db_1} and {db_2}") - - diff_proc = subprocess.Popen([sqldiff, db_2, db_1], stdout=subprocess.PIPE) - console = Console() - for line in iter(diff_proc.stdout.readline, b""): - line = line.decode("UTF-8").rstrip() - if raw_output: - print(line) - else: - syntax = Syntax( - line, "sql", theme=style, line_numbers=False, code_width=1000 - ) - console.print(syntax) - - -@cli.command(name="run") -@click.argument("python_file", nargs=1, type=click.Path(exists=True)) -def run(python_file): - """Run a python file using same environment as osxphotos""" - run_path(python_file, run_name="__main__") - - -@cli.command(name="exportdb", hidden=OSXPHOTOS_HIDDEN) -@click.option("--version", is_flag=True, help="Print export database version and exit.") -@click.option("--vacuum", is_flag=True, help="Run VACUUM to defragment the database.") -@click.option( - "--check-signatures", - is_flag=True, - help="Check signatures for all exported photos in the database to find signatures that don't match.", -) -@click.option( - "--update-signatures", - is_flag=True, - help="Update signatures for all exported photos in the database to match on-disk signatures.", -) -@click.option( - "--touch-file", - is_flag=True, - help="Touch files on disk to match created date in Photos library and update export database signatures", -) -@click.option( - "--last-run", - is_flag=True, - help="Show last run osxphotos commands used with this database.", -) -@click.option( - "--save-config", - metavar="CONFIG_FILE", - help="Save last run configuration to TOML file for use by --load-config.", -) -@click.option( - "--info", - metavar="FILE_PATH", - nargs=1, - help="Print information about FILE_PATH contained in the database.", -) -@click.option( - "--migrate", - is_flag=True, - help="Migrate (if needed) export database to current version.", -) -@click.option( - "--sql", - metavar="SQL_STATEMENT", - help="Execute SQL_STATEMENT against export database and print results.", -) -@click.option( - "--export-dir", - help="Optional path to export directory (if not parent of export database).", - type=click.Path(exists=True, file_okay=False, dir_okay=True), -) -@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.") -@click.option( - "--dry-run", - is_flag=True, - help="Run in dry-run mode (don't actually update files), e.g. for use with --update-signatures.", -) -@click.argument("export_db", metavar="EXPORT_DATABASE", type=click.Path(exists=True)) -def exportdb( - version, - vacuum, - check_signatures, - update_signatures, - touch_file, - last_run, - save_config, - info, - migrate, - sql, - export_dir, - verbose, - dry_run, - export_db, -): - """Utilities for working with the osxphotos export database""" - export_db = pathlib.Path(export_db) - if export_db.is_dir(): - # assume it's the export folder - export_db = export_db / OSXPHOTOS_EXPORT_DB - if not export_db.is_file(): - print( - f"[red]Error: {OSXPHOTOS_EXPORT_DB} missing from {export_db.parent}[/red]" - ) - sys.exit(1) - - export_dir = export_dir or export_db.parent - - sub_commands = [ - version, - check_signatures, - update_signatures, - touch_file, - last_run, - bool(save_config), - bool(info), - migrate, - bool(sql), - ] - if sum(sub_commands) > 1: - print(f"[red]Only a single sub-command may be specified at a time[/red]") - sys.exit(1) - - if version: - try: - osxphotos_ver, export_db_ver = export_db_get_version(export_db) - except Exception as e: - print(f"[red]Error: could not read version from {export_db}: {e}[/red]") - sys.exit(1) - else: - print( - f"osxphotos version: {osxphotos_ver}, export database version: {export_db_ver}" - ) - sys.exit(0) - - if vacuum: - try: - start_size = pathlib.Path(export_db).stat().st_size - export_db_vacuum(export_db) - except Exception as e: - print(f"[red]Error: {e}[/red]") - sys.exit(1) - else: - print( - f"Vacuumed {export_db}! {start_size} bytes -> {pathlib.Path(export_db).stat().st_size} bytes" - ) - sys.exit(0) - - if update_signatures: - try: - updated, skipped = export_db_update_signatures( - export_db, export_dir, verbose, dry_run - ) - except Exception as e: - print(f"[red]Error: {e}[/red]") - sys.exit(1) - else: - print(f"Done. Updated {updated} files, skipped {skipped} files.") - sys.exit(0) - - if last_run: - try: - last_run_info = export_db_get_last_run(export_db) - except Exception as e: - print(f"[red]Error: {e}[/red]") - sys.exit(1) - else: - print(f"last run at {last_run_info[0]}:") - print(f"osxphotos {last_run_info[1]}") - sys.exit(0) - - if save_config: - try: - export_db_save_config_to_file(export_db, save_config) - except Exception as e: - print(f"[red]Error: {e}[/red]") - sys.exit(1) - else: - print(f"Saved configuration to {save_config}") - sys.exit(0) - - if check_signatures: - try: - matched, notmatched, skipped = export_db_check_signatures( - export_db, export_dir, verbose=verbose - ) - except Exception as e: - print(f"[red]Error: {e}[/red]") - sys.exit(1) - else: - print( - f"Done. Found {matched} matching signatures and {notmatched} signatures that don't match. Skipped {skipped} missing files." - ) - sys.exit(0) - - if touch_file: - try: - touched, not_touched, skipped = export_db_touch_files( - export_db, export_dir, verbose=verbose, dry_run=dry_run - ) - except Exception as e: - print(f"[red]Error: {e}[/red]") - sys.exit(1) - else: - print( - f"Done. Touched {touched} files, skipped {not_touched} up to date files, skipped {skipped} missing files." - ) - sys.exit(0) - - if info: - exportdb = ExportDB(export_db, export_dir) - try: - info_rec = exportdb.get_file_record(info) - except Exception as e: - print(f"[red]Error: {e}[/red]") - sys.exit(1) - else: - if info_rec: - print(info_rec.asdict()) - else: - print(f"[red]File '{info}' not found in export database[/red]") - sys.exit(0) - - if migrate: - exportdb = ExportDB(export_db, export_dir) - upgraded = exportdb.was_upgraded - if upgraded: - print( - f"Migrated export database {export_db} from version {upgraded[0]} to {upgraded[1]}" - ) - else: - print( - f"Export database {export_db} is already at latest version {OSXPHOTOS_EXPORTDB_VERSION}" - ) - sys.exit(0) - - if sql: - exportdb = ExportDB(export_db, export_dir) - try: - c = exportdb._conn.cursor() - results = c.execute(sql) - except Exception as e: - print(f"[red]Error: {e}[/red]") - sys.exit(1) - else: - for row in results: - print(row) - sys.exit(0) - - -def _query_options_from_kwargs(**kwargs) -> QueryOptions: - """Validate query options and create a QueryOptions instance""" - # sanity check input args - nonexclusive = [ - "keyword", - "person", - "album", - "folder", - "name", - "uuid", - "uuid_from_file", - "edited", - "external_edit", - "uti", - "has_raw", - "from_date", - "to_date", - "from_time", - "to_time", - "label", - "is_reference", - "query_eval", - "query_function", - "min_size", - "max_size", - "regex", - "selected", - "exif", - "duplicate", - ] - exclusive = [ - ("favorite", "not_favorite"), - ("hidden", "not_hidden"), - ("missing", "not_missing"), - ("only_photos", "only_movies"), - ("burst", "not_burst"), - ("live", "not_live"), - ("cloudasset", "not_cloudasset"), - ("incloud", "not_incloud"), - ("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"), - ("deleted", "deleted_only"), - ("shared", "not_shared"), - ("has_comment", "no_comment"), - ("has_likes", "no_likes"), - ("in_album", "not_in_album"), - ("location", "no_location"), - ] - # print help if no non-exclusive term or a double exclusive term is given - # TODO: add option to validate requiring at least one query arg - if any(all([kwargs[b], kwargs[n]]) for b, n in exclusive) or any( - [ - all([any(kwargs["title"]), kwargs["no_title"]]), - all([any(kwargs["description"]), kwargs["no_description"]]), - all([any(kwargs["place"]), kwargs["no_place"]]), - ] - ): - raise IncompatibleQueryOptions - - # actually have something to query - include_photos = True - include_movies = True # default searches for everything - if kwargs["only_movies"]: - include_photos = False - if kwargs["only_photos"]: - include_movies = False - - # load UUIDs if necessary and append to any uuids passed with --uuid - uuid = None - if kwargs["uuid_from_file"]: - uuid_list = list(kwargs["uuid"]) # Click option is a tuple - uuid_list.extend(load_uuid_from_file(kwargs["uuid_from_file"])) - uuid = tuple(uuid_list) - - query_fields = [field.name for field in dataclasses.fields(QueryOptions)] - query_dict = {field: kwargs.get(field) for field in query_fields} - query_dict["photos"] = include_photos - query_dict["movies"] = include_movies - query_dict["uuid"] = uuid - return QueryOptions(**query_dict) - - -def _query_photos(photosdb: PhotosDB, query_options: QueryOptions) -> List: - """Query photos given a QueryOptions instance""" - try: - photos = photosdb.query(query_options) - except ValueError as e: - if "Invalid query_eval CRITERIA:" in str(e): - msg = str(e).split(":")[1] - raise click.BadOptionUsage( - "query_eval", f"Invalid query-eval CRITERIA: {msg}" - ) - else: - raise ValueError(e) - - return photos diff --git a/osxphotos/cli/exportdb.py b/osxphotos/cli/exportdb.py new file mode 100644 index 00000000..5756243f --- /dev/null +++ b/osxphotos/cli/exportdb.py @@ -0,0 +1,251 @@ +"""exportdb command for osxphotos CLI""" + +import pathlib +import sys + +import click +from rich import print + +from osxphotos._constants import OSXPHOTOS_EXPORT_DB +from osxphotos._version import __version__ +from osxphotos.export_db import OSXPHOTOS_EXPORTDB_VERSION, ExportDB +from osxphotos.export_db_utils import ( + export_db_check_signatures, + export_db_get_last_run, + export_db_get_version, + export_db_save_config_to_file, + export_db_touch_files, + export_db_update_signatures, + export_db_vacuum, +) + +from .common import OSXPHOTOS_HIDDEN, verbose_print + + +@click.command(name="exportdb", hidden=OSXPHOTOS_HIDDEN) +@click.option("--version", is_flag=True, help="Print export database version and exit.") +@click.option("--vacuum", is_flag=True, help="Run VACUUM to defragment the database.") +@click.option( + "--check-signatures", + is_flag=True, + help="Check signatures for all exported photos in the database to find signatures that don't match.", +) +@click.option( + "--update-signatures", + is_flag=True, + help="Update signatures for all exported photos in the database to match on-disk signatures.", +) +@click.option( + "--touch-file", + is_flag=True, + help="Touch files on disk to match created date in Photos library and update export database signatures", +) +@click.option( + "--last-run", + is_flag=True, + help="Show last run osxphotos commands used with this database.", +) +@click.option( + "--save-config", + metavar="CONFIG_FILE", + help="Save last run configuration to TOML file for use by --load-config.", +) +@click.option( + "--info", + metavar="FILE_PATH", + nargs=1, + help="Print information about FILE_PATH contained in the database.", +) +@click.option( + "--migrate", + is_flag=True, + help="Migrate (if needed) export database to current version.", +) +@click.option( + "--sql", + metavar="SQL_STATEMENT", + help="Execute SQL_STATEMENT against export database and print results.", +) +@click.option( + "--export-dir", + help="Optional path to export directory (if not parent of export database).", + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.") +@click.option( + "--dry-run", + is_flag=True, + help="Run in dry-run mode (don't actually update files), e.g. for use with --update-signatures.", +) +@click.argument("export_db", metavar="EXPORT_DATABASE", type=click.Path(exists=True)) +def exportdb( + version, + vacuum, + check_signatures, + update_signatures, + touch_file, + last_run, + save_config, + info, + migrate, + sql, + export_dir, + verbose, + dry_run, + export_db, +): + """Utilities for working with the osxphotos export database""" + + verbose_ = verbose_print(verbose, rich=True) + + export_db = pathlib.Path(export_db) + if export_db.is_dir(): + # assume it's the export folder + export_db = export_db / OSXPHOTOS_EXPORT_DB + if not export_db.is_file(): + print( + f"[red]Error: {OSXPHOTOS_EXPORT_DB} missing from {export_db.parent}[/red]" + ) + sys.exit(1) + + export_dir = export_dir or export_db.parent + + sub_commands = [ + version, + check_signatures, + update_signatures, + touch_file, + last_run, + bool(save_config), + bool(info), + migrate, + bool(sql), + ] + if sum(sub_commands) > 1: + print("[red]Only a single sub-command may be specified at a time[/red]") + sys.exit(1) + + if version: + try: + osxphotos_ver, export_db_ver = export_db_get_version(export_db) + except Exception as e: + print(f"[red]Error: could not read version from {export_db}: {e}[/red]") + sys.exit(1) + else: + print( + f"osxphotos version: {osxphotos_ver}, export database version: {export_db_ver}" + ) + sys.exit(0) + + if vacuum: + try: + start_size = pathlib.Path(export_db).stat().st_size + export_db_vacuum(export_db) + except Exception as e: + print(f"[red]Error: {e}[/red]") + sys.exit(1) + else: + print( + f"Vacuumed {export_db}! {start_size} bytes -> {pathlib.Path(export_db).stat().st_size} bytes" + ) + sys.exit(0) + + if update_signatures: + try: + updated, skipped = export_db_update_signatures( + export_db, export_dir, verbose_, dry_run + ) + except Exception as e: + print(f"[red]Error: {e}[/red]") + sys.exit(1) + else: + print(f"Done. Updated {updated} files, skipped {skipped} files.") + sys.exit(0) + + if last_run: + try: + last_run_info = export_db_get_last_run(export_db) + except Exception as e: + print(f"[red]Error: {e}[/red]") + sys.exit(1) + else: + print(f"last run at {last_run_info[0]}:") + print(f"osxphotos {last_run_info[1]}") + sys.exit(0) + + if save_config: + try: + export_db_save_config_to_file(export_db, save_config) + except Exception as e: + print(f"[red]Error: {e}[/red]") + sys.exit(1) + else: + print(f"Saved configuration to {save_config}") + sys.exit(0) + + if check_signatures: + try: + matched, notmatched, skipped = export_db_check_signatures( + export_db, export_dir, verbose_=verbose_ + ) + except Exception as e: + print(f"[red]Error: {e}[/red]") + sys.exit(1) + else: + print( + f"Done. Found {matched} matching signatures and {notmatched} signatures that don't match. Skipped {skipped} missing files." + ) + sys.exit(0) + + if touch_file: + try: + touched, not_touched, skipped = export_db_touch_files( + export_db, export_dir, verbose_=verbose_, dry_run=dry_run + ) + except Exception as e: + print(f"[red]Error: {e}[/red]") + sys.exit(1) + else: + print( + f"Done. Touched {touched} files, skipped {not_touched} up to date files, skipped {skipped} missing files." + ) + sys.exit(0) + + if info: + exportdb = ExportDB(export_db, export_dir) + try: + info_rec = exportdb.get_file_record(info) + except Exception as e: + print(f"[red]Error: {e}[/red]") + sys.exit(1) + else: + if info_rec: + print(info_rec.asdict()) + else: + print(f"[red]File '{info}' not found in export database[/red]") + sys.exit(0) + + if migrate: + exportdb = ExportDB(export_db, export_dir) + if upgraded := exportdb.was_upgraded: + print( + f"Migrated export database {export_db} from version {upgraded[0]} to {upgraded[1]}" + ) + else: + print( + f"Export database {export_db} is already at latest version {OSXPHOTOS_EXPORTDB_VERSION}" + ) + sys.exit(0) + + if sql: + exportdb = ExportDB(export_db, export_dir) + try: + c = exportdb._conn.cursor() + results = c.execute(sql) + except Exception as e: + print(f"[red]Error: {e}[/red]") + sys.exit(1) + else: + for row in results: + print(row) + sys.exit(0) diff --git a/osxphotos/cli/grep.py b/osxphotos/cli/grep.py new file mode 100644 index 00000000..43c335d4 --- /dev/null +++ b/osxphotos/cli/grep.py @@ -0,0 +1,57 @@ +"""grep command for osxphotos CLI """ + +import pathlib + +import click +from rich import print + +from osxphotos.photosdb.photosdb_utils import get_photos_library_version +from osxphotos.sqlgrep import sqlgrep + +from .common import DB_OPTION, OSXPHOTOS_HIDDEN, get_photos_db + + +@click.command(name="grep", hidden=OSXPHOTOS_HIDDEN) +@DB_OPTION +@click.pass_obj +@click.pass_context +@click.option( + "--ignore-case", + "-i", + required=False, + is_flag=True, + default=False, + help="Ignore case when searching (default is case-sensitive).", +) +@click.option( + "--print-filename", + "-p", + required=False, + is_flag=True, + default=False, + help="Print name of database file when printing results.", +) +@click.argument("pattern", metavar="PATTERN", required=True) +def grep(ctx, cli_obj, db, ignore_case, print_filename, pattern): + """Search for PATTERN in the Photos sqlite database file""" + db = db or get_photos_db() + db = pathlib.Path(db) + if db.is_file(): + # if passed the actual database, really want the parent of the database directory + db = db.parent.parent + photos_ver = get_photos_library_version(str(db)) + if photos_ver < 5: + db_file = db / "database" / "photos.db" + else: + db_file = db / "database" / "Photos.sqlite" + + if not db_file.is_file(): + click.secho(f"Could not find database file {db_file}", fg="red") + ctx.exit(2) + + db_file = str(db_file) + + for table, column, row_id, value in sqlgrep( + db_file, pattern, ignore_case, print_filename, rich_markup=True + ): + print(", ".join([table, column, row_id, value])) diff --git a/osxphotos/cli_help.py b/osxphotos/cli/help.py similarity index 94% rename from osxphotos/cli_help.py rename to osxphotos/cli/help.py index 719c0301..c7b2bb10 100644 --- a/osxphotos/cli_help.py +++ b/osxphotos/cli/help.py @@ -1,7 +1,6 @@ """Help text helper class for osxphotos CLI """ import io -import pathlib import re import click @@ -9,13 +8,13 @@ import osxmetadata from rich.console import Console from rich.markdown import Markdown -from ._constants import ( +from osxphotos._constants import ( EXTENDED_ATTRIBUTE_NAMES, EXTENDED_ATTRIBUTE_NAMES_QUOTED, OSXPHOTOS_EXPORT_DB, POST_COMMAND_CATEGORIES, ) -from .phototemplate import ( +from osxphotos.phototemplate import ( TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED, TEMPLATE_SUBSTITUTIONS_PATHLIB, @@ -25,15 +24,37 @@ from .phototemplate import ( __all__ = [ "ExportCommand", "template_help", - "tutorial_help", "rich_text", "strip_md_header_and_links", "strip_md_links", "strip_html_comments", - "get_tutorial_text", + "help", + "get_help_msg", ] +def get_help_msg(command): + """get help message for a Click command""" + with click.Context(command) as ctx: + return command.get_help(ctx) + + +@click.command() +@click.argument("topic", default=None, required=False, nargs=1) +@click.pass_context +def help(ctx, topic, **kw): + """Print help; for help on commands: help .""" + if topic is None: + click.echo(ctx.parent.get_help()) + return + elif topic in ctx.obj.group.commands: + ctx.info_name = topic + click.echo_via_pager(ctx.obj.group.commands[topic].get_help(ctx)) + else: + click.echo(f"Invalid command: {topic}", err=True) + click.echo(ctx.parent.get_help()) + + # TODO: The following help text could probably be done as mako template class ExportCommand(click.Command): """Custom click.Command that overrides get_help() to show additional help info for export""" @@ -282,19 +303,6 @@ def template_help(width=78): return help_str -def tutorial_help(width=78): - """Return formatted string for tutorial""" - sio = io.StringIO() - console = Console(file=sio, force_terminal=True, width=width) - help_md = get_tutorial_text() - help_md = strip_html_comments(help_md) - help_md = strip_md_links(help_md) - console.print(Markdown(help_md)) - help_str = sio.getvalue() - sio.close() - return help_str - - def rich_text(text, width=78): """Return rich formatted text""" sio = io.StringIO() @@ -348,12 +356,3 @@ def strip_md_links(md): def strip_html_comments(text): """Strip html comments from text (which doesn't need to be valid HTML)""" return re.sub(r"", "", text) - - -def get_tutorial_text(): - """Load tutorial text from file""" - # TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this - help_file = pathlib.Path(__file__).parent / "tutorial.md" - with open(help_file, "r") as fd: - md = fd.read() - return md diff --git a/osxphotos/cli/info.py b/osxphotos/cli/info.py new file mode 100644 index 00000000..ec3e51be --- /dev/null +++ b/osxphotos/cli/info.py @@ -0,0 +1,72 @@ +"""info command for osxphotos CLI""" + +import json + +import click +import yaml + +import osxphotos +from osxphotos._constants import _PHOTOS_4_VERSION + +from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db +from .list import _list_libraries + + +@click.command() +@DB_OPTION +@JSON_OPTION +@DB_ARGUMENT +@click.pass_obj +@click.pass_context +def info(ctx, cli_obj, db, json_, photos_library): + """Print out descriptive info of the Photos library database.""" + + db = get_photos_db(*photos_library, db, cli_obj.db) + if db is None: + click.echo(ctx.obj.group.commands["info"].get_help(ctx), err=True) + click.echo("\n\nLocated the following Photos library databases: ", err=True) + _list_libraries() + return + + photosdb = osxphotos.PhotosDB(dbfile=db) + info = {"database_path": photosdb.db_path, "database_version": photosdb.db_version} + photos = photosdb.photos(movies=False) + not_shared_photos = [p for p in photos if not p.shared] + info["photo_count"] = len(not_shared_photos) + + hidden = [p for p in photos if p.hidden] + info["hidden_photo_count"] = len(hidden) + + movies = photosdb.photos(images=False, movies=True) + not_shared_movies = [p for p in movies if not p.shared] + info["movie_count"] = len(not_shared_movies) + + if photosdb.db_version > _PHOTOS_4_VERSION: + shared_photos = [p for p in photos if p.shared] + info["shared_photo_count"] = len(shared_photos) + + shared_movies = [p for p in movies if p.shared] + info["shared_movie_count"] = len(shared_movies) + + keywords = photosdb.keywords_as_dict + info["keywords_count"] = len(keywords) + info["keywords"] = keywords + + albums = photosdb.albums_as_dict + info["albums_count"] = len(albums) + info["albums"] = albums + + if photosdb.db_version > _PHOTOS_4_VERSION: + albums_shared = photosdb.albums_shared_as_dict + info["shared_albums_count"] = len(albums_shared) + info["shared_albums"] = albums_shared + + persons = photosdb.persons_as_dict + + info["persons_count"] = len(persons) + info["persons"] = persons + + if cli_obj.json or json_: + click.echo(json.dumps(info, ensure_ascii=False)) + else: + click.echo(yaml.dump(info, sort_keys=False, allow_unicode=True)) diff --git a/osxphotos/cli/install_uninstall_run.py b/osxphotos/cli/install_uninstall_run.py new file mode 100644 index 00000000..478b1084 --- /dev/null +++ b/osxphotos/cli/install_uninstall_run.py @@ -0,0 +1,37 @@ +"""install/uninstall/run commands for osxphotos CLI""" + +import sys +from runpy import run_module, run_path + +import click + + +@click.command() +@click.argument("packages", nargs=-1, required=True) +@click.option( + "-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version" +) +def install(packages, upgrade): + """Install Python packages into the same environment as osxphotos""" + args = ["pip", "install"] + if upgrade: + args += ["--upgrade"] + args += list(packages) + sys.argv = args + run_module("pip", run_name="__main__") + + +@click.command() +@click.argument("packages", nargs=-1, required=True) +@click.option("-y", "--yes", is_flag=True, help="Don't ask for confirmation") +def uninstall(packages, yes): + """Uninstall Python packages from the osxphotos environment""" + sys.argv = ["pip", "uninstall"] + list(packages) + (["-y"] if yes else []) + run_module("pip", run_name="__main__") + + +@click.command(name="run") +@click.argument("python_file", nargs=1, type=click.Path(exists=True)) +def run(python_file): + """Run a python file using same environment as osxphotos""" + run_path(python_file, run_name="__main__") diff --git a/osxphotos/cli/keywords.py b/osxphotos/cli/keywords.py new file mode 100644 index 00000000..919b44ae --- /dev/null +++ b/osxphotos/cli/keywords.py @@ -0,0 +1,37 @@ +"""keywords command for osxphotos CLI""" + +import json + +import click +import yaml + +import osxphotos + +from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db +from .list import _list_libraries + + +@click.command() +@DB_OPTION +@JSON_OPTION +@DB_ARGUMENT +@click.pass_obj +@click.pass_context +def keywords(ctx, cli_obj, db, json_, photos_library): + """Print out keywords found in the Photos library.""" + + # below needed for to make CliRunner work for testing + cli_db = cli_obj.db if cli_obj is not None else None + db = get_photos_db(*photos_library, db, cli_db) + if db is None: + click.echo(ctx.obj.group.commands["keywords"].get_help(ctx), err=True) + click.echo("\n\nLocated the following Photos library databases: ", err=True) + _list_libraries() + return + + photosdb = osxphotos.PhotosDB(dbfile=db) + keywords = {"keywords": photosdb.keywords_as_dict} + if json_ or cli_obj.json: + click.echo(json.dumps(keywords, ensure_ascii=False)) + else: + click.echo(yaml.dump(keywords, sort_keys=False, allow_unicode=True)) diff --git a/osxphotos/cli/labels.py b/osxphotos/cli/labels.py new file mode 100644 index 00000000..2e55178e --- /dev/null +++ b/osxphotos/cli/labels.py @@ -0,0 +1,37 @@ +"""labels command for osxphotos CLI""" + +import json + +import click +import yaml + +import osxphotos + +from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db +from .list import _list_libraries + + +@click.command() +@DB_OPTION +@JSON_OPTION +@DB_ARGUMENT +@click.pass_obj +@click.pass_context +def labels(ctx, cli_obj, db, json_, photos_library): + """Print out image classification labels found in the Photos library.""" + + # below needed for to make CliRunner work for testing + cli_db = cli_obj.db if cli_obj is not None else None + db = get_photos_db(*photos_library, db, cli_db) + if db is None: + click.echo(ctx.obj.group.commands["labels"].get_help(ctx), err=True) + click.echo("\n\nLocated the following Photos library databases: ", err=True) + _list_libraries() + return + + photosdb = osxphotos.PhotosDB(dbfile=db) + labels = {"labels": photosdb.labels_as_dict} + if json_ or cli_obj.json: + click.echo(json.dumps(labels, ensure_ascii=False)) + else: + click.echo(yaml.dump(labels, sort_keys=False, allow_unicode=True)) diff --git a/osxphotos/cli/list.py b/osxphotos/cli/list.py new file mode 100644 index 00000000..836da420 --- /dev/null +++ b/osxphotos/cli/list.py @@ -0,0 +1,57 @@ +"""list command for osxphotos CLI""" + +import json + +import click + +import osxphotos + +from .common import JSON_OPTION + + +@click.command(name="list") +@JSON_OPTION +@click.pass_obj +@click.pass_context +def list_libraries(ctx, cli_obj, json_): + """Print list of Photos libraries found on the system.""" + + # implemented in _list_libraries so it can be called by other CLI functions + # without errors due to passing ctx and cli_obj + _list_libraries(json_=json_ or cli_obj.json, error=False) + + +def _list_libraries(json_=False, error=True): + """Print list of Photos libraries found on the system. + If json_ == True, print output as JSON (default = False)""" + + photo_libs = osxphotos.utils.list_photo_libraries() + sys_lib = osxphotos.utils.get_system_library_path() + last_lib = osxphotos.utils.get_last_library_path() + + if json_: + libs = { + "photo_libraries": photo_libs, + "system_library": sys_lib, + "last_library": last_lib, + } + click.echo(json.dumps(libs, ensure_ascii=False)) + else: + last_lib_flag = sys_lib_flag = False + + for lib in photo_libs: + if lib == sys_lib: + click.echo(f"(*)\t{lib}", err=error) + sys_lib_flag = True + elif lib == last_lib: + click.echo(f"(#)\t{lib}", err=error) + last_lib_flag = True + else: + click.echo(f"\t{lib}", err=error) + + if sys_lib_flag or last_lib_flag: + click.echo("\n", err=error) + if sys_lib_flag: + click.echo("(*)\tSystem Photos Library", err=error) + if last_lib_flag: + click.echo("(#)\tLast opened Photos Library", err=error) diff --git a/osxphotos/cli/param_types.py b/osxphotos/cli/param_types.py new file mode 100644 index 00000000..3ad8370e --- /dev/null +++ b/osxphotos/cli/param_types.py @@ -0,0 +1,108 @@ +"""Click parameter types for osxphotos CLI""" +import datetime +import pathlib + +import bitmath +import click + +from osxphotos.export_db_utils import export_db_get_version +from osxphotos.utils import expand_and_validate_filepath, load_function + +__all__ = [ + "BitMathSize", + "DateTimeISO8601", + "ExportDBType", + "FunctionCall", + "TimeISO8601", +] + + +class DateTimeISO8601(click.ParamType): + + name = "DATETIME" + + def convert(self, value, param, ctx): + try: + return datetime.datetime.fromisoformat(value) + except Exception: + self.fail( + f"Invalid datetime format {value}. " + "Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]" + ) + + +class BitMathSize(click.ParamType): + + name = "BITMATH" + + def convert(self, value, param, ctx): + try: + value = bitmath.parse_string(value) + except ValueError: + # no units specified + try: + value = int(value) + value = bitmath.Byte(value) + except ValueError as e: + self.fail( + f"{value} must be specified as bytes or using SI/NIST units. " + + "For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'." + ) + return value + + +class TimeISO8601(click.ParamType): + + name = "TIME" + + def convert(self, value, param, ctx): + try: + return datetime.time.fromisoformat(value).replace(tzinfo=None) + except Exception: + self.fail( + f"Invalid time format {value}. " + "Valid format: HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]] " + "however, note that timezone will be ignored." + ) + + +class FunctionCall(click.ParamType): + name = "FUNCTION" + + def convert(self, value, param, ctx): + if "::" not in value: + self.fail( + f"Could not parse function name from '{value}'. " + "Valid format filename.py::function" + ) + + filename, funcname = value.split("::") + + filename_validated = expand_and_validate_filepath(filename) + if not filename_validated: + self.fail(f"'{filename}' does not appear to be a file") + + try: + function = load_function(filename_validated, funcname) + except Exception as e: + self.fail(f"Could not load function {funcname} from {filename_validated}") + + return (function, value) + + +class ExportDBType(click.ParamType): + + name = "EXPORTDB" + + def convert(self, value, param, ctx): + try: + export_db_name = pathlib.Path(value) + if export_db_name.is_dir(): + raise click.BadParameter(f"{value} is a directory") + if export_db_name.is_file(): + # verify it's actually an osxphotos export_db + # export_db_get_version will raise an error if it's not valid + osxphotos_ver, export_db_ver = export_db_get_version(value) + return value + except Exception: + self.fail(f"{value} exists but is not a valid osxphotos export database. ") diff --git a/osxphotos/cli/persons.py b/osxphotos/cli/persons.py new file mode 100644 index 00000000..30faa244 --- /dev/null +++ b/osxphotos/cli/persons.py @@ -0,0 +1,36 @@ +"""persons command for osxphotos CLI""" +import json + +import click +import yaml + +import osxphotos + +from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db +from .list import _list_libraries + + +@click.command() +@DB_OPTION +@JSON_OPTION +@DB_ARGUMENT +@click.pass_obj +@click.pass_context +def persons(ctx, cli_obj, db, json_, photos_library): + """Print out persons (faces) found in the Photos library.""" + + # below needed for to make CliRunner work for testing + cli_db = cli_obj.db if cli_obj is not None else None + db = get_photos_db(*photos_library, db, cli_db) + if db is None: + click.echo(ctx.obj.group.commands["persons"].get_help(ctx), err=True) + click.echo("\n\nLocated the following Photos library databases: ", err=True) + _list_libraries() + return + + photosdb = osxphotos.PhotosDB(dbfile=db) + persons = {"persons": photosdb.persons_as_dict} + if json_ or cli_obj.json: + click.echo(json.dumps(persons, ensure_ascii=False)) + else: + click.echo(yaml.dump(persons, sort_keys=False, allow_unicode=True)) diff --git a/osxphotos/cli/places.py b/osxphotos/cli/places.py new file mode 100644 index 00000000..235b4517 --- /dev/null +++ b/osxphotos/cli/places.py @@ -0,0 +1,62 @@ +"""places command for osxphotos CLI""" + +import json + +import click +import yaml + +import osxphotos +from osxphotos._constants import _PHOTOS_4_VERSION, _UNKNOWN_PLACE + +from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db +from .list import _list_libraries + + +@click.command() +@DB_OPTION +@JSON_OPTION +@DB_ARGUMENT +@click.pass_obj +@click.pass_context +def places(ctx, cli_obj, db, json_, photos_library): + """Print out places found in the Photos library.""" + + # below needed for to make CliRunner work for testing + cli_db = cli_obj.db if cli_obj is not None else None + db = get_photos_db(*photos_library, db, cli_db) + if db is None: + click.echo(ctx.obj.group.commands["places"].get_help(ctx), err=True) + click.echo("\n\nLocated the following Photos library databases: ", err=True) + _list_libraries() + return + + photosdb = osxphotos.PhotosDB(dbfile=db) + place_names = {} + for photo in photosdb.photos(movies=True): + if photo.place: + try: + place_names[photo.place.name] += 1 + except Exception: + place_names[photo.place.name] = 1 + else: + try: + place_names[_UNKNOWN_PLACE] += 1 + except Exception: + place_names[_UNKNOWN_PLACE] = 1 + + # sort by place count + places = { + "places": { + name: place_names[name] + for name in sorted( + place_names.keys(), key=lambda key: place_names[key], reverse=True + ) + } + } + + # below needed for to make CliRunner work for testing + cli_json = cli_obj.json if cli_obj is not None else None + if json_ or cli_json: + click.echo(json.dumps(places, ensure_ascii=False)) + else: + click.echo(yaml.dump(places, sort_keys=False, allow_unicode=True)) diff --git a/osxphotos/cli/print_photo_info.py b/osxphotos/cli/print_photo_info.py new file mode 100644 index 00000000..55aa2d0b --- /dev/null +++ b/osxphotos/cli/print_photo_info.py @@ -0,0 +1,112 @@ +"""print_photo_info function to print PhotoInfo objects""" + +import csv +import sys +from typing import Callable, List + +from osxphotos.photoinfo import PhotoInfo + + +def print_photo_info( + photos: List[PhotoInfo], json: bool = False, print_func: Callable = print +): + dump = [] + if json: + dump.extend(p.json() for p in photos) + print_func(f"[{', '.join(dump)}]") + else: + # dump as CSV + csv_writer = csv.writer( + sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL + ) + # add headers + dump.append( + [ + "uuid", + "filename", + "original_filename", + "date", + "description", + "title", + "keywords", + "albums", + "persons", + "path", + "ismissing", + "hasadjustments", + "external_edit", + "favorite", + "hidden", + "shared", + "latitude", + "longitude", + "path_edited", + "isphoto", + "ismovie", + "uti", + "burst", + "live_photo", + "path_live_photo", + "iscloudasset", + "incloud", + "date_modified", + "portrait", + "screenshot", + "slow_mo", + "time_lapse", + "hdr", + "selfie", + "panorama", + "has_raw", + "uti_raw", + "path_raw", + "intrash", + ] + ) + for p in photos: + date_modified_iso = p.date_modified.isoformat() if p.date_modified else None + dump.append( + [ + p.uuid, + p.filename, + p.original_filename, + p.date.isoformat(), + p.description, + p.title, + ", ".join(p.keywords), + ", ".join(p.albums), + ", ".join(p.persons), + p.path, + p.ismissing, + p.hasadjustments, + p.external_edit, + p.favorite, + p.hidden, + p.shared, + p._latitude, + p._longitude, + p.path_edited, + p.isphoto, + p.ismovie, + p.uti, + p.burst, + p.live_photo, + p.path_live_photo, + p.iscloudasset, + p.incloud, + date_modified_iso, + p.portrait, + p.screenshot, + p.slow_mo, + p.time_lapse, + p.hdr, + p.selfie, + p.panorama, + p.has_raw, + p.uti_raw, + p.path_raw, + p.intrash, + ] + ) + for row in dump: + csv_writer.writerow(row) diff --git a/osxphotos/cli/query.py b/osxphotos/cli/query.py new file mode 100644 index 00000000..c5eadbba --- /dev/null +++ b/osxphotos/cli/query.py @@ -0,0 +1,358 @@ +"""query command for osxphotos CLI""" + +import click + +import osxphotos +from osxphotos.photosalbum import PhotosAlbum +from osxphotos.queryoptions import QueryOptions + +from .common import ( + CLI_COLOR_ERROR, + CLI_COLOR_WARNING, + DB_ARGUMENT, + DB_OPTION, + DELETED_OPTIONS, + JSON_OPTION, + OSXPHOTOS_HIDDEN, + QUERY_OPTIONS, + get_photos_db, + load_uuid_from_file, + set_debug, +) +from .list import _list_libraries +from .print_photo_info import print_photo_info + + +@click.command() +@DB_OPTION +@JSON_OPTION +@QUERY_OPTIONS +@DELETED_OPTIONS +@click.option("--missing", is_flag=True, help="Search for photos missing from disk.") +@click.option( + "--not-missing", + is_flag=True, + help="Search for photos present on disk (e.g. not missing).", +) +@click.option( + "--cloudasset", + is_flag=True, + help="Search for photos that are part of an iCloud library", +) +@click.option( + "--not-cloudasset", + is_flag=True, + help="Search for photos that are not part of an iCloud library", +) +@click.option( + "--incloud", + is_flag=True, + help="Search for photos that are in iCloud (have been synched)", +) +@click.option( + "--not-incloud", + is_flag=True, + help="Search for photos that are not in iCloud (have not been synched)", +) +@click.option( + "--add-to-album", + metavar="ALBUM", + help="Add all photos from query to album ALBUM in Photos. Album ALBUM will be created " + "if it doesn't exist. All photos in the query results will be added to this album. " + "This only works if the Photos library being queried is the last-opened (default) library in Photos. " + "This feature is currently experimental. I don't know how well it will work on large query sets.", +) +@click.option( + "--debug", required=False, is_flag=True, default=False, hidden=OSXPHOTOS_HIDDEN +) +@DB_ARGUMENT +@click.pass_obj +@click.pass_context +def query( + ctx, + cli_obj, + db, + photos_library, + keyword, + person, + album, + folder, + name, + uuid, + uuid_from_file, + title, + no_title, + description, + no_description, + ignore_case, + json_, + edited, + external_edit, + favorite, + not_favorite, + hidden, + not_hidden, + missing, + not_missing, + shared, + not_shared, + only_movies, + only_photos, + uti, + burst, + not_burst, + live, + not_live, + cloudasset, + not_cloudasset, + incloud, + not_incloud, + from_date, + to_date, + from_time, + to_time, + 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, + has_raw, + place, + no_place, + location, + no_location, + label, + deleted, + deleted_only, + has_comment, + no_comment, + has_likes, + no_likes, + is_reference, + in_album, + not_in_album, + duplicate, + min_size, + max_size, + regex, + selected, + exif, + query_eval, + query_function, + add_to_album, + debug, +): + """Query the Photos database using 1 or more search options; + if more than one option is provided, they are treated as "AND" + (e.g. search for photos matching all options). + """ + + if debug: + set_debug(True) + osxphotos._set_debug(True) + + # if no query terms, show help and return + # sanity check input args + nonexclusive = [ + keyword, + person, + album, + folder, + name, + uuid, + uuid_from_file, + edited, + external_edit, + uti, + has_raw, + from_date, + to_date, + from_time, + to_time, + label, + is_reference, + query_eval, + query_function, + min_size, + max_size, + regex, + selected, + exif, + duplicate, + ] + exclusive = [ + (favorite, not_favorite), + (hidden, not_hidden), + (missing, not_missing), + (any(title), no_title), + (any(description), no_description), + (only_photos, only_movies), + (burst, not_burst), + (live, not_live), + (cloudasset, not_cloudasset), + (incloud, not_incloud), + (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), + (any(place), no_place), + (deleted, deleted_only), + (shared, not_shared), + (has_comment, no_comment), + (has_likes, no_likes), + (in_album, not_in_album), + (location, no_location), + ] + # print help if no non-exclusive term or a double exclusive term is given + if any(all(bb) for bb in exclusive) or not any( + nonexclusive + [b ^ n for b, n in exclusive] + ): + click.echo("Incompatible query options", err=True) + click.echo(ctx.obj.group.commands["query"].get_help(ctx), err=True) + return + + # actually have something to query + # default searches for everything + photos = True + movies = True + if only_movies: + photos = False + if only_photos: + movies = False + + # load UUIDs if necessary and append to any uuids passed with --uuid + if uuid_from_file: + uuid_list = list(uuid) # Click option is a tuple + uuid_list.extend(load_uuid_from_file(uuid_from_file)) + uuid = tuple(uuid_list) + + # below needed for to make CliRunner work for testing + cli_db = cli_obj.db if cli_obj is not None else None + db = get_photos_db(*photos_library, db, cli_db) + if db is None: + click.echo(ctx.obj.group.commands["query"].get_help(ctx), err=True) + click.echo("\n\nLocated the following Photos library databases: ", err=True) + _list_libraries() + return + + photosdb = osxphotos.PhotosDB(dbfile=db) + query_options = QueryOptions( + keyword=keyword, + person=person, + album=album, + folder=folder, + uuid=uuid, + title=title, + no_title=no_title, + description=description, + no_description=no_description, + ignore_case=ignore_case, + edited=edited, + external_edit=external_edit, + favorite=favorite, + not_favorite=not_favorite, + hidden=hidden, + not_hidden=not_hidden, + missing=missing, + not_missing=not_missing, + shared=shared, + not_shared=not_shared, + photos=photos, + movies=movies, + uti=uti, + burst=burst, + not_burst=not_burst, + live=live, + not_live=not_live, + cloudasset=cloudasset, + not_cloudasset=not_cloudasset, + incloud=incloud, + not_incloud=not_incloud, + from_date=from_date, + to_date=to_date, + from_time=from_time, + to_time=to_time, + portrait=portrait, + not_portrait=not_portrait, + screenshot=screenshot, + not_screenshot=not_screenshot, + slow_mo=slow_mo, + not_slow_mo=not_slow_mo, + time_lapse=time_lapse, + not_time_lapse=not_time_lapse, + hdr=hdr, + not_hdr=not_hdr, + selfie=selfie, + not_selfie=not_selfie, + panorama=panorama, + not_panorama=not_panorama, + has_raw=has_raw, + place=place, + no_place=no_place, + location=location, + no_location=no_location, + label=label, + deleted=deleted, + deleted_only=deleted_only, + has_comment=has_comment, + no_comment=no_comment, + has_likes=has_likes, + no_likes=no_likes, + is_reference=is_reference, + in_album=in_album, + not_in_album=not_in_album, + name=name, + min_size=min_size, + max_size=max_size, + query_eval=query_eval, + function=query_function, + regex=regex, + selected=selected, + exif=exif, + duplicate=duplicate, + ) + + try: + photos = photosdb.query(query_options) + except ValueError as e: + if "Invalid query_eval CRITERIA:" in str(e): + msg = str(e).split(":")[1] + raise click.BadOptionUsage( + "query_eval", f"Invalid query-eval CRITERIA: {msg}" + ) + else: + raise ValueError(e) + + # below needed for to make CliRunner work for testing + cli_json = cli_obj.json if cli_obj is not None else None + + if add_to_album and photos: + album_query = PhotosAlbum(add_to_album, verbose=None) + photo_len = len(photos) + photo_word = "photos" if photo_len > 1 else "photo" + click.echo( + f"Adding {photo_len} {photo_word} to album '{album_query.name}'. Note: Photos may prompt you to confirm this action.", + err=True, + ) + try: + album_query.add_list(photos) + except Exception as e: + click.secho( + f"Error adding photos to album {add_to_album}: {e}", + fg=CLI_COLOR_ERROR, + err=True, + ) + + print_photo_info(photos, cli_json or json_, print_func=click.echo) diff --git a/osxphotos/cli/repl.py b/osxphotos/cli/repl.py new file mode 100644 index 00000000..10a0da6e --- /dev/null +++ b/osxphotos/cli/repl.py @@ -0,0 +1,339 @@ +"""repl command for osxphotos CLI""" + +import dataclasses +import os +import os.path +import pathlib +import sys +import time +from typing import List + +import click +import photoscript +from rich import pretty, print + +import osxphotos +from osxphotos._constants import _PHOTOS_4_VERSION +from osxphotos.photoinfo import PhotoInfo +from osxphotos.photosdb import PhotosDB +from osxphotos.pyrepl import embed_repl +from osxphotos.queryoptions import QueryOptions + +from .common import ( + DB_ARGUMENT, + DB_OPTION, + DELETED_OPTIONS, + QUERY_OPTIONS, + get_photos_db, + load_uuid_from_file, +) + + +class IncompatibleQueryOptions(Exception): + pass + + +@click.command(name="repl") +@DB_OPTION +@click.pass_obj +@click.pass_context +@click.option( + "--emacs", + required=False, + is_flag=True, + default=False, + help="Launch REPL with Emacs keybindings (default is vi bindings)", +) +@click.option( + "--beta", + is_flag=True, + default=False, + hidden=True, + help="Enable beta options.", +) +@QUERY_OPTIONS +@DELETED_OPTIONS +@click.option("--missing", is_flag=True, help="Search for photos missing from disk.") +@click.option( + "--not-missing", + is_flag=True, + help="Search for photos present on disk (e.g. not missing).", +) +@click.option( + "--cloudasset", + is_flag=True, + help="Search for photos that are part of an iCloud library", +) +@click.option( + "--not-cloudasset", + is_flag=True, + help="Search for photos that are not part of an iCloud library", +) +@click.option( + "--incloud", + is_flag=True, + help="Search for photos that are in iCloud (have been synched)", +) +@click.option( + "--not-incloud", + is_flag=True, + help="Search for photos that are not in iCloud (have not been synched)", +) +def repl(ctx, cli_obj, db, emacs, beta, **kwargs): + """Run interactive osxphotos REPL shell (useful for debugging, prototyping, and inspecting your Photos library)""" + import logging + + from objexplore import explore + from photoscript import Album, Photo, PhotosLibrary + from rich import inspect as _inspect + + from osxphotos import ExifTool, PhotoInfo, PhotosDB + from osxphotos.albuminfo import AlbumInfo + from osxphotos.momentinfo import MomentInfo + from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter + from osxphotos.placeinfo import PlaceInfo + from osxphotos.queryoptions import QueryOptions + from osxphotos.scoreinfo import ScoreInfo + from osxphotos.searchinfo import SearchInfo + + logger = logging.getLogger() + logger.disabled = True + + pretty.install() + print(f"python version: {sys.version}") + print(f"osxphotos version: {osxphotos._version.__version__}") + db = db or get_photos_db() + photosdb = _load_photos_db(db) + # enable beta features if requested + if beta: + photosdb._beta = beta + print("Beta mode enabled") + print("Getting photos") + tic = time.perf_counter() + try: + query_options = _query_options_from_kwargs(**kwargs) + except IncompatibleQueryOptions: + click.echo("Incompatible query options", err=True) + click.echo(ctx.obj.group.commands["repl"].get_help(ctx), err=True) + sys.exit(1) + photos = _query_photos(photosdb, query_options) + all_photos = _get_all_photos(photosdb) + toc = time.perf_counter() + tictoc = toc - tic + + # shortcut for helper functions + get_photo = photosdb.get_photo + show = _show_photo + spotlight = _spotlight_photo + get_selected = _get_selected(photosdb) + try: + selected = get_selected() + except Exception: + # get_selected sometimes fails + selected = [] + + def inspect(obj): + """inspect object""" + return _inspect(obj, methods=True) + + print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds\n") + print("The following classes have been imported from osxphotos:") + print( + "- AlbumInfo, ExifTool, PhotoInfo, PhotoExporter, ExportOptions, ExportResults, PhotosDB, PlaceInfo, QueryOptions, MomentInfo, ScoreInfo, SearchInfo\n" + ) + print("The following variables are defined:") + print(f"- photosdb: PhotosDB() instance for {photosdb.library_path}") + print( + f"- photos: list of PhotoInfo objects for all photos filtered with any query options passed on command line (len={len(photos)})" + ) + print( + f"- all_photos: list of PhotoInfo objects for all photos in photosdb, including those in the trash (len={len(all_photos)})" + ) + print( + f"- selected: list of PhotoInfo objects for any photos selected in Photos (len={len(selected)})" + ) + print(f"\nThe following functions may be helpful:") + print( + f"- get_photo(uuid): return a PhotoInfo object for photo with uuid; e.g. get_photo('B13F4485-94E0-41CD-AF71-913095D62E31')" + ) + print( + f"- get_selected(); return list of PhotoInfo objects for photos selected in Photos" + ) + print( + f"- show(photo): open a photo object in the default viewer; e.g. show(selected[0])" + ) + print( + f"- show(path): open a file at path in the default viewer; e.g. show('/path/to/photo.jpg')" + ) + print(f"- spotlight(photo): open a photo and spotlight it in Photos") + # print( + # f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)" + # ) + print( + f"- inspect(object): print information about an object; e.g. inspect(PhotoInfo)" + ) + print( + f"- explore(object): interactively explore an object with objexplore; e.g. explore(PhotoInfo)" + ) + print(f"- q, quit, quit(), exit, exit(): exit this interactive shell\n") + + embed_repl( + globals=globals(), + locals=locals(), + history_filename=str(pathlib.Path.home() / ".osxphotos_repl_history"), + quit_words=["q", "quit", "exit"], + vi_mode=not emacs, + ) + + +def _show_photo(photo: PhotoInfo): + """open image with default image viewer + + Note: This is for debugging only -- it will actually open any filetype which could + be very, very bad. + + Args: + photo: PhotoInfo object or a path to a photo on disk + """ + photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo + + if not os.path.isfile(photopath): + return f"'{photopath}' does not appear to be a valid photo path" + + os.system(f"open '{photopath}'") + + +def _load_photos_db(dbpath): + print("Loading database") + tic = time.perf_counter() + photosdb = osxphotos.PhotosDB(dbfile=dbpath, verbose=print) + toc = time.perf_counter() + tictoc = toc - tic + print(f"Done: took {tictoc:0.2f} seconds") + return photosdb + + +def _get_all_photos(photosdb): + """get list of all photos in photosdb""" + photos = photosdb.photos(images=True, movies=True) + photos.extend(photosdb.photos(images=True, movies=True, intrash=True)) + return photos + + +def _get_selected(photosdb): + """get list of PhotoInfo objects for photos selected in Photos""" + + def get_selected(): + selected = photoscript.PhotosLibrary().selection + if not selected: + return [] + return photosdb.photos(uuid=[p.uuid for p in selected]) + + return get_selected + + +def _spotlight_photo(photo: PhotoInfo): + photo_ = photoscript.Photo(photo.uuid) + photo_.spotlight() + + +def _query_options_from_kwargs(**kwargs) -> QueryOptions: + """Validate query options and create a QueryOptions instance""" + # sanity check input args + nonexclusive = [ + "keyword", + "person", + "album", + "folder", + "name", + "uuid", + "uuid_from_file", + "edited", + "external_edit", + "uti", + "has_raw", + "from_date", + "to_date", + "from_time", + "to_time", + "label", + "is_reference", + "query_eval", + "query_function", + "min_size", + "max_size", + "regex", + "selected", + "exif", + "duplicate", + ] + exclusive = [ + ("favorite", "not_favorite"), + ("hidden", "not_hidden"), + ("missing", "not_missing"), + ("only_photos", "only_movies"), + ("burst", "not_burst"), + ("live", "not_live"), + ("cloudasset", "not_cloudasset"), + ("incloud", "not_incloud"), + ("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"), + ("deleted", "deleted_only"), + ("shared", "not_shared"), + ("has_comment", "no_comment"), + ("has_likes", "no_likes"), + ("in_album", "not_in_album"), + ("location", "no_location"), + ] + # print help if no non-exclusive term or a double exclusive term is given + # TODO: add option to validate requiring at least one query arg + if any(all([kwargs[b], kwargs[n]]) for b, n in exclusive) or any( + [ + all([any(kwargs["title"]), kwargs["no_title"]]), + all([any(kwargs["description"]), kwargs["no_description"]]), + all([any(kwargs["place"]), kwargs["no_place"]]), + ] + ): + raise IncompatibleQueryOptions + + # actually have something to query + include_photos = True + include_movies = True # default searches for everything + if kwargs["only_movies"]: + include_photos = False + if kwargs["only_photos"]: + include_movies = False + + # load UUIDs if necessary and append to any uuids passed with --uuid + uuid = None + if kwargs["uuid_from_file"]: + uuid_list = list(kwargs["uuid"]) # Click option is a tuple + uuid_list.extend(load_uuid_from_file(kwargs["uuid_from_file"])) + uuid = tuple(uuid_list) + + query_fields = [field.name for field in dataclasses.fields(QueryOptions)] + query_dict = {field: kwargs.get(field) for field in query_fields} + query_dict["photos"] = include_photos + query_dict["movies"] = include_movies + query_dict["uuid"] = uuid + return QueryOptions(**query_dict) + + +def _query_photos(photosdb: PhotosDB, query_options: QueryOptions) -> List: + """Query photos given a QueryOptions instance""" + try: + photos = photosdb.query(query_options) + except ValueError as e: + if "Invalid query_eval CRITERIA:" not in str(e): + raise ValueError(e) from e + msg = str(e).split(":")[1] + raise click.BadOptionUsage( + "query_eval", f"Invalid query-eval CRITERIA: {msg}" + ) from e + + return photos diff --git a/osxphotos/cli/snap_diff.py b/osxphotos/cli/snap_diff.py new file mode 100644 index 00000000..ba39c2fe --- /dev/null +++ b/osxphotos/cli/snap_diff.py @@ -0,0 +1,157 @@ +"""snap/diff commands for osxphotos CLI""" + +import datetime +import os +import pathlib +import shutil +import subprocess + +import click +from rich.console import Console +from rich.syntax import Syntax + +import osxphotos + +from .common import DB_OPTION, OSXPHOTOS_SNAPSHOT_DIR, get_photos_db, verbose_print + + +@click.command(name="snap") +@click.pass_obj +@click.pass_context +@DB_OPTION +def snap(ctx, cli_obj, db): + """Create snapshot of Photos database to use with diff command + + Snapshots only the database files, not the entire library. If OSXPHOTOS_SNAPSHOT + environment variable is defined, will use that as snapshot directory, otherwise + uses '/private/tmp/osxphotos_snapshots' + + Works only on Photos library versions since Catalina (10.15) or newer. + """ + + db = get_photos_db(db, cli_obj.db) + db_path = pathlib.Path(db) + if db_path.is_file(): + # assume it's the sqlite file + db_path = db_path.parent.parent + db_path = db_path / "database" + + db_folder = os.environ.get("OSXPHOTOS_SNAPSHOT", OSXPHOTOS_SNAPSHOT_DIR) + if not os.path.isdir(db_folder): + click.echo(f"Creating snapshot folder: '{db_folder}'") + os.mkdir(db_folder) + + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + destination_path = pathlib.Path(db_folder) / timestamp + + # get all the sqlite files including the write ahead log if any + files = db_path.glob("*.sqlite*") + os.makedirs(destination_path) + fu = osxphotos.fileutil.FileUtil() + count = 0 + for file in files: + if file.is_file(): + fu.copy(file, destination_path) + count += 1 + + print(f"Copied {count} files from {db_path} to {destination_path}") + + +@click.command(name="diff") +@click.pass_obj +@click.pass_context +@DB_OPTION +@click.option( + "--raw-output", + "-r", + is_flag=True, + default=False, + help="Print raw output (don't use syntax highlighting).", +) +@click.option( + "--style", + "-s", + metavar="STYLE", + nargs=1, + default="monokai", + help="Specify style/theme for syntax highlighting. " + "Theme may be any valid pygments style (https://pygments.org/styles/). " + "Default is 'monokai'.", +) +@click.argument("db2", nargs=-1, type=click.Path(exists=True)) +@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.") +def diff(ctx, cli_obj, db, raw_output, style, db2, verbose): + """Compare two Photos databases and print out differences + + To use the diff command, you'll need to install sqldiff via homebrew: + + - Install homebrew (https://brew.sh/) if not already installed + + - Install sqldiff: `brew install sqldiff` + + When run with no arguments, compares the current Photos library to the + most recent snapshot in the the OSXPHOTOS_SNAPSHOT directory. + + If run with the --db option, compares the library specified by --db to the + most recent snapshot in the the OSXPHOTOS_SNAPSHOT directory. + + If run with just the DB2 argument, compares the current Photos library to + the database specified by the DB2 argument. + + If run with both the --db option and the DB2 argument, compares the + library specified by --db to the database specified by DB2 + + See also `osxphotos snap` + + If the OSXPHOTOS_SNAPSHOT environment variable is not set, will use + '/private/tmp/osxphotos_snapshots' + + Works only on Photos library versions since Catalina (10.15) or newer. + """ + + verbose_ = verbose_print(verbose, rich=True) + + sqldiff = shutil.which("sqldiff") + if not sqldiff: + click.echo( + "sqldiff not found; install via homebrew (https://brew.sh/): `brew install sqldiff`" + ) + ctx.exit(2) + verbose_(f"sqldiff found at '{sqldiff}'") + + db = get_photos_db(db, cli_obj.db) + db_path = pathlib.Path(db) + if db_path.is_file(): + # assume it's the sqlite file + db_path = db_path.parent.parent + db_path = db_path / "database" + db_1 = db_path / "photos.sqlite" + + if db2: + db_2 = pathlib.Path(db2[0]) + else: + # get most recent snapshot + db_folder = os.environ.get("OSXPHOTOS_SNAPSHOT", OSXPHOTOS_SNAPSHOT_DIR) + verbose_(f"Using snapshot folder: '{db_folder}'") + folders = sorted([f for f in pathlib.Path(db_folder).glob("*") if f.is_dir()]) + folder_2 = folders[-1] + db_2 = folder_2 / "Photos.sqlite" + + if not db_1.exists(): + print(f"database file {db_1} missing") + if not db_2.exists(): + print(f"database file {db_2} missing") + + verbose_(f"Comparing databases {db_1} and {db_2}") + + diff_proc = subprocess.Popen([sqldiff, db_2, db_1], stdout=subprocess.PIPE) + console = Console() + for line in iter(diff_proc.stdout.readline, b""): + line = line.decode("UTF-8").rstrip() + if raw_output: + print(line) + else: + syntax = Syntax( + line, "sql", theme=style, line_numbers=False, code_width=1000 + ) + console.print(syntax) diff --git a/osxphotos/cli/tutorial.py b/osxphotos/cli/tutorial.py new file mode 100644 index 00000000..49ea3ebe --- /dev/null +++ b/osxphotos/cli/tutorial.py @@ -0,0 +1,46 @@ +"""tutorial command for osxphotos CLI""" + +import io +import pathlib + +import click +from rich.console import Console +from rich.markdown import Markdown + +from .help import strip_html_comments, strip_md_links + + +@click.command(name="tutorial") +@click.argument( + "WIDTH", + nargs=-1, + type=click.INT, +) +@click.pass_obj +@click.pass_context +def tutorial(ctx, cli_obj, width): + """Display osxphotos tutorial.""" + width = width[0] if width else 100 + click.echo_via_pager(tutorial_help(width=width)) + + +def tutorial_help(width=78): + """Return formatted string for tutorial""" + sio = io.StringIO() + console = Console(file=sio, force_terminal=True, width=width) + help_md = get_tutorial_text() + help_md = strip_html_comments(help_md) + help_md = strip_md_links(help_md) + console.print(Markdown(help_md)) + help_str = sio.getvalue() + sio.close() + return help_str + + +def get_tutorial_text(): + """Load tutorial text from file""" + # TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this + help_file = pathlib.Path(__file__).parent / "../tutorial.md" + with open(help_file, "r") as fd: + md = fd.read() + return md diff --git a/osxphotos/cli/uuid.py b/osxphotos/cli/uuid.py new file mode 100644 index 00000000..8d353c01 --- /dev/null +++ b/osxphotos/cli/uuid.py @@ -0,0 +1,26 @@ +"""uuid command for osxphotos CLI""" + +import click +import photoscript + + +@click.command(name="uuid") +@click.pass_obj +@click.pass_context +@click.option( + "--filename", + "-f", + required=False, + is_flag=True, + default=False, + help="Include filename of selected photos in output", +) +def uuid(ctx, cli_obj, filename): + """Print out unique IDs (UUID) of photos selected in Photos + + Prints outs UUIDs in form suitable for --uuid-from-file and --skip-uuid-from-file + """ + for photo in photoscript.PhotosLibrary().selection: + if filename: + print(f"# {photo.filename}") + print(photo.uuid) diff --git a/osxphotos/export_db_utils.py b/osxphotos/export_db_utils.py index bf78c65b..f4db516d 100644 --- a/osxphotos/export_db_utils.py +++ b/osxphotos/export_db_utils.py @@ -1,17 +1,18 @@ """ Utility functions for working with export_db """ -import pathlib -import sqlite3 -from typing import Optional, Tuple, Union import datetime import os +import pathlib +import sqlite3 +from typing import Callable, Optional, Tuple, Union import toml from rich import print from ._constants import OSXPHOTOS_EXPORT_DB from ._version import __version__ +from .utils import noop from .export_db import OSXPHOTOS_EXPORTDB_VERSION, ExportDB from .fileutil import FileUtil from .photosdb import PhotosDB @@ -57,7 +58,7 @@ def export_db_vacuum(dbfile: Union[str, pathlib.Path]) -> None: def export_db_update_signatures( dbfile: Union[str, pathlib.Path], export_dir: Union[str, pathlib.Path], - verbose: bool = False, + verbose_: Callable = noop, dry_run: bool = False, ) -> Tuple[int, int]: """Update signatures for all files found in the export database to match what's on disk @@ -78,13 +79,11 @@ def export_db_update_signatures( filepath = export_dir / filepath if not os.path.exists(filepath): skipped += 1 - if verbose: - print(f"[dark_orange]Skipping missing file[/dark_orange]: '{filepath}'") + verbose_(f"[dark_orange]Skipping missing file[/dark_orange]: '{filepath}'") continue updated += 1 file_sig = fileutil.file_sig(filepath) - if verbose: - print(f"[green]Updating signature for[/green]: '{filepath}'") + verbose_(f"[green]Updating signature for[/green]: '{filepath}'") if not dry_run: c.execute( "UPDATE export_data SET dest_mode = ?, dest_size = ?, dest_mtime = ? WHERE filepath_normalized = ?;", @@ -129,7 +128,7 @@ def export_db_save_config_to_file( def export_db_check_signatures( dbfile: Union[str, pathlib.Path], export_dir: Union[str, pathlib.Path], - verbose: bool = False, + verbose_: Callable = noop, ) -> Tuple[int, int, int]: """Check signatures for all files found in the export database to verify what matches the on disk files @@ -151,19 +150,16 @@ def export_db_check_signatures( filepath = export_dir / filepath if not filepath.exists(): skipped += 1 - if verbose: - print(f"[dark_orange]Skipping missing file[/dark_orange]: '{filepath}'") + verbose_(f"[dark_orange]Skipping missing file[/dark_orange]: '{filepath}'") continue file_sig = fileutil.file_sig(filepath) file_rec = exportdb.get_file_record(filepath) if file_rec.dest_sig == file_sig: matched += 1 - if verbose: - print(f"[green]Signatures matched[/green]: '{filepath}'") + verbose_(f"[green]Signatures matched[/green]: '{filepath}'") else: notmatched += 1 - if verbose: - print(f"[deep_pink3]Signatures do not match[/deep_pink3]: '{filepath}'") + verbose_(f"[deep_pink3]Signatures do not match[/deep_pink3]: '{filepath}'") return (matched, notmatched, skipped) @@ -171,7 +167,7 @@ def export_db_check_signatures( def export_db_touch_files( dbfile: Union[str, pathlib.Path], export_dir: Union[str, pathlib.Path], - verbose: bool = False, + verbose_: Callable = noop, dry_run: bool = False, ) -> Tuple[int, int, int]: """Touch files on disk to match the Photos library created date @@ -183,8 +179,8 @@ def export_db_touch_files( # open and close exportdb to ensure it gets migrated exportdb = ExportDB(dbfile, export_dir) upgraded = exportdb.was_upgraded - if upgraded and verbose: - print( + if upgraded: + verbose_( f"Upgraded export database {dbfile} from version {upgraded[0]} to {upgraded[1]}" ) exportdb.close() @@ -204,7 +200,6 @@ def export_db_touch_files( # in the mean time, photos_db_path = None will use the default library photos_db_path = None - verbose_ = print if verbose else lambda *args, **kwargs: None photosdb = PhotosDB(dbfile=photos_db_path, verbose=verbose_) exportdb = ExportDB(dbfile, export_dir) c.execute( @@ -223,19 +218,17 @@ def export_db_touch_files( dest_size = row[4] if not filepath.exists(): skipped += 1 - if verbose: - print( - f"[dark_orange]Skipping missing file (not in export directory)[/dark_orange]: '{filepath}'" - ) + verbose_( + f"[dark_orange]Skipping missing file (not in export directory)[/dark_orange]: '{filepath}'" + ) continue photo = photosdb.get_photo(uuid) if not photo: skipped += 1 - if verbose: - print( - f"[dark_orange]Skipping missing photo (did not find in Photos Library)[/dark_orange]: '{filepath}' ({uuid})" - ) + verbose_( + f"[dark_orange]Skipping missing photo (did not find in Photos Library)[/dark_orange]: '{filepath}' ({uuid})" + ) continue ts = int(photo.date.timestamp()) @@ -243,18 +236,16 @@ def export_db_touch_files( mtime = stat.st_mtime if mtime == ts: not_touched += 1 - if verbose: - print( - f"[green]Skipping file (timestamp matches)[/green]: '{filepath}' [dodger_blue1]{isotime_from_ts(ts)} ({ts})[/dodger_blue1]" - ) + verbose_( + f"[green]Skipping file (timestamp matches)[/green]: '{filepath}' [dodger_blue1]{isotime_from_ts(ts)} ({ts})[/dodger_blue1]" + ) continue touched += 1 - if verbose: - print( - f"[deep_pink3]Touching file[/deep_pink3]: '{filepath}' " - f"[dodger_blue1]{isotime_from_ts(mtime)} ({mtime}) -> {isotime_from_ts(ts)} ({ts})[/dodger_blue1]" - ) + verbose_( + f"[deep_pink3]Touching file[/deep_pink3]: '{filepath}' " + f"[dodger_blue1]{isotime_from_ts(mtime)} ({mtime}) -> {isotime_from_ts(ts)} ({ts})[/dodger_blue1]" + ) if not dry_run: os.utime(str(filepath), (ts, ts)) diff --git a/osxphotos/sqlgrep.py b/osxphotos/sqlgrep.py index 000b5d1d..3cad8eff 100644 --- a/osxphotos/sqlgrep.py +++ b/osxphotos/sqlgrep.py @@ -29,7 +29,7 @@ def sqlgrep( flags = re.IGNORECASE if ignore_case else 0 try: with sqlite3.connect(f"file:{filename}?mode=ro", uri=True) as conn: - regex = re.compile(r"(" + pattern + r")", flags=flags) + regex = re.compile(f'({pattern})', flags=flags) filename_header = f"{filename}: " if print_filename else "" conn.row_factory = sqlite3.Row cursor = conn.cursor() @@ -54,4 +54,4 @@ def sqlgrep( field_value, ] except sqlite3.DatabaseError as e: - raise sqlite3.DatabaseError(f"{filename}: {e}") + raise sqlite3.DatabaseError(f"{filename}: {e}") from e diff --git a/requirements.txt b/requirements.txt index 61c1c6fa..ef7354a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,26 +1,25 @@ -Click>=8.0.1,<9.0 -Mako>=1.1.4,<1.2.0 -PyYAML>=5.4.1,<6.0.0 bitmath>=1.3.3.1,<1.4.0.0 bpylist2==3.0.2 -dataclasses==0.7;python_version<'3.7' +Click>=8.0.4,<9.0 +Mako>=1.1.4,<1.2.0 more-itertools>=8.8.0,<9.0.0 -objexplore>=1.5.5,<1.6.0 +objexplore>=1.6.3,<2.0.0 osxmetadata>=0.99.34,<1.0.0 pathvalidate>=2.4.1,<2.5.0 photoscript>=0.1.4,<0.2.0 ptpython>=3.0.20,<3.1.0 pyobjc-core>=7.3,<9.0 -pyobjc-framework-AVFoundation>=7.3,<9.0 pyobjc-framework-AppleScriptKit>=7.3,<9.0 pyobjc-framework-AppleScriptObjC>=7.3,<9.0 +pyobjc-framework-AVFoundation>=7.3,<9.0 pyobjc-framework-Cocoa>=7.3,<9.0 pyobjc-framework-CoreServices>=7.2,<9.0 pyobjc-framework-Metal>=7.3,<9.0 pyobjc-framework-Photos>=7.3,<9.0 pyobjc-framework-Quartz>=7.3,<9.0 pyobjc-framework-Vision>=7.3,<9.0 -rich>=10.6.0,<=11.0.0 +PyYAML>=5.4.1,<6.0.0 +rich>=11.2.0,<12.0.0 textx>=2.3.0,<2.4.0 toml>=0.10.2,<0.11.0 wurlitzer>=2.1.0,<2.2.0 \ No newline at end of file diff --git a/setup.py b/setup.py index f15026b7..ee139482 100755 --- a/setup.py +++ b/setup.py @@ -67,21 +67,19 @@ setup( "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS :: MacOS X", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Topic :: Software Development :: Libraries :: Python Modules", ], install_requires=[ - "Click>=8.0.1,<9.0", + "Click>=8.0.4,<9.0", "Mako>=1.1.4,<1.2.0", "PyYAML>=5.4.1,<5.5.0", "bitmath>=1.3.3.1,<1.4.0.0", "bpylist2==3.0.2", - "dataclasses==0.7;python_version<'3.7'", "more-itertools>=8.8.0,<9.0.0", - "objexplore>=1.5.5,<1.6.0", + "objexplore>=1.6.3,<2.0.0", "osxmetadata>=0.99.34,<1.0.0", "pathvalidate>=2.4.1,<3.0.0", "photoscript>=0.1.4,<0.2.0", @@ -96,7 +94,7 @@ setup( "pyobjc-framework-Photos>=7.3,<9.0", "pyobjc-framework-Quartz>=7.3,<9.0", "pyobjc-framework-Vision>=7.3,<9.0", - "rich>=10.6.0,<=11.0.0", + "rich>=11.2.0,<12.0.0", "textx>=2.3.0,<3.0.0", "toml>=0.10.2,<0.11.0", "wurlitzer>=2.1.0,<3.0.0", diff --git a/tests/conftest.py b/tests/conftest.py index 20497be8..ef8645cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,10 +3,9 @@ import os import pathlib import pytest +from applescript import AppleScript from photoscript.utils import ditto -import osxphotos -from applescript import AppleScript from osxphotos.exiftool import _ExifToolProc @@ -124,3 +123,12 @@ def copy_photos_library_to_path(photos_library_path: str, dest_path: str) -> str """Copy a photos library to a folder""" ditto(photos_library_path, dest_path) return dest_path + + +@pytest.fixture(scope="session", autouse=True) +def delete_crash_logs(): + """Delete left over crash logs from tests that were supposed to crash""" + yield + path = pathlib.Path(os.getcwd()) / "osxphotos_crash.log" + if path.is_file(): + path.unlink() diff --git a/tests/test___all__.py b/tests/test___all__.py deleted file mode 100644 index b6df06a0..00000000 --- a/tests/test___all__.py +++ /dev/null @@ -1,25 +0,0 @@ -import re -import sys - -from os import walk -from collections import Counter - - -FILE_PATTERN = "^(?!_).*\.py$" -SOUCE_CODE_ROOT = "osxphotos" - -def create_module_name(dirpath: str, filename: str) -> str: - prefix = dirpath[dirpath.rfind(SOUCE_CODE_ROOT):].replace('/', '.') - return f"{prefix}.{filename}".replace(".py", "") - - -def test_check_duplicate(): - for dirpath, dirnames, filenames in walk(SOUCE_CODE_ROOT): - print("\n", sys.modules) - for filename in filenames: - if re.search(FILE_PATTERN, filename): - module = create_module_name(dirpath, filename) - if module in sys.modules: - all_list = sys.modules[module].__all__ - all_set = set(all_list) - assert Counter(all_list) == Counter(all_set) \ No newline at end of file diff --git a/tests/test_catalina_10_15_7.py b/tests/test_catalina_10_15_7.py index cd8e9457..14088150 100644 --- a/tests/test_catalina_10_15_7.py +++ b/tests/test_catalina_10_15_7.py @@ -1408,7 +1408,6 @@ def test_exiftool_newlines_in_description(photosdb): @pytest.mark.skip(reason="Test not yet implemented") def test_duplicates_1(photosdb): # test photo has duplicates - photo = photosdb.get_photo(uuid=UUID_DICT["duplicates"]) assert len(photo.duplicates) == 1 assert photo.duplicates[0].uuid == UUID_DUPLICATE diff --git a/tests/test_cli.py b/tests/test_cli.py index cbcdec62..94401914 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,17 +1,41 @@ """ Test the command line interface (CLI) """ +import datetime +import glob +import json +import locale import os +import os.path +import pathlib +import re +import shutil import sqlite3 +import sys import tempfile +import time from tempfile import TemporaryDirectory import pytest from click.testing import CliRunner from conftest import copy_photos_library_to_path +from osxmetadata import OSXMetaData, Tag import osxphotos -from osxphotos.exiftool import get_exiftool_path -from osxphotos.utils import normalize_unicode +from osxphotos._constants import OSXPHOTOS_EXPORT_DB +from osxphotos.cli import ( + about, + albums, + cli_main, + export, + keywords, + labels, + persons, + places, + query, +) +from osxphotos.exiftool import ExifTool, get_exiftool_path +from osxphotos.fileutil import FileUtil +from osxphotos.utils import noop, normalize_fs_path, normalize_unicode CLI_PHOTOS_DB = "tests/Test-10.15.7.photoslibrary" LIVE_PHOTOS_DB = "tests/Test-Cloud-10.15.1.photoslibrary" @@ -991,11 +1015,6 @@ def touch_all_photos_in_db(dbpath): Args: dbpath: path to photos library to touch """ - import os - import time - - import osxphotos - ts = int(time.time()) for photo in osxphotos.PhotosDB(dbpath).photos(): if photo.path is not None: @@ -1010,11 +1029,6 @@ def touch_all_photos_in_db(dbpath): def setup_touch_tests(): """perform setup needed for --touch-file tests""" - import logging - import os - import time - - import osxphotos # touch all photos so they do not match PhotoInfo.date touch_all_photos_in_db(PHOTOS_DB_TOUCH) @@ -1036,11 +1050,9 @@ def setup_touch_tests(): def test_osxphotos(): - import osxphotos - from osxphotos.cli import cli runner = CliRunner() - result = runner.invoke(cli, []) + result = runner.invoke(cli_main, []) output = result.output assert result.exit_code == 0 @@ -1050,11 +1062,9 @@ def test_osxphotos(): def test_osxphotos_help_1(): # test help command no topic - import osxphotos - from osxphotos.cli import cli runner = CliRunner() - result = runner.invoke(cli, ["help"]) + result = runner.invoke(cli_main, ["help"]) output = result.output assert result.exit_code == 0 for line in CLI_OUTPUT_NO_SUBCOMMAND: @@ -1063,29 +1073,24 @@ def test_osxphotos_help_1(): def test_osxphotos_help_2(): # test help command valid topic - import osxphotos - from osxphotos.cli import cli runner = CliRunner() - result = runner.invoke(cli, ["help", "persons"]) + result = runner.invoke(cli_main, ["help", "persons"]) assert result.exit_code == 0 assert "Print out persons (faces) found in the Photos library." in result.output def test_osxphotos_help_3(): # test help command invalid topic - import osxphotos - from osxphotos.cli import cli runner = CliRunner() - result = runner.invoke(cli, ["help", "foo"]) + result = runner.invoke(cli_main, ["help", "foo"]) assert result.exit_code == 0 assert "Invalid command: foo" in result.output def test_about(): """Test about""" - from osxphotos.cli import about runner = CliRunner() cwd = os.getcwd() @@ -1095,12 +1100,6 @@ def test_about(): def test_query_uuid(): - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1136,12 +1135,6 @@ def test_query_uuid(): def test_query_uuid_from_file_1(): """Test query with --uuid-from-file""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1165,11 +1158,6 @@ def test_query_uuid_from_file_1(): def test_query_has_comment(): """Test query with --has-comment""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1187,11 +1175,6 @@ def test_query_has_comment(): def test_query_no_comment(): """Test query with --no-comment""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1211,11 +1194,6 @@ def test_query_no_comment(): def test_query_has_likes(): """Test query with --has-likes""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1232,11 +1210,6 @@ def test_query_has_likes(): def test_query_no_likes(): """Test query with --no-likes""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1256,11 +1229,6 @@ def test_query_no_likes(): def test_query_is_reference(): """Test query with --is-reference""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1277,11 +1245,6 @@ def test_query_is_reference(): def test_query_in_album(): """Test query with --in-album""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1298,11 +1261,6 @@ def test_query_in_album(): def test_query_not_in_album(): """Test query with --not-in-album""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1319,11 +1277,6 @@ def test_query_not_in_album(): def test_query_duplicate(): """Test query with --duplicate""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1341,11 +1294,6 @@ def test_query_duplicate(): def test_query_location(): """Test query with --location""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1364,11 +1312,6 @@ def test_query_location(): def test_query_no_location(): """Test query with --no-location""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1389,11 +1332,6 @@ def test_query_no_location(): @pytest.mark.parametrize("exiftag,exifvalue,uuid_expected", QUERY_EXIF_DATA) def test_query_exif(exiftag, exifvalue, uuid_expected): """Test query with --exif""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1422,11 +1360,6 @@ def test_query_exif(exiftag, exifvalue, uuid_expected): ) def test_query_exif_case_insensitive(exiftag, exifvalue, uuid_expected): """Test query with --exif -i""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -1451,12 +1384,6 @@ def test_query_exif_case_insensitive(exiftag, exifvalue, uuid_expected): def test_export(): - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1470,12 +1397,6 @@ def test_export(): def test_export_uuid_from_file(): """Test export with --uuid-from-file""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1498,12 +1419,6 @@ def test_export_uuid_from_file(): def test_export_skip_uuid_from_file(): """Test export with --skip-uuid-from-file""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1527,12 +1442,6 @@ def test_export_skip_uuid_from_file(): def test_export_skip_uuid(): """Test export with --skip-uuid""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1560,12 +1469,6 @@ def test_export_skip_uuid(): def test_export_preview(): """test export with --preview""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1589,12 +1492,6 @@ def test_export_preview(): def test_export_preview_file_exists(): """test export with --preview when preview images already exist, issue #516""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1631,12 +1528,6 @@ def test_export_preview_file_exists(): def test_export_preview_suffix(): """test export with --preview and --preview-suffix""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1662,12 +1553,6 @@ def test_export_preview_suffix(): def test_export_preview_if_missing(): """test export with --preview_if_missing""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1696,12 +1581,6 @@ def test_export_preview_if_missing(): def test_export_preview_overwrite(): """test export with --preview and --overwrite (#526)""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1740,12 +1619,6 @@ def test_export_preview_overwrite(): def test_export_preview_update(): """test export with --preview and --update (#526)""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1783,12 +1656,6 @@ def test_export_preview_update(): def test_export_as_hardlink(): - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1806,10 +1673,6 @@ def test_export_as_hardlink(): def test_export_as_hardlink_samefile(): # test that --export-as-hardlink actually creates a hardlink # src and dest should be same file - import os - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1835,10 +1698,6 @@ def test_export_as_hardlink_samefile(): def test_export_using_hardlinks_incompat_options(): # test that error shown if --export-as-hardlink used with --exiftool - import os - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1863,12 +1722,6 @@ def test_export_using_hardlinks_incompat_options(): def test_export_current_name(): - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1883,12 +1736,6 @@ def test_export_current_name(): def test_export_skip_edited(): - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1904,12 +1751,6 @@ def test_export_skip_edited(): def test_export_skip_original_if_edited(): """test export with --skip-original-if-edited""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -1941,12 +1782,6 @@ def test_export_skip_original_if_edited(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool(): - import glob - import os - import os.path - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() @@ -1979,12 +1814,6 @@ def test_export_exiftool(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_template_change(): """Test --exiftool when template changes with --update, #630""" - import glob - import os - import os.path - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() @@ -2062,14 +1891,6 @@ def test_export_exiftool_template_change(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_path(): """test --exiftool with --exiftool-path""" - import glob - import os - import os.path - import shutil - import tempfile - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() @@ -2105,17 +1926,6 @@ def test_export_exiftool_path(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_path_render_template(): """test --exiftool-path with {exiftool:} template rendering""" - import glob - import os - import os.path - import re - import shutil - import sys - import tempfile - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool - from osxphotos.utils import noop exiftool_source = osxphotos.exiftool.get_exiftool_path() @@ -2150,12 +1960,6 @@ def test_export_exiftool_path_render_template(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_ignore_date_modified(): - import glob - import os - import os.path - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() @@ -2191,12 +1995,6 @@ def test_export_exiftool_ignore_date_modified(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_quicktime(): """test --exiftol correctly writes QuickTime tags""" - import glob - import os - import os.path - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() @@ -2232,12 +2030,6 @@ def test_export_exiftool_quicktime(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_duplicate_keywords(): """ensure duplicate keywords are removed""" - import glob - import os - import os.path - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() @@ -2264,12 +2056,6 @@ def test_export_exiftool_duplicate_keywords(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_error(): """ " test --exiftool catching error""" - import glob - import os - import os.path - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() @@ -2302,12 +2088,6 @@ def test_export_exiftool_error(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_option(): """test --exiftool-option""" - import glob - import os - import os.path - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() @@ -2340,12 +2120,6 @@ def test_export_exiftool_option(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_merge(): """test --exiftool-merge-keywords and --exiftool-merge-persons""" - import glob - import os - import os.path - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() @@ -2380,13 +2154,6 @@ def test_export_exiftool_merge(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_merge_sidecar(): """test --exiftool-merge-keywords and --exiftool-merge-persons with --sidecar""" - import glob - import json - import os - import os.path - - from osxphotos.cli import export - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() @@ -2431,12 +2198,6 @@ def test_export_exiftool_merge_sidecar(): def test_export_edited_suffix(): """test export with --edited-suffix""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -2459,12 +2220,6 @@ def test_export_edited_suffix(): def test_export_edited_suffix_template(): """test export with --edited-suffix template""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -2487,12 +2242,6 @@ def test_export_edited_suffix_template(): def test_export_original_suffix(): """test export with --original-suffix""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -2515,12 +2264,6 @@ def test_export_original_suffix(): def test_export_original_suffix_template(): """test export with --original-suffix template""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -2547,12 +2290,6 @@ def test_export_original_suffix_template(): ) def test_export_convert_to_jpeg(): """test --convert-to-jpeg""" - import glob - import os - import os.path - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -2574,12 +2311,6 @@ def test_export_convert_to_jpeg(): ) def test_export_convert_to_jpeg_quality(): """test --convert-to-jpeg --jpeg-quality""" - import glob - import os - import os.path - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -2609,12 +2340,6 @@ def test_export_convert_to_jpeg_quality(): ) def test_export_convert_to_jpeg_skip_raw(): """test --convert-to-jpeg""" - import glob - import os - import os.path - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -2637,12 +2362,6 @@ def test_export_convert_to_jpeg_skip_raw(): def test_export_duplicate(): """Test export with --duplicate""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -2659,12 +2378,6 @@ def test_export_duplicate(): def test_export_duplicate_unicode_filenames(): # test issue #515 - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -2704,13 +2417,6 @@ def test_export_duplicate_unicode_filenames(): def test_query_date_1(): """Test --from-date and --to-date""" - import json - import os - import os.path - import time - - import osxphotos - from osxphotos.cli import query os.environ["TZ"] = "US/Pacific" time.tzset() @@ -2735,13 +2441,6 @@ def test_query_date_1(): def test_query_date_2(): """Test --from-date and --to-date""" - import json - import os - import os.path - import time - - import osxphotos - from osxphotos.cli import query os.environ["TZ"] = "Asia/Jerusalem" time.tzset() @@ -2766,13 +2465,6 @@ def test_query_date_2(): def test_query_date_timezone(): """Test --from-date, --to-date with ISO 8601 timezone""" - import json - import os - import os.path - import time - - import osxphotos - from osxphotos.cli import query os.environ["TZ"] = "US/Pacific" time.tzset() @@ -2797,13 +2489,6 @@ def test_query_date_timezone(): def test_query_time(): """Test --from-time, --to-time""" - import json - import os - import os.path - import time - - import osxphotos - from osxphotos.cli import query os.environ["TZ"] = "US/Pacific" time.tzset() @@ -2828,12 +2513,6 @@ def test_query_time(): def test_query_keyword_1(): """Test query --keyword""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -2848,12 +2527,6 @@ def test_query_keyword_1(): def test_query_keyword_2(): """Test query --keyword with lower case keyword""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -2868,12 +2541,6 @@ def test_query_keyword_2(): def test_query_keyword_3(): """Test query --keyword with lower case keyword and --ignore-case""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -2895,12 +2562,6 @@ def test_query_keyword_3(): def test_query_keyword_4(): """Test query with more than one --keyword""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -2923,12 +2584,6 @@ def test_query_keyword_4(): def test_query_person_1(): """Test query --person""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -2943,12 +2598,6 @@ def test_query_person_1(): def test_query_person_2(): """Test query --person with lower case person""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -2963,12 +2612,6 @@ def test_query_person_2(): def test_query_person_3(): """Test query --person with lower case person and --ignore-case""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -2990,12 +2633,6 @@ def test_query_person_3(): def test_query_person_4(): """Test query with multiple --person""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3018,12 +2655,6 @@ def test_query_person_4(): def test_query_album_1(): """Test query --album""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3044,12 +2675,6 @@ def test_query_album_1(): def test_query_album_2(): """Test query --album with lower case album""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3070,12 +2695,6 @@ def test_query_album_2(): def test_query_album_3(): """Test query --album with lower case album and --ignore-case""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3097,12 +2716,6 @@ def test_query_album_3(): def test_query_album_4(): """Test query with multipl --album""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3125,12 +2738,6 @@ def test_query_album_4(): def test_query_label_1(): """Test query --label""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3145,12 +2752,6 @@ def test_query_label_1(): def test_query_label_2(): """Test query --label with lower case label""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3165,12 +2766,6 @@ def test_query_label_2(): def test_query_label_3(): """Test query --label with lower case label and --ignore-case""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3192,12 +2787,6 @@ def test_query_label_3(): def test_query_label_4(): """Test query with more than one --label""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3220,12 +2809,6 @@ def test_query_label_4(): def test_query_deleted_deleted_only(): """Test query with --deleted and --deleted-only""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3244,12 +2827,6 @@ def test_query_deleted_deleted_only(): def test_query_deleted_1(): """Test query with --deleted""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3263,12 +2840,6 @@ def test_query_deleted_1(): def test_query_deleted_2(): """Test query with --deleted""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3282,12 +2853,6 @@ def test_query_deleted_2(): def test_query_deleted_3(): """Test query with --deleted-only""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3302,12 +2867,6 @@ def test_query_deleted_3(): def test_query_deleted_4(): """Test query with --deleted-only""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -3322,19 +2881,13 @@ def test_query_deleted_4(): def test_export_sidecar(): """test --sidecar""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3353,19 +2906,13 @@ def test_export_sidecar(): def test_export_sidecar_drop_ext(): """test --sidecar with --sidecar-drop-ext option""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3385,19 +2932,13 @@ def test_export_sidecar_drop_ext(): def test_export_sidecar_exiftool(): """test --sidecar exiftool""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3416,19 +2957,13 @@ def test_export_sidecar_exiftool(): def test_export_sidecar_templates(): - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3459,19 +2994,13 @@ def test_export_sidecar_templates(): def test_export_sidecar_templates_exiftool(): """test --sidecar exiftool with templates""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3502,21 +3031,13 @@ def test_export_sidecar_templates_exiftool(): def test_export_sidecar_update(): """test sidecar don't update if not changed and do update if changed""" - import datetime - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import cli - from osxphotos.fileutil import FileUtil runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3537,7 +3058,7 @@ def test_export_sidecar_update(): fileutil.unlink(CLI_EXPORT_SIDECAR_FILENAMES[1]) result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3556,7 +3077,7 @@ def test_export_sidecar_update(): # run update again, no sidecar files should update result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3578,7 +3099,7 @@ def test_export_sidecar_update(): fileutil.utime(CLI_EXPORT_SIDECAR_FILENAMES[2], (ts, ts)) result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3597,7 +3118,7 @@ def test_export_sidecar_update(): # run update again, no sidecar files should update result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3616,7 +3137,7 @@ def test_export_sidecar_update(): # run update again with updated metadata, forcing update result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3638,16 +3159,13 @@ def test_export_sidecar_update(): def test_export_sidecar_invalid(): """test invalid combination of sidecars""" - import os - - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -3664,12 +3182,6 @@ def test_export_sidecar_invalid(): def test_export_live(): - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3683,12 +3195,6 @@ def test_export_live(): def test_export_skip_live(): - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3702,12 +3208,6 @@ def test_export_skip_live(): def test_export_raw(): - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3729,12 +3229,6 @@ def test_export_raw(): # TODO: Update this once RAW db is added # def test_skip_raw(): -# import glob -# import os -# import os.path -# import osxphotos -# from osxphotos.cli import export - # runner = CliRunner() # cwd = os.getcwd() # # pylint: disable=not-context-manager @@ -3748,12 +3242,6 @@ def test_export_raw(): def test_export_raw_original(): - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3767,12 +3255,6 @@ def test_export_raw_original(): def test_export_raw_edited(): - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3786,12 +3268,6 @@ def test_export_raw_edited(): def test_export_raw_edited_original(): - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3804,13 +3280,6 @@ def test_export_raw_edited_original(): def test_export_directory_template_1(): # test export using directory template - import glob - import locale - import os - import os.path - - import osxphotos - from osxphotos.cli import export locale.setlocale(locale.LC_ALL, "en_US") @@ -3836,12 +3305,6 @@ def test_export_directory_template_1(): def test_export_directory_template_2(): # test export using directory template with missing substitution value - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3865,12 +3328,6 @@ def test_export_directory_template_2(): def test_export_directory_template_3(): # test export using directory template with unmatched substitution value - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3892,12 +3349,6 @@ def test_export_directory_template_3(): def test_export_directory_template_album_1(): # test export using directory template with multiple albums - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3916,12 +3367,6 @@ def test_export_directory_template_album_1(): def test_export_directory_template_album_2(): # test export using directory template with multiple albums # specify default value - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3949,13 +3394,6 @@ def test_export_directory_template_album_2(): ) def test_export_directory_template_locale(): # test export using directory template in user locale non-US - import glob - import locale - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -3988,13 +3426,6 @@ def test_export_directory_template_locale(): def test_export_filename_template_1(): """export photos using filename template""" - import glob - import locale - import os - import os.path - - import osxphotos - from osxphotos.cli import export locale.setlocale(locale.LC_ALL, "en_US") @@ -4020,13 +3451,6 @@ def test_export_filename_template_1(): def test_export_filename_template_2(): """export photos using filename template with folder_album and path_sep""" - import glob - import locale - import os - import os.path - - import osxphotos - from osxphotos.cli import export locale.setlocale(locale.LC_ALL, "en_US") @@ -4052,13 +3476,6 @@ def test_export_filename_template_2(): def test_export_filename_template_strip(): """export photos using filename template with --strip""" - import glob - import locale - import os - import os.path - - import osxphotos - from osxphotos.cli import export locale.setlocale(locale.LC_ALL, "en_US") @@ -4085,12 +3502,6 @@ def test_export_filename_template_strip(): def test_export_filename_template_pathsep_in_name_1(): """export photos using filename template with folder_album and "/" in album name""" - import locale - import os - import os.path - import pathlib - - from osxphotos.cli import export locale.setlocale(locale.LC_ALL, "en_US") @@ -4118,12 +3529,6 @@ def test_export_filename_template_pathsep_in_name_1(): def test_export_filename_template_pathsep_in_name_2(): """export photos using filename template with keyword and "/" in keyword""" - import locale - import os - import os.path - import pathlib - - from osxphotos.cli import export locale.setlocale(locale.LC_ALL, "en_US") @@ -4152,13 +3557,6 @@ def test_export_filename_template_pathsep_in_name_2(): def test_export_filename_template_long_description(): """export photos using filename template with description that exceeds max length""" - import locale - import os - import os.path - import pathlib - - import osxphotos - from osxphotos.cli import export locale.setlocale(locale.LC_ALL, "en_US") @@ -4185,12 +3583,6 @@ def test_export_filename_template_long_description(): def test_export_filename_template_3(): """test --filename with invalid template""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4212,12 +3604,6 @@ def test_export_filename_template_3(): def test_export_album(): """Test export of an album""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4234,11 +3620,6 @@ def test_export_album(): def test_export_album_unicode_name(): """Test export of an album with non-English characters in name""" - import glob - import os - import os.path - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4261,12 +3642,6 @@ def test_export_album_unicode_name(): def test_export_album_deleted_twin(): """Test export of an album where album of same name has been deleted""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4289,12 +3664,6 @@ def test_export_album_deleted_twin(): def test_export_deleted_1(): """Test export with --deleted""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4316,12 +3685,6 @@ def test_export_deleted_1(): def test_export_deleted_2(): """Test export with --deleted""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4343,12 +3706,6 @@ def test_export_deleted_2(): def test_export_not_deleted_1(): """Test export does not find intrash files without --deleted flag""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4363,12 +3720,6 @@ def test_export_not_deleted_1(): def test_export_not_deleted_2(): """Test export does not find intrash files without --deleted flag""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4383,12 +3734,6 @@ def test_export_not_deleted_2(): def test_export_deleted_only_1(): """Test export with --deleted-only""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4405,12 +3750,6 @@ def test_export_deleted_only_1(): def test_export_deleted_only_2(): """Test export with --deleted-only""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4430,8 +3769,6 @@ def test_export_error(monkeypatch): # Note: I often comment out the try/except block in cli.py::export_photo_with_template when # debugging to see exactly where the error is # this test verifies I've re-enabled that code - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4454,12 +3791,6 @@ def test_export_error(monkeypatch): @pytest.mark.parametrize("exiftag,exifvalue,files_expected", EXPORT_EXIF_DATA) def test_export_exif(exiftag, exifvalue, files_expected): """Test export --exif query""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -4474,12 +3805,6 @@ def test_export_exif(exiftag, exifvalue, files_expected): def test_places(): - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import places runner = CliRunner() cwd = os.getcwd() @@ -4493,12 +3818,6 @@ def test_places(): def test_place_13(): # test --place on 10.13 - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -4517,12 +3836,6 @@ def test_place_13(): def test_no_place_13(): # test --no-place on 10.13 - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -4540,12 +3853,6 @@ def test_no_place_13(): def test_place_15_1(): # test --place on 10.15 - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -4564,12 +3871,6 @@ def test_place_15_1(): def test_place_15_2(): # test --place on 10.15 - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -4590,12 +3891,6 @@ def test_place_15_2(): def test_no_place_15(): # test --no-place on 10.15 - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -4613,12 +3908,6 @@ def test_no_place_15(): def test_no_folder_1_15(): # test --folder on 10.15 - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -4652,12 +3941,6 @@ def test_no_folder_1_15(): def test_no_folder_2_15(): # test --folder with --uuid on 10.15 - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -4687,12 +3970,6 @@ def test_no_folder_2_15(): def test_no_folder_1_14(): # test --folder on 10.14 - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -4708,20 +3985,13 @@ def test_no_folder_1_14(): def test_export_sidecar_keyword_template(): - import glob - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -4830,12 +4100,6 @@ def test_export_sidecar_keyword_template(): def test_export_update_basic(): """test export then update""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import OSXPHOTOS_EXPORT_DB, export runner = CliRunner() cwd = os.getcwd() @@ -4861,12 +4125,6 @@ def test_export_update_basic(): def test_export_force_update(): """test export with --force-update""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import OSXPHOTOS_EXPORT_DB, export runner = CliRunner() cwd = os.getcwd() @@ -4949,12 +4207,6 @@ def test_export_force_update(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_update_complex(): """test complex --update scenario, #630""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import OSXPHOTOS_EXPORT_DB, export runner = CliRunner() cwd = os.getcwd() @@ -5065,11 +4317,6 @@ def test_export_update_complex(): ) def test_export_live_edited(): """test export of edited live image #576""" - import glob - import os - import os.path - - from osxphotos.cli import OSXPHOTOS_EXPORT_DB, export runner = CliRunner() cwd = os.getcwd() @@ -5094,12 +4341,6 @@ def test_export_live_edited(): def test_export_update_child_folder(): """test export then update into a child folder of previous export""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import OSXPHOTOS_EXPORT_DB, export runner = CliRunner() cwd = os.getcwd() @@ -5121,12 +4362,6 @@ def test_export_update_child_folder(): def test_export_update_parent_folder(): """test export then update into a parent folder of previous export""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import OSXPHOTOS_EXPORT_DB, export runner = CliRunner() cwd = os.getcwd() @@ -5148,12 +4383,6 @@ def test_export_update_parent_folder(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_update_exiftool(): """test export then update with exiftool""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -5188,12 +4417,6 @@ def test_export_update_exiftool(): def test_export_update_hardlink(): """test export with hardlink then update""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export photosdb = osxphotos.PhotosDB(dbfile=CLI_PHOTOS_DB) photo = photosdb.photos(uuid=[CLI_EXPORT_UUID])[0] @@ -5227,12 +4450,6 @@ def test_export_update_hardlink(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_update_hardlink_exiftool(): """test export with hardlink then update with exiftool""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export photosdb = osxphotos.PhotosDB(dbfile=CLI_PHOTOS_DB) photo = photosdb.photos(uuid=[CLI_EXPORT_UUID])[0] @@ -5265,13 +4482,6 @@ def test_export_update_hardlink_exiftool(): def test_export_update_edits(): """test export then update after removing and editing files""" - import glob - import os - import os.path - import shutil - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -5302,13 +4512,6 @@ def test_export_update_edits(): def test_export_update_only_new(): """test --update --only-new""" - import glob - import os - import os.path - import time - - import osxphotos - from osxphotos.cli import OSXPHOTOS_EXPORT_DB, export os.environ["TZ"] = "US/Pacific" time.tzset() @@ -5364,12 +4567,6 @@ def test_export_update_only_new(): def test_export_update_no_db(): """test export then update after db has been deleted""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import OSXPHOTOS_EXPORT_DB, export runner = CliRunner() cwd = os.getcwd() @@ -5398,12 +4595,6 @@ def test_export_update_no_db(): def test_export_then_hardlink(): """test export then hardlink""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export photosdb = osxphotos.PhotosDB(dbfile=CLI_PHOTOS_DB) photo = photosdb.photos(uuid=[CLI_EXPORT_UUID])[0] @@ -5438,13 +4629,6 @@ def test_export_then_hardlink(): def test_export_dry_run(): """test export with dry-run flag""" - import os - import os.path - import re - - import osxphotos - from osxphotos.cli import export - from osxphotos.utils import normalize_fs_path runner = CliRunner() cwd = os.getcwd() @@ -5465,13 +4649,6 @@ def test_export_dry_run(): def test_export_update_edits_dry_run(): """test export then update after removing and editing files with dry-run flag""" - import glob - import os - import os.path - import shutil - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -5511,13 +4688,6 @@ def test_export_update_edits_dry_run(): def test_export_directory_template_1_dry_run(): """test export using directory template with dry-run flag""" - import locale - import os - import os.path - import re - - import osxphotos - from osxphotos.cli import export locale.setlocale(locale.LC_ALL, "en_US") @@ -5549,11 +4719,6 @@ def test_export_directory_template_1_dry_run(): def test_export_touch_files(): """test export with --touch-files""" - import os - import time - - import osxphotos - from osxphotos.cli import export os.environ["TZ"] = "US/Pacific" time.tzset() @@ -5592,12 +4757,6 @@ def test_export_touch_files(): def test_export_touch_files_update(): """test complex export scenario with --update and --touch-files""" - import os - import pathlib - import time - - import osxphotos - from osxphotos.cli import export os.environ["TZ"] = "US/Pacific" time.tzset() @@ -5744,12 +4903,6 @@ def test_export_touch_files_update(): # @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_touch_files_exiftool_update(): """test complex export scenario with --update, --exiftool, and --touch-files""" - import os - import pathlib - import time - - import osxphotos - from osxphotos.cli import export os.environ["TZ"] = "US/Pacific" time.tzset() @@ -5919,9 +5072,6 @@ def test_export_touch_files_exiftool_update(): def test_export_ignore_signature(): """test export with --ignore-signature""" - import os - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -5976,11 +5126,6 @@ def test_export_ignore_signature_sidecar(): If a sidecar does not exist for the photo, a sidecar will be written whether or not the photo file was written """ - import os - - import osxphotos - from osxphotos.cli import export - runner = CliRunner() cwd = os.getcwd() @@ -6103,12 +5248,6 @@ def test_export_ignore_signature_sidecar(): def test_labels(): """Test osxphotos labels""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import labels runner = CliRunner() cwd = os.getcwd() @@ -6123,12 +5262,6 @@ def test_labels(): def test_keywords(): """Test osxphotos keywords""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import keywords runner = CliRunner() cwd = os.getcwd() @@ -6145,11 +5278,6 @@ def test_keywords(): # do with how pytest is invoking the command # def test_albums_str(): # """Test osxphotos albums string output """ -# import json -# import osxphotos -# import os -# import os.path -# from osxphotos.cli import albums # runner = CliRunner() # cwd = os.getcwd() @@ -6161,12 +5289,6 @@ def test_keywords(): def test_albums_json(): """Test osxphotos albums json output""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import albums runner = CliRunner() cwd = os.getcwd() @@ -6181,12 +5303,6 @@ def test_albums_json(): def test_persons(): """Test osxphotos persons""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import persons runner = CliRunner() cwd = os.getcwd() @@ -6201,12 +5317,6 @@ def test_persons(): def test_export_report(): """test export with --report option""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6223,12 +5333,6 @@ def test_export_report(): def test_export_report_not_a_file(): """test export with --report option and bad report value""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6243,12 +5347,6 @@ def test_export_report_not_a_file(): def test_export_as_hardlink_download_missing(): """test export with incompatible export options""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6271,12 +5369,6 @@ def test_export_as_hardlink_download_missing(): def test_export_missing(): """test export with --missing""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6299,12 +5391,6 @@ def test_export_missing(): def test_export_missing_not_download_missing(): """test export with incompatible export options""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6319,9 +5405,6 @@ def test_export_missing_not_download_missing(): def test_export_cleanup(): """test export with --cleanup flag""" - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6366,10 +5449,6 @@ def test_export_cleanup(): def test_export_cleanup_empty_album(): """test export with --cleanup flag with an empty album (#481)""" - import pathlib - import tempfile - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6410,9 +5489,6 @@ def test_export_cleanup_empty_album(): def test_export_cleanup_accented_album_name(): """test export with --cleanup flag and photos in album with accented unicode characters (#561, #618)""" - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6453,9 +5529,6 @@ def test_export_cleanup_accented_album_name(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_cleanup_exiftool_accented_album_name_same_filenames(): """test export with --cleanup flag and photos in album with accented unicode characters (#561, #618)""" - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6518,12 +5591,6 @@ def test_export_cleanup_exiftool_accented_album_name_same_filenames(): def test_save_load_config(): """test --save-config, --load-config""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6620,11 +5687,6 @@ def test_save_load_config(): def test_config_only(): """test --save-config, --config-only""" - import glob - import os - import os.path - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6655,13 +5717,6 @@ def test_config_only(): def test_export_exportdb(): """test --exportdb""" - import glob - import os - import os.path - import re - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6713,13 +5768,6 @@ def test_export_exportdb(): def test_export_exportdb_ramdb(): """test --exportdb --ramdb""" - import glob - import os - import os.path - import re - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6760,13 +5808,6 @@ def test_export_exportdb_ramdb(): def test_export_ramdb(): """test --ramdb""" - import glob - import os - import os.path - import re - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6802,13 +5843,6 @@ def test_export_ramdb(): def test_export_finder_tag_keywords(): """test --finder-tag-keywords""" - import glob - import os - import os.path - - from osxmetadata import OSXMetaData, Tag - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6883,13 +5917,6 @@ def test_export_finder_tag_keywords(): def test_export_finder_tag_template(): """test --finder-tag-template""" - import glob - import os - import os.path - - from osxmetadata import OSXMetaData, Tag - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -6967,13 +5994,6 @@ def test_export_finder_tag_template(): def test_export_finder_tag_template_multiple(): """test --finder-tag-template used more than once""" - import glob - import os - import os.path - - from osxmetadata import OSXMetaData, Tag - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7007,13 +6027,6 @@ def test_export_finder_tag_template_multiple(): def test_export_finder_tag_template_keywords(): """test --finder-tag-template with --finder-tag-keywords""" - import glob - import os - import os.path - - from osxmetadata import OSXMetaData, Tag - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7046,13 +6059,6 @@ def test_export_finder_tag_template_keywords(): def test_export_finder_tag_template_multi_field(): """test --finder-tag-template with multiple fields (issue #422)""" - import glob - import os - import os.path - - from osxmetadata import OSXMetaData, Tag - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7082,13 +6088,6 @@ def test_export_finder_tag_template_multi_field(): def test_export_xattr_template(): """test --xattr template""" - import glob - import os - import os.path - - from osxmetadata import OSXMetaData - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7177,11 +6176,6 @@ def test_export_xattr_template(): def test_export_jpeg_ext(): """test --jpeg-ext""" - import glob - import os - import os.path - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7219,11 +6213,6 @@ def test_export_jpeg_ext(): def test_export_jpeg_ext_not_jpeg(): """test --jpeg-ext with non-jpeg files""" - import glob - import os - import os.path - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7261,11 +6250,6 @@ def test_export_jpeg_ext_not_jpeg(): def test_export_jpeg_ext_edited_movie(): """test --jpeg-ext doesn't change extension on edited movie (issue #366)""" - import glob - import os - import os.path - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7310,11 +6294,6 @@ def test_export_jpeg_ext_edited_movie(): ) def test_export_jpeg_ext_convert_to_jpeg(): """test --jpeg-ext with --convert-to-jpeg""" - import glob - import os - import os.path - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7345,11 +6324,6 @@ def test_export_jpeg_ext_convert_to_jpeg(): ) def test_export_jpeg_ext_convert_to_jpeg_movie(): """test --jpeg-ext with --convert-to-jpeg and a movie, shouldn't convert or change extensions, #366""" - import glob - import os - import os.path - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7384,12 +6358,6 @@ def test_export_jpeg_ext_convert_to_jpeg_movie(): ) def test_export_burst_folder_album(): """test non-selected burst photos are exported with the album their key photo is in, issue #401""" - import glob - import os - import os.path - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7421,12 +6389,6 @@ def test_export_burst_folder_album(): ) def test_export_burst_uuid(): """test non-selected burst photos are exported when image is specified by --uuid, #640""" - import glob - import os - import os.path - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7469,12 +6431,6 @@ def test_export_burst_uuid(): ) def test_export_download_missing_file_exists(): """test --download-missing with file exists and --update, issue #456""" - import glob - import os - import os.path - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7519,12 +6475,6 @@ def test_export_download_missing_file_exists(): ) def test_export_download_missing_preview(): """test --download-missing --preview, #564""" - import glob - import os - import os.path - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7555,12 +6505,6 @@ def test_export_download_missing_preview(): ) def test_export_download_missing_preview_applesccript(): """test --download-missing --preview and applescript download, #564""" - import glob - import os - import os.path - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7590,11 +6534,6 @@ def test_export_download_missing_preview_applesccript(): ) def test_export_skip_live_photokit(): """test that --skip-live works with --use-photokit (issue #537)""" - import os - import os.path - import pathlib - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7623,12 +6562,6 @@ def test_export_skip_live_photokit(): def test_query_name(): """test query --name""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7645,12 +6578,6 @@ def test_query_name(): def test_query_name_unicode(): """test query --name with a unicode name""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7669,12 +6596,6 @@ def test_query_name_unicode(): def test_query_name_i(): """test query --name -i""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7698,11 +6619,6 @@ def test_query_name_i(): def test_query_name_original_filename(): """test query --name only searches original filename on Photos 5+""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7718,11 +6634,6 @@ def test_query_name_original_filename(): def test_query_name_original_filename_i(): """test query --name only searches original filename on Photos 5+ with -i""" - import json - import os - import os.path - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7738,12 +6649,6 @@ def test_query_name_original_filename_i(): def test_export_name(): """test export --name""" - import glob - import os - import os.path - - import osxphotos - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7759,9 +6664,6 @@ def test_export_name(): def test_query_eval(): """test export --query-eval""" - import glob - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7784,9 +6686,6 @@ def test_query_eval(): def test_bad_query_eval(): """test export --query-eval with bad input""" - import glob - - from osxphotos.cli import export runner = CliRunner() cwd = os.getcwd() @@ -7808,12 +6707,6 @@ def test_bad_query_eval(): def test_query_min_size_1(): """test query --min-size""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7829,12 +6722,6 @@ def test_query_min_size_1(): def test_query_min_size_2(): """test query --min-size""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7856,12 +6743,6 @@ def test_query_min_size_2(): def test_query_max_size_1(): """test query --max-size""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7877,12 +6758,6 @@ def test_query_max_size_1(): def test_query_max_size_2(): """test query --max-size""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7898,12 +6773,6 @@ def test_query_max_size_2(): def test_query_min_max_size(): """test query --max-size with --min-size""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7927,12 +6796,6 @@ def test_query_min_max_size(): def test_query_min_size_error(): """test query --max-size with invalid size""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7945,12 +6808,6 @@ def test_query_min_size_error(): def test_query_regex_1(): """test query --regex against title""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -7973,12 +6830,6 @@ def test_query_regex_1(): def test_query_regex_2(): """test query --regex with no match""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -8001,12 +6852,6 @@ def test_query_regex_2(): def test_query_regex_3(): """test query --regex with --ignore-case""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -8030,12 +6875,6 @@ def test_query_regex_3(): def test_query_regex_4(): """test query --regex against album""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -8058,12 +6897,6 @@ def test_query_regex_4(): def test_query_regex_multiple(): """test query multiple --regex values (#525)""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -8089,9 +6922,6 @@ def test_query_regex_multiple(): def test_query_function(): """test query --query-function""" - import json - - from osxphotos.cli import query runner = CliRunner() cwd = os.getcwd() @@ -8122,12 +6952,6 @@ def test_query_function(): def test_export_export_dir_template(): """Test {export_dir} template""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() @@ -8135,7 +6959,7 @@ def test_export_export_dir_template(): with runner.isolated_filesystem(): isolated_cwd = os.getcwd() result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8159,12 +6983,6 @@ def test_export_export_dir_template(): def test_export_filepath_template(): """Test {filepath} template""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() @@ -8172,7 +6990,7 @@ def test_export_filepath_template(): with runner.isolated_filesystem(): isolated_cwd = os.getcwd() result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8198,16 +7016,13 @@ def test_export_filepath_template(): def test_export_post_command(): """Test --post-command""" - import os.path - - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8228,7 +7043,7 @@ def test_export_post_command(): # run again with --update to test skipped result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8251,16 +7066,13 @@ def test_export_post_command(): def test_export_post_command_bad_command(): """Test --post-command with bad command""" - import os.path - - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8280,9 +7092,6 @@ def test_export_post_command_bad_command(): def test_export_post_function(): """Test --post-function""" - import os.path - - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() @@ -8295,7 +7104,7 @@ def test_export_post_function(): tempdir = os.getcwd() result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8315,9 +7124,6 @@ def test_export_post_function(): def test_export_post_function_exception(): """Test --post-function that generates an exception""" - import os.path - - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() @@ -8333,7 +7139,7 @@ def test_export_post_function_exception(): tempdir = os.getcwd() result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8353,9 +7159,6 @@ def test_export_post_function_exception(): def test_export_post_function_bad_value(): """Test --post-function option validation""" - import os.path - - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() @@ -8371,7 +7174,7 @@ def test_export_post_function_bad_value(): tempdir = os.getcwd() result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8391,10 +7194,6 @@ def test_export_post_function_bad_value(): def test_export_directory_template_function(): """Test --directory with template function""" - import os.path - import pathlib - - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() @@ -8405,7 +7204,7 @@ def test_export_directory_template_function(): tempdir = os.getcwd() result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8424,9 +7223,6 @@ def test_export_directory_template_function(): def test_export_query_function(): """Test --query-function""" - import os.path - - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() @@ -8442,7 +7238,7 @@ def test_export_query_function(): tempdir = os.getcwd() result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8460,9 +7256,6 @@ def test_export_query_function(): def test_export_album_seq(): """Test {album_seq} template""" - import glob - - from osxphotos.cli import cli runner = CliRunner() cwd = os.getcwd() @@ -8470,7 +7263,7 @@ def test_export_album_seq(): with runner.isolated_filesystem(): for uuid in UUID_DICT_FOLDER_ALBUM_SEQ: result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8498,20 +7291,13 @@ def test_export_album_seq(): @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_description_template(): """Test for issue #506""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import cli - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8532,20 +7318,13 @@ def test_export_description_template(): def test_export_description_template_conditional(): """Test for issue #506""" - import json - import os - import os.path - - import osxphotos - from osxphotos.cli import cli - from osxphotos.exiftool import ExifTool runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - cli, + cli_main, [ "export", "--db", @@ -8566,3 +7345,17 @@ def test_export_description_template_conditional(): assert ( json_got["EXIF:ImageDescription"] == DESCRIPTION_VALUE_TITLE_CONDITIONAL ) + + +def test_export_min_size_1(): + """test export --min-size""" + + runner = CliRunner() + cwd = os.getcwd() + with runner.isolated_filesystem(): + result = runner.invoke( + export, + [".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--min-size", "10MB"], + ) + assert result.exit_code == 0 + assert "Exporting 4 photos" in result.output