From 007f0e09601555136a6228ef8de06c1e8bbc6d82 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sun, 5 Feb 2023 14:48:42 -0800 Subject: [PATCH] Feature add query command (#970) * Added query_command and example * Refactored QUERY_OPTIONS, added query_command, refactored verbose, #930, #931 * Added query options to debug-dump, #966 * Refactored query, #602 * Added precedence test for --load-config * Refactored handling of query options * Refactored export_photo * Removed extraneous print * Updated API_README * Updated examples --- API_README.md | 749 ++++++++++++++++++------- examples/cli_example_1.py | 67 +++ examples/cli_example_2.py | 160 ++++++ examples/cli_example_3.py | 60 ++ osxphotos/__init__.py | 3 +- osxphotos/cli/__init__.py | 29 +- osxphotos/cli/add_locations.py | 2 +- osxphotos/cli/albums.py | 11 +- osxphotos/cli/cli.py | 8 +- osxphotos/cli/cli_commands.py | 305 ++++++++++ osxphotos/cli/cli_params.py | 677 ++++++++++++++++++++++ osxphotos/cli/common.py | 547 +----------------- osxphotos/cli/debug_dump.py | 48 +- osxphotos/cli/docs.py | 4 +- osxphotos/cli/dump.py | 12 +- osxphotos/cli/exiftool_cli.py | 9 +- osxphotos/cli/export.py | 186 +----- osxphotos/cli/exportdb.py | 2 +- osxphotos/cli/grep.py | 3 +- osxphotos/cli/import_cli.py | 5 +- osxphotos/cli/info.py | 3 +- osxphotos/cli/install_uninstall_run.py | 15 +- osxphotos/cli/keywords.py | 3 +- osxphotos/cli/kvstore.py | 50 ++ osxphotos/cli/labels.py | 3 +- osxphotos/cli/list.py | 2 +- osxphotos/cli/orphans.py | 9 +- osxphotos/cli/param_types.py | 21 + osxphotos/cli/persons.py | 3 +- osxphotos/cli/photo_inspect.py | 3 +- osxphotos/cli/places.py | 3 +- osxphotos/cli/query.py | 287 +--------- osxphotos/cli/repl.py | 9 +- osxphotos/cli/snap_diff.py | 13 +- osxphotos/cli/sync.py | 16 +- osxphotos/cli/timewarp.py | 2 +- osxphotos/cli/verbose.py | 13 +- osxphotos/queryoptions.py | 16 +- osxphotos/sqlitekvstore.py | 11 + tests/test_cli.py | 130 ++++- tests/test_cli_all_commands.py | 96 ++++ tests/test_sqlitekvstore.py | 61 +- 42 files changed, 2322 insertions(+), 1334 deletions(-) create mode 100644 examples/cli_example_1.py create mode 100644 examples/cli_example_2.py create mode 100644 examples/cli_example_3.py create mode 100644 osxphotos/cli/cli_commands.py create mode 100644 osxphotos/cli/cli_params.py create mode 100644 osxphotos/cli/kvstore.py create mode 100644 tests/test_cli_all_commands.py diff --git a/API_README.md b/API_README.md index bec4e362..b168b616 100644 --- a/API_README.md +++ b/API_README.md @@ -7,6 +7,7 @@ In addition to a command line interface, OSXPhotos provides a access to a Python * [Example uses of the Python package](#example-uses-of-the-python-package) * [Package Interface](#package-interface) * [PhotosDB](#photosdb) + * [QueryOptions](#queryoptions) * [PhotoInfo](#photoinfo) * [ExifInfo](#exifinfo) * [AlbumInfo](#albuminfo) @@ -32,124 +33,348 @@ In addition to a command line interface, OSXPhotos provides a access to a Python ## Example uses of the Python package +### Print filename, date created, title, and keywords for all photos in a library + ```python -""" Simple usage of the package """ -import os.path +"""print filename, date created, title, and keywords for all photos in Photos library""" import osxphotos -def main(): - db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary") - photosdb = osxphotos.PhotosDB(db) - print(photosdb.keywords) - print(photosdb.persons) - print(photosdb.album_names) - - print(photosdb.keywords_as_dict) - print(photosdb.persons_as_dict) - print(photosdb.albums_as_dict) - - # find all photos with Keyword = Foo and containing John Smith - photos = photosdb.photos(keywords=["Foo"],persons=["John Smith"]) - - # find all photos that include Alice Smith but do not contain the keyword Bar - photos = [p for p in photosdb.photos(persons=["Alice Smith"]) - if p not in photosdb.photos(keywords=["Bar"]) ] - for p in photos: - print( - p.uuid, - p.filename, - p.original_filename, - p.date, - p.description, - p.title, - p.keywords, - p.albums, - p.persons, - p.path, - ) - if __name__ == "__main__": - main() + photosdb = osxphotos.PhotosDB() + for photo in photosdb.photos(): + print(photo.original_filename, photo.date, photo.title, photo.keywords) + ``` -```python -""" Export all photos to specified directory using album names as folders - If file has been edited, also export the edited version, - otherwise, export the original version - This will result in duplicate photos if photo is in more than album """ +The primary interface to the Photos library is the [PhotosDB](#photosdb) object. The [PhotosDB](#photosdb) object provides access to the photos in the library via the [photos](#photosdbphotos) method and the [query](#photosdbquery). These methods returns a list of [PhotoInfo](#photoinfo) objects, one for each photo in the library. The [PhotoInfo](#photoinfo) object provides access to the metadata for each photo. -import os.path -import pathlib -import sys +### Building simple command line tools + +osxphotos provides several useful helper functions to make it easy to build simple command line tools. For example, the following code will print information about all photos in a library or a subset of photos filtered by one or more query options. This mirrors the `osxphotos query` command line tool. Tools built using these helper functions can be easily distributed as a single file and run via `osxphotos run script.py` so the user doesn't need to install python, any dependencies, or create a virtual environment. + +Here's a simple example showing how to use the `query_command` decorator to implement a simple command line tool. The `query_command` decorator turns your function into a full-fledged [Click](https://palletsprojects.com/p/click/) command line app that can be run via `osxphotos run example.py` or `python example.py` if you have pip installed osxphotos. Your command will include all the query options available in `osxphotos query` as command line options as well as `--verbose` and other convenient options. + + +```python +"""Sample query command for osxphotos + +This shows how simple it is to create a command line tool using osxphotos to process your photos. + +Using the @query_command decorator turns your function to a full-fledged command line app that +can be run via `osxphotos run cli_example_1.py` or `python cli_example_1.py` if you have pip installed osxphotos. + +Using this decorator makes it very easy to create a quick command line tool that can operate on +a subset of your photos. Additionally, writing a command in this way makes it easy to later +incorporate the command into osxphotos as a full-fledged command. + +The decorator will add all the query options available in `osxphotos query` as command line options +as well as the following options: +--verbose +--timestamp +--theme +--db +--debug (hidden, won't show in help) + +The decorated function will perform the query and pass the list of filtered PhotoInfo objects +to your function. You can then do whatever you want with the photos. + +For example, to run the command on only selected photos: + + osxphotos run cli_example_1.py --selected + +To run the command on all photos with the keyword "foo": + + osxphotos run cli_example_1.py --keyword foo + +For more advanced example, see `cli_example_2.py` +""" + +from __future__ import annotations + +import osxphotos +from osxphotos.cli import query_command, verbose + + +@query_command +def example(photos: list[osxphotos.PhotoInfo], **kwargs): + """Sample query command for osxphotos. Prints out the filename and date of each photo. + + Whatever text you put in the function's docstring here, will be used as the command's + help text when run via `osxphotos run cli_example_1.py --help` or `python cli_example_1.py --help` + """ + + # verbose() will print to stdout if --verbose option is set + # you can optionally provide a level (default is 1) to print only if --verbose is set to that level + # for example: -VV or --verbose --verbose == level 2 + verbose(f"Found {len(photos)} photo(s)") + verbose("This message will only be printed if verbose level 2 is set", level=2) + + # do something with photos here + for photo in photos: + # photos is a list of PhotoInfo objects + # see: https://rhettbull.github.io/osxphotos/reference.html#osxphotos.PhotoInfo + verbose(f"Processing {photo.original_filename}") + print(f"{photo.original_filename} {photo.date}") + ... + + +if __name__ == "__main__": + # call your function here + # you do not need to pass any arguments to the function + # as the decorator will handle parsing the command line arguments + example() +``` + + +Here is a more advanced example that shows how to implement a script with a "dry run" and "resume" capability that preserves state between runs. Using the built-in helpers allows you to implement complex behavior in just a few lines of code. + + +```python +"""Sample query command for osxphotos + +This shows how simple it is to create a command line tool using osxphotos to process your photos. + +Using the @query_command decorator turns your function to a full-fledged command line app that +can be run via `osxphotos run cli_example_2.py` or `python cli_example_2.py` if you have pip installed osxphotos. + +Using this decorator makes it very easy to create a quick command line tool that can operate on +a subset of your photos. Additionally, writing a command in this way makes it easy to later +incorporate the command into osxphotos as a full-fledged command. + +The decorator will add all the query options available in `osxphotos query` as command line options +as well as the following options: +--verbose +--timestamp +--theme +--db +--debug (hidden, won't show in help) + +The decorated function will perform the query and pass the list of filtered PhotoInfo objects +to your function. You can then do whatever you want with the photos. + +For example, to run the command on only selected photos: + + osxphotos run cli_example_2.py --selected + +To run the command on all photos with the keyword "foo": + + osxphotos run cli_example_2.py --keyword foo + +The following helper functions may be useful and can be imported from osxphotos.cli: + + abort(message: str, exit_code: int = 1) + Abort with error message and exit code + echo(message: str) + Print message to stdout using rich formatting + echo_error(message: str) + Print message to stderr using rich formatting + logger: logging.Logger + Python logger for osxphotos; for example, logger.debug("debug message") + verbose(*args, level: int = 1) + Print args to stdout if --verbose option is set + query_command: decorator to create an osxphotos query command + kvstore(name: str) -> SQLiteKVStore useful for storing state between runs + +The verbose, echo, and echo_error functions use rich formatting to print messages to stdout and stderr. +See https://github.com/Textualize/rich for more information on rich formatting. + +In addition to standard rich formatting styles, the following styles will be defined +(and can be changed using --theme): + + [change]: something change + [no_change]: indicate no change + [count]: a count + [error]: an error + [filename]: a filename + [filepath]: a filepath + [num]: a number + [time]: a time or date + [tz]: a timezone + [warning]: a warning + [uuid]: a uuid + +The tags should be closed with [/] to end the style. For example: + + echo("[filename]foo[/] [time]bar[/]") + +For simpler examples, see `cli_example_1.py` +""" + +from __future__ import annotations + +import datetime import click -from pathvalidate import is_valid_filepath, sanitize_filepath import osxphotos +from osxphotos.cli import ( + abort, + echo, + echo_error, + kvstore, + logger, + query_command, + verbose, +) -@click.command() -@click.argument("export_path", type=click.Path(exists=True)) +@query_command() @click.option( - "--default-album", - help="Default folder for photos with no album. Defaults to 'unfiled'", - default="unfiled", + "--resume", + is_flag=True, + help="Resume processing from last run, do not reprocess photos", ) @click.option( - "--library-path", - help="Path to Photos library, default to last used library", - default=None, + "--dry-run", is_flag=True, help="Do a dry run, don't actually do anything" ) -def export(export_path, default_album, library_path): - export_path = os.path.expanduser(export_path) - library_path = os.path.expanduser(library_path) if library_path else None +def example(resume, dry_run, photos: list[osxphotos.PhotoInfo], **kwargs): + """Sample query command for osxphotos. Prints out the filename and date of each photo. - if library_path is not None: - photosdb = osxphotos.PhotosDB(library_path) - else: - photosdb = osxphotos.PhotosDB() + Whatever text you put in the function's docstring here, will be used as the command's + help text when run via `osxphotos run cli_example_2.py --help` or `python cli_example_2.py --help` - photos = photosdb.photos() + The @query_command decorator returns a click.command so you can add additional options + using standard click decorators. For example, the --resume and --dry-run options. + For more information on click, see https://palletsprojects.com/p/click/. + """ - for p in photos: - if not p.ismissing: - albums = p.albums - if not albums: - albums = [default_album] - for album in albums: - click.echo(f"exporting {p.filename} in album {album}") + # abort will print the message to stderr and exit with the given exit code + if not photos: + abort("Nothing to do!", 1) - # make sure no invalid characters in destination path (could be in album name) - album_name = sanitize_filepath(album, platform="auto") + # verbose() will print to stdout if --verbose option is set + # you can optionally provide a level (default is 1) to print only if --verbose is set to that level + # for example: -VV or --verbose --verbose == level 2 + verbose(f"Found [count]{len(photos)}[/] photos") + verbose("This message will only be printed if verbose level 2 is set", level=2) - # create destination folder, if necessary, based on album name - dest_dir = os.path.join(export_path, album_name) + # the logger is a python logging.Logger object + # debug messages will only be printed if --debug option is set + logger.debug(f"{kwargs=}") - # verify path is a valid path - if not is_valid_filepath(dest_dir, platform="auto"): - sys.exit(f"Invalid filepath {dest_dir}") + # kvstore() returns a SQLiteKVStore object for storing state between runs + # this is basically a persistent dictionary that can be used to store state + # see https://github.com/RhetTbull/sqlitekvstore for more information + kv = kvstore("cli_example_2") + verbose(f"Using key-value cache: {kv.path}") - # create destination dir if needed - if not os.path.isdir(dest_dir): - os.makedirs(dest_dir) + # do something with photos here + for photo in photos: + # photos is a list of PhotoInfo objects + # see: https://rhettbull.github.io/osxphotos/reference.html#osxphotos.PhotoInfo + if resume and photo.uuid in kv: + echo( + f"Skipping processed photo [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])" + ) + continue - # export the photo - if p.hasadjustments: - # export edited version - exported = p.export(dest_dir, edited=True) - edited_name = pathlib.Path(p.path_edited).name - click.echo(f"Exported {edited_name} to {exported}") - # export unedited version - exported = p.export(dest_dir) - click.echo(f"Exported {p.filename} to {exported}") - else: - click.echo(f"Skipping missing photo: {p.filename}") + # store the uuid and current time in the kvstore + # the key and value must be a type supported by SQLite: int, float, str, bytes, bool, None + # if you need to store other values, you should serialize them to a string or bytes first + # for example, using json.dumps() or pickle.dumps() + kv[photo.uuid] = datetime.datetime.now().isoformat() + echo(f"Processing [filename]{photo.original_filename}[/] [time]{photo.date}[/]") + if not dry_run: + # do something with the photo here + echo(f"Doing something with [filename]{photo.original_filename}[/]") + + # echo_error will print to stderr + # if you add [warning] or [error], it will be formatted accordingly + # and include an emoji to make the message stand out + echo_error("[warning]This is a warning message!") + echo_error("[error]This is an error message!") if __name__ == "__main__": - export() # pylint: disable=no-value-for-parameter + # call your function here + # you do not need to pass any arguments to the function + # as the decorator will handle parsing the command line arguments + example() ``` + + +In addition to the `query_command` decorator, you can also use the `selection_command` decorator to implement a command that operates on the current selection in Photos. + + +```python +"""Sample query command for osxphotos + +This shows how simple it is to create a command line tool using osxphotos to process your photos. + +Using the @selection_command decorator turns your function to a full-fledged command line app that +can be run via `osxphotos run cli_example_1.py` or `python cli_example_1.py` if you have pip installed osxphotos. + +Using this decorator makes it very easy to create a quick command line tool that can operate on +a subset of your photos. Additionally, writing a command in this way makes it easy to later +incorporate the command into osxphotos as a full-fledged command. + +The decorator will add the following options to your command: +--verbose +--timestamp +--theme +--db +--debug (hidden, won't show in help) + +The decorated function will get the selected photos and pass the list of PhotoInfo objects +to your function. You can then do whatever you want with the photos. +""" + +from __future__ import annotations + +import osxphotos +from osxphotos.cli import ( + selection_command, + verbose, +) + + +@selection_command +def example(photos: list[osxphotos.PhotoInfo], **kwargs): + """Sample command for osxphotos. Prints out the filename and date of each photo + currently selected in Photos.app. + + Whatever text you put in the function's docstring here, will be used as the command's + help text when run via `osxphotos run cli_example_1.py --help` or `python cli_example_1.py --help` + """ + + # verbose() will print to stdout if --verbose option is set + # you can optionally provide a level (default is 1) to print only if --verbose is set to that level + # for example: -VV or --verbose --verbose == level 2 + verbose(f"Found {len(photos)} photo(s)") + verbose("This message will only be printed if verbose level 2 is set", level=2) + + # do something with photos here + for photo in photos: + # photos is a list of PhotoInfo objects + # see: https://rhettbull.github.io/osxphotos/reference.html#osxphotos.PhotoInfo + verbose(f"Processing {photo.original_filename}") + print(f"{photo.original_filename} {photo.date}") + ... + + +if __name__ == "__main__": + # call your function here + # you do not need to pass any arguments to the function + # as the decorator will handle parsing the command line arguments + example() +``` + ## Package Interface @@ -222,6 +447,132 @@ Returns a PhotosDB object. **Note**: If you have a large library (e.g. many thousdands of photos), creating the PhotosDB object can take a long time (10s of seconds). See [Implementation Notes](#implementation-notes) for additional details. +#### `photos(keywords=None, uuid=None, persons=None, albums=None, images=True, movies=True, from_date=None, to_date=None, intrash=False)` + +```python +# assumes photosdb is a PhotosDB object (see above) +photos = photosdb.photos([keywords=['keyword',]], [uuid=['uuid',]], [persons=['person',]], [albums=['album',]],[from_date=datetime.datetime],[to_date=datetime.datetime]) +``` + +Returns a list of [PhotoInfo](#photoinfo) objects. Each PhotoInfo object represents a photo in the Photos Libary. + +If called with no parameters, returns a list of every photo in the Photos library. + +May be called with one or more of the following parameters to filter the list of photos returned: + +```python +photos = photosdb.photos( + keywords = [], + uuid = [], + persons = [], + albums = [], + images = bool, + movies = bool, + from_date = datetime.datetime, + to_date = datetime.datetime, + intrash = bool, +) +``` + +* ```keywords```: list of one or more keywords. Returns only photos containing the keyword(s). If more than one keyword is provided finds photos matching any of the keywords (e.g. treated as "or") +* ```uuid```: list of one or more uuids. Returns only photos whos UUID matches. **Note**: The UUID is the universally unique identifier that the Photos database uses to identify each photo. You shouldn't normally need to use this but it is a way to access a specific photo if you know the UUID. If more than more uuid is provided, returns photos that match any of the uuids (e.g. treated as "or") +* ```persons```: list of one or more persons. Returns only photos containing the person(s). If more than one person provided, returns photos that match any of the persons (e.g. treated as "or") +* ```albums```: list of one or more album names. Returns only photos contained in the album(s). If more than one album name is provided, returns photos contained in any of the albums (.e.g. treated as "or") +* ```images```: bool; if True, returns photos/images; default is True +* ```movies```: bool; if True, returns movies/videos; default is True +* ```from_date```: datetime.datetime; if provided, finds photos where creation date >= from_date; default is None +* ```to_date```: datetime.datetime; if provided, finds photos where creation date <= to_date; default is None +* ```intrash```: if True, finds only photos in the "Recently Deleted" or trash folder, if False does not find any photos in the trash; default is False + +See also [get_photo()](#getphoto) which is much faster for retrieving a single photo and [query](#photosdbquery) which provides much more flexibility in querying the database. + +If more than one of (keywords, uuid, persons, albums,from_date, to_date) is provided, they are treated as "and" criteria. E.g. + +Finds all photos with (keyword = "wedding" or "birthday") and (persons = "Juan Rodriguez") + +```python +photos=photosdb.photos(keywords=["wedding","birthday"],persons=["Juan Rodriguez"]) +``` + +Find all photos tagged with keyword "wedding": + +```python +# assumes photosdb is a PhotosDB object (see above) +photos = photosdb.photos(keywords=["wedding"]) + ``` + +Find all photos of Maria Smith + +```python +# assumes photosdb is a PhotosDB object (see above) +photos=photosdb.photos(persons=["Maria Smith"]) +``` + +Find all photos in album "Summer Vacation" or album "Ski Trip" + +```python +# assumes photosdb is a PhotosDB object (see above) +photos=photosdb.photos(albums=["Summer Vacation", "Ski Trip"]) +``` + +Find the single photo with uuid = "osMNIO5sQFGZTbj9WrydRB" + +```python +# assumes photosdb is a PhotosDB object (see above) +photos=photosdb.photos(uuid=["osMNIO5sQFGZTbj9WrydRB"]) +``` + +If you need to do more complicated searches, you can do this programmaticaly. For example, find photos with keyword = "Kids" but not in album "Vacation 2019" + +```python +# assumes photosdb is a PhotosDB object (see above) +photos1 = photosdb.photos(albums=["Vacation 2019"]) +photos2 = photosdb.photos(keywords=["Kids"]) +photos3 = [p for p in photos2 if p not in photos1] +``` + +To get only movies: + +```python +movies = photosdb.photos(images=False, movies=True) +``` + +**Note** PhotosDB.photos() may return a different number of photos than Photos.app reports in the GUI. This is because photos() returns [hidden](#hidden) photos, [shared](#shared) photos, and for [burst](#burst) photos, all selected burst images even if non-selected burst images have not been deleted. Photos only reports 1 single photo for each set of burst images until you "finalize" the burst by selecting key photos and deleting the others using the "Make a selection" option. + +For example, in my library, Photos says I have 19,386 photos and 474 movies. However, PhotosDB.photos() reports 25,002 photos. The difference is due to 5,609 shared photos and 7 hidden photos. (*Note* Shared photos only valid for Photos 5). Similarly, filtering for just movies returns 625 results. The difference between 625 and 474 reported by Photos is due to 151 shared movies. + +```pycon +>>> import osxphotos +>>> photosdb = osxphotos.PhotosDB("/Users/smith/Pictures/Photos Library.photoslibrary") +>>> photos = photosdb.photos() +>>> len(photos) +25002 +>>> shared = [p for p in photos if p.shared] +>>> len(shared) +5609 +>>> not_shared = [p for p in photos if not p.shared] +>>> len(not_shared) +19393 +>>> hidden = [p for p in photos if p.hidden] +>>> len(hidden) +7 +>>> movies = photosdb.photos(movies=True, images=False) +>>> len(movies) +625 +>>> shared_movies = [m for m in movies if m.shared] +>>> len(shared_movies) +151 +>>> +``` + +#### `get_photo(uuid)` + +Returns a single PhotoInfo instance for photo with UUID matching `uuid` or None if no photo is found matching `uuid`. If you know the UUID of a photo, `get_photo()` is much faster than `photos`. See also [photos()](#photos). + +#### `query(options: QueryOptions) -> List[PhotoInfo]:` + +Returns a list of [PhotoInfo](#photoinfo) objects matching the query options. This is preferred method of querying the photos database. See [QueryOptions](#queryoptions) for details on the options available. + #### `keywords` ```python @@ -430,132 +781,110 @@ for row in results: conn.close() ``` -#### `photos(keywords=None, uuid=None, persons=None, albums=None, images=True, movies=True, from_date=None, to_date=None, intrash=False)` - -```python -# assumes photosdb is a PhotosDB object (see above) -photos = photosdb.photos([keywords=['keyword',]], [uuid=['uuid',]], [persons=['person',]], [albums=['album',]],[from_date=datetime.datetime],[to_date=datetime.datetime]) -``` - -Returns a list of [PhotoInfo](#photoinfo) objects. Each PhotoInfo object represents a photo in the Photos Libary. - -If called with no parameters, returns a list of every photo in the Photos library. - -May be called with one or more of the following parameters: - -```python -photos = photosdb.photos( - keywords = [], - uuid = [], - persons = [], - albums = [], - images = bool, - movies = bool, - from_date = datetime.datetime, - to_date = datetime.datetime, - intrash = bool, -) -``` - -* ```keywords```: list of one or more keywords. Returns only photos containing the keyword(s). If more than one keyword is provided finds photos matching any of the keywords (e.g. treated as "or") -* ```uuid```: list of one or more uuids. Returns only photos whos UUID matches. **Note**: The UUID is the universally unique identifier that the Photos database uses to identify each photo. You shouldn't normally need to use this but it is a way to access a specific photo if you know the UUID. If more than more uuid is provided, returns photos that match any of the uuids (e.g. treated as "or") -* ```persons```: list of one or more persons. Returns only photos containing the person(s). If more than one person provided, returns photos that match any of the persons (e.g. treated as "or") -* ```albums```: list of one or more album names. Returns only photos contained in the album(s). If more than one album name is provided, returns photos contained in any of the albums (.e.g. treated as "or") -* ```images```: bool; if True, returns photos/images; default is True -* ```movies```: bool; if True, returns movies/videos; default is True -* ```from_date```: datetime.datetime; if provided, finds photos where creation date >= from_date; default is None -* ```to_date```: datetime.datetime; if provided, finds photos where creation date <= to_date; default is None -* ```intrash```: if True, finds only photos in the "Recently Deleted" or trash folder, if False does not find any photos in the trash; default is False - -See also [get_photo()](#getphoto) which is much faster for retrieving a single photo. - -If more than one of (keywords, uuid, persons, albums,from_date, to_date) is provided, they are treated as "and" criteria. E.g. - -Finds all photos with (keyword = "wedding" or "birthday") and (persons = "Juan Rodriguez") - -```python -photos=photosdb.photos(keywords=["wedding","birthday"],persons=["Juan Rodriguez"]) -``` - -Find all photos tagged with keyword "wedding": - -```python -# assumes photosdb is a PhotosDB object (see above) -photos = photosdb.photos(keywords=["wedding"]) - ``` - -Find all photos of Maria Smith - -```python -# assumes photosdb is a PhotosDB object (see above) -photos=photosdb.photos(persons=["Maria Smith"]) -``` - -Find all photos in album "Summer Vacation" or album "Ski Trip" - -```python -# assumes photosdb is a PhotosDB object (see above) -photos=photosdb.photos(albums=["Summer Vacation", "Ski Trip"]) -``` - -Find the single photo with uuid = "osMNIO5sQFGZTbj9WrydRB" - -```python -# assumes photosdb is a PhotosDB object (see above) -photos=photosdb.photos(uuid=["osMNIO5sQFGZTbj9WrydRB"]) -``` - -If you need to do more complicated searches, you can do this programmaticaly. For example, find photos with keyword = "Kids" but not in album "Vacation 2019" - -```python -# assumes photosdb is a PhotosDB object (see above) -photos1 = photosdb.photos(albums=["Vacation 2019"]) -photos2 = photosdb.photos(keywords=["Kids"]) -photos3 = [p for p in photos2 if p not in photos1] -``` - -To get only movies: - -```python -movies = photosdb.photos(images=False, movies=True) -``` - -**Note** PhotosDB.photos() may return a different number of photos than Photos.app reports in the GUI. This is because photos() returns [hidden](#hidden) photos, [shared](#shared) photos, and for [burst](#burst) photos, all selected burst images even if non-selected burst images have not been deleted. Photos only reports 1 single photo for each set of burst images until you "finalize" the burst by selecting key photos and deleting the others using the "Make a selection" option. - -For example, in my library, Photos says I have 19,386 photos and 474 movies. However, PhotosDB.photos() reports 25,002 photos. The difference is due to 5,609 shared photos and 7 hidden photos. (*Note* Shared photos only valid for Photos 5). Similarly, filtering for just movies returns 625 results. The difference between 625 and 474 reported by Photos is due to 151 shared movies. - -```pycon ->>> import osxphotos ->>> photosdb = osxphotos.PhotosDB("/Users/smith/Pictures/Photos Library.photoslibrary") ->>> photos = photosdb.photos() ->>> len(photos) -25002 ->>> shared = [p for p in photos if p.shared] ->>> len(shared) -5609 ->>> not_shared = [p for p in photos if not p.shared] ->>> len(not_shared) -19393 ->>> hidden = [p for p in photos if p.hidden] ->>> len(hidden) -7 ->>> movies = photosdb.photos(movies=True, images=False) ->>> len(movies) -625 ->>> shared_movies = [m for m in movies if m.shared] ->>> len(shared_movies) -151 ->>> -``` - -#### `get_photo(uuid)` - -Returns a single PhotoInfo instance for photo with UUID matching `uuid` or None if no photo is found matching `uuid`. If you know the UUID of a photo, `get_photo()` is much faster than `photos`. See also [photos()](#photos). - #### `execute(sql)` Execute sql statement against the Photos database and return a sqlite cursor with the results. +### QueryOptions + +QueryOptions class for [PhotosDB.query()](#photosdbquery) + +#### Attributes + +See [queryoptions.py](https://github.com/RhetTbull/osxphotos/blob/master/osxphotos/queryoptions.py) for typing information. + +* `added_after`: search for photos added after a given date +* `added_before`: search for photos added before a given date +* `added_in_last`: search for photos added in last X datetime.timedelta +* `album`: list of album names to search for +* `burst_photos`: search for burst photos +* `burst`: search for burst photos +* `cloudasset`: search for photos that are managed by iCloud +* `deleted_only`: search only for deleted photos +* `deleted`: also include deleted photos +* `description`: list of descriptions to search for +* `duplicate`: search for duplicate photos +* `edited`: search for edited photos +* `exif`: search for photos with EXIF tags that matches the given data +* `external_edit`: search for photos edited in external apps +* `favorite`: search for favorite photos +* `folder`: list of folder names to search for +* `from_date`: search for photos taken on or after this date +* `function`: list of query functions to evaluate +* `has_comment`: search for photos with comments +* `has_likes`: search for shared photos with likes +* `has_raw`: search for photos with associated raw files +* `hdr`: search for HDR photos +* `hidden`: search for hidden photos +* `ignore_case`: ignore case when searching +* `in_album`: search for photos in an album +* `incloud`: search for cloud assets that are synched to iCloud +* `is_reference`: search for photos stored by reference (that is, they are not managed by Photos) +* `keyword`: list of keywords to search for +* `label`: list of labels to search for +* `live`: search for live photos +* `location`: search for photos with a location +* `max_size`: maximum size of photos to search for +* `min_size`: minimum size of photos to search for +* `missing_bursts`: for burst photos, also include burst photos that are missing +* `missing`: search for missing photos +* `movies`: search for movies +* `name`: list of names to search for +* `no_comment`: search for photos with no comments +* `no_description`: search for photos with no description +* `no_likes`: search for shared photos with no likes +* `no_location`: search for photos with no location +* `no_keyword`: search for photos with no keywords +* `no_place`: search for photos with no place +* `no_title`: search for photos with no title +* `not_burst`: search for non-burst photos +* `not_cloudasset`: search for photos that are not managed by iCloud +* `not_edited`: search for photos that have not been edited +* `not_favorite`: search for non-favorite photos +* `not_hdr`: search for non-HDR photos +* `not_hidden`: search for non-hidden photos +* `not_in_album`: search for photos not in an album +* `not_incloud`: search for cloud asset photos that are not yet synched to iCloud +* `not_live`: search for non-live photos +* `not_missing`: search for non-missing photos +* `not_panorama`: search for non-panorama photos +* `not_portrait`: search for non-portrait photos +* `not_reference`: search for photos not stored by reference (that is, they are managed by Photos) +* `not_screenshot`: search for non-screenshot photos +* `not_selfie`: search for non-selfie photos +* `not_shared`: search for non-shared photos +* `not_slow_mo`: search for non-slow-mo photos +* `not_time_lapse`: search for non-time-lapse photos +* `panorama`: search for panorama photos +* `person`: list of person names to search for +* `photos`: search for photos +* `place`: list of place names to search for +* `portrait`: search for portrait photos +* `query_eval`: list of query expressions to evaluate +* `regex`: list of regular expressions to search for +* `screenshot`: search for screenshot photos +* `selected`: search for selected photos +* `selfie`: search for selfie photos +* `shared`: search for shared photos +* `slow_mo`: search for slow-mo photos +* `time_lapse`: search for time-lapse photos +* `title`: list of titles to search for +* `to_date`: search for photos taken on or before this date +* `uti`: list of UTIs to search for +* `uuid`: list of uuids to search for +* `year`: search for photos taken in a given year + +```python +"""Find all screenshots taken in 2019""" +import osxphotos + +if __name__ == "__main__": + photosdb = osxphotos.PhotosDB() + results = photosdb.query(osxphotos.QueryOptions(screenshot=True, year=[2019])) + for photo in results: + print(photo.original_filename, photo.date) +``` + ### PhotoInfo PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library. diff --git a/examples/cli_example_1.py b/examples/cli_example_1.py new file mode 100644 index 00000000..c30f49dd --- /dev/null +++ b/examples/cli_example_1.py @@ -0,0 +1,67 @@ +"""Sample query command for osxphotos + +This shows how simple it is to create a command line tool using osxphotos to process your photos. + +Using the @query_command decorator turns your function to a full-fledged command line app that +can be run via `osxphotos run cli_example_1.py` or `python cli_example_1.py` if you have pip installed osxphotos. + +Using this decorator makes it very easy to create a quick command line tool that can operate on +a subset of your photos. Additionally, writing a command in this way makes it easy to later +incorporate the command into osxphotos as a full-fledged command. + +The decorator will add all the query options available in `osxphotos query` as command line options +as well as the following options: +--verbose +--timestamp +--theme +--db +--debug (hidden, won't show in help) + +The decorated function will perform the query and pass the list of filtered PhotoInfo objects +to your function. You can then do whatever you want with the photos. + +For example, to run the command on only selected photos: + + osxphotos run cli_example_1.py --selected + +To run the command on all photos with the keyword "foo": + + osxphotos run cli_example_1.py --keyword foo + +For more advanced example, see `cli_example_2.py` +""" + +from __future__ import annotations + +import osxphotos +from osxphotos.cli import query_command, verbose + + +@query_command +def example(photos: list[osxphotos.PhotoInfo], **kwargs): + """Sample query command for osxphotos. Prints out the filename and date of each photo. + + Whatever text you put in the function's docstring here, will be used as the command's + help text when run via `osxphotos run cli_example_1.py --help` or `python cli_example_1.py --help` + """ + + # verbose() will print to stdout if --verbose option is set + # you can optionally provide a level (default is 1) to print only if --verbose is set to that level + # for example: -VV or --verbose --verbose == level 2 + verbose(f"Found {len(photos)} photo(s)") + verbose("This message will only be printed if verbose level 2 is set", level=2) + + # do something with photos here + for photo in photos: + # photos is a list of PhotoInfo objects + # see: https://rhettbull.github.io/osxphotos/reference.html#osxphotos.PhotoInfo + verbose(f"Processing {photo.original_filename}") + print(f"{photo.original_filename} {photo.date}") + ... + + +if __name__ == "__main__": + # call your function here + # you do not need to pass any arguments to the function + # as the decorator will handle parsing the command line arguments + example() diff --git a/examples/cli_example_2.py b/examples/cli_example_2.py new file mode 100644 index 00000000..d37f83c3 --- /dev/null +++ b/examples/cli_example_2.py @@ -0,0 +1,160 @@ +"""Sample query command for osxphotos + +This shows how simple it is to create a command line tool using osxphotos to process your photos. + +Using the @query_command decorator turns your function to a full-fledged command line app that +can be run via `osxphotos run cli_example_2.py` or `python cli_example_2.py` if you have pip installed osxphotos. + +Using this decorator makes it very easy to create a quick command line tool that can operate on +a subset of your photos. Additionally, writing a command in this way makes it easy to later +incorporate the command into osxphotos as a full-fledged command. + +The decorator will add all the query options available in `osxphotos query` as command line options +as well as the following options: +--verbose +--timestamp +--theme +--db +--debug (hidden, won't show in help) + +The decorated function will perform the query and pass the list of filtered PhotoInfo objects +to your function. You can then do whatever you want with the photos. + +For example, to run the command on only selected photos: + + osxphotos run cli_example_2.py --selected + +To run the command on all photos with the keyword "foo": + + osxphotos run cli_example_2.py --keyword foo + +The following helper functions may be useful and can be imported from osxphotos.cli: + + abort(message: str, exit_code: int = 1) + Abort with error message and exit code + echo(message: str) + Print message to stdout using rich formatting + echo_error(message: str) + Print message to stderr using rich formatting + logger: logging.Logger + Python logger for osxphotos; for example, logger.debug("debug message") + verbose(*args, level: int = 1) + Print args to stdout if --verbose option is set + query_command: decorator to create an osxphotos query command + kvstore(name: str) -> SQLiteKVStore useful for storing state between runs + +The verbose, echo, and echo_error functions use rich formatting to print messages to stdout and stderr. +See https://github.com/Textualize/rich for more information on rich formatting. + +In addition to standard rich formatting styles, the following styles will be defined +(and can be changed using --theme): + + [change]: something change + [no_change]: indicate no change + [count]: a count + [error]: an error + [filename]: a filename + [filepath]: a filepath + [num]: a number + [time]: a time or date + [tz]: a timezone + [warning]: a warning + [uuid]: a uuid + +The tags should be closed with [/] to end the style. For example: + + echo("[filename]foo[/] [time]bar[/]") + +For simpler examples, see `cli_example_1.py` +""" + +from __future__ import annotations + +import datetime + +import click + +import osxphotos +from osxphotos.cli import ( + abort, + echo, + echo_error, + kvstore, + logger, + query_command, + verbose, +) + + +@query_command() +@click.option( + "--resume", + is_flag=True, + help="Resume processing from last run, do not reprocess photos", +) +@click.option( + "--dry-run", is_flag=True, help="Do a dry run, don't actually do anything" +) +def example(resume, dry_run, photos: list[osxphotos.PhotoInfo], **kwargs): + """Sample query command for osxphotos. Prints out the filename and date of each photo. + + Whatever text you put in the function's docstring here, will be used as the command's + help text when run via `osxphotos run cli_example_2.py --help` or `python cli_example_2.py --help` + + The @query_command decorator returns a click.command so you can add additional options + using standard click decorators. For example, the --resume and --dry-run options. + For more information on click, see https://palletsprojects.com/p/click/. + """ + + # abort will print the message to stderr and exit with the given exit code + if not photos: + abort("Nothing to do!", 1) + + # verbose() will print to stdout if --verbose option is set + # you can optionally provide a level (default is 1) to print only if --verbose is set to that level + # for example: -VV or --verbose --verbose == level 2 + verbose(f"Found [count]{len(photos)}[/] photos") + verbose("This message will only be printed if verbose level 2 is set", level=2) + + # the logger is a python logging.Logger object + # debug messages will only be printed if --debug option is set + logger.debug(f"{kwargs=}") + + # kvstore() returns a SQLiteKVStore object for storing state between runs + # this is basically a persistent dictionary that can be used to store state + # see https://github.com/RhetTbull/sqlitekvstore for more information + kv = kvstore("cli_example_2") + verbose(f"Using key-value cache: {kv.path}") + + # do something with photos here + for photo in photos: + # photos is a list of PhotoInfo objects + # see: https://rhettbull.github.io/osxphotos/reference.html#osxphotos.PhotoInfo + if resume and photo.uuid in kv: + echo( + f"Skipping processed photo [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])" + ) + continue + + # store the uuid and current time in the kvstore + # the key and value must be a type supported by SQLite: int, float, str, bytes, bool, None + # if you need to store other values, you should serialize them to a string or bytes first + # for example, using json.dumps() or pickle.dumps() + kv[photo.uuid] = datetime.datetime.now().isoformat() + echo(f"Processing [filename]{photo.original_filename}[/] [time]{photo.date}[/]") + if not dry_run: + # do something with the photo here + echo(f"Doing something with [filename]{photo.original_filename}[/]") + + # echo_error will print to stderr + # if you add [warning] or [error], it will be formatted accordingly + # and include an emoji to make the message stand out + echo_error("[warning]This is a warning message!") + echo_error("[error]This is an error message!") + + +if __name__ == "__main__": + # call your function here + # you do not need to pass any arguments to the function + # as the decorator will handle parsing the command line arguments + example() diff --git a/examples/cli_example_3.py b/examples/cli_example_3.py new file mode 100644 index 00000000..50236758 --- /dev/null +++ b/examples/cli_example_3.py @@ -0,0 +1,60 @@ +"""Sample query command for osxphotos + +This shows how simple it is to create a command line tool using osxphotos to process your photos. + +Using the @selection_command decorator turns your function to a full-fledged command line app that +can be run via `osxphotos run cli_example_1.py` or `python cli_example_1.py` if you have pip installed osxphotos. + +Using this decorator makes it very easy to create a quick command line tool that can operate on +a subset of your photos. Additionally, writing a command in this way makes it easy to later +incorporate the command into osxphotos as a full-fledged command. + +The decorator will add the following options to your command: +--verbose +--timestamp +--theme +--db +--debug (hidden, won't show in help) + +The decorated function will get the selected photos and pass the list of PhotoInfo objects +to your function. You can then do whatever you want with the photos. +""" + +from __future__ import annotations + +import osxphotos +from osxphotos.cli import ( + selection_command, + verbose, +) + + +@selection_command +def example(photos: list[osxphotos.PhotoInfo], **kwargs): + """Sample command for osxphotos. Prints out the filename and date of each photo + currently selected in Photos.app. + + Whatever text you put in the function's docstring here, will be used as the command's + help text when run via `osxphotos run cli_example_1.py --help` or `python cli_example_1.py --help` + """ + + # verbose() will print to stdout if --verbose option is set + # you can optionally provide a level (default is 1) to print only if --verbose is set to that level + # for example: -VV or --verbose --verbose == level 2 + verbose(f"Found {len(photos)} photo(s)") + verbose("This message will only be printed if verbose level 2 is set", level=2) + + # do something with photos here + for photo in photos: + # photos is a list of PhotoInfo objects + # see: https://rhettbull.github.io/osxphotos/reference.html#osxphotos.PhotoInfo + verbose(f"Processing {photo.original_filename}") + print(f"{photo.original_filename} {photo.date}") + ... + + +if __name__ == "__main__": + # call your function here + # you do not need to pass any arguments to the function + # as the decorator will handle parsing the command line arguments + example() diff --git a/osxphotos/__init__.py b/osxphotos/__init__.py index abb090f8..0f2fe7f3 100644 --- a/osxphotos/__init__.py +++ b/osxphotos/__init__.py @@ -25,13 +25,12 @@ from .queryoptions import QueryOptions from .scoreinfo import ScoreInfo from .searchinfo import SearchInfo +# configure logging; every module in osxphotos should use this logger logging.basicConfig( level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(filename)s - %(lineno)d - %(message)s", ) - logger: logging.Logger = logging.getLogger("osxphotos") - if not is_debug(): logging.disable(logging.DEBUG) diff --git a/osxphotos/cli/__init__.py b/osxphotos/cli/__init__.py index 04734c79..ec26e036 100644 --- a/osxphotos/cli/__init__.py +++ b/osxphotos/cli/__init__.py @@ -47,17 +47,30 @@ from .about import about from .add_locations import add_locations from .albums import albums from .cli import cli_main -from .common import get_photos_db +from .cli_commands import ( + abort, + echo, + echo_error, + logger, + query_command, + selection_command, + verbose, +) +from .cli_params import DB_OPTION, DEBUG_OPTIONS, JSON_OPTION +from .common import OSXPHOTOS_HIDDEN, get_photos_db from .debug_dump import debug_dump +from .docs import docs_command from .dump import dump from .exiftool_cli import exiftool from .export import export from .exportdb import exportdb from .grep import grep from .help import help +from .import_cli import import_cli from .info import info from .install_uninstall_run import install, run, uninstall from .keywords import keywords +from .kvstore import kvstore from .labels import labels from .list import _list_libraries, list_libraries from .orphans import orphans @@ -67,39 +80,53 @@ from .places import places from .query import query from .repl import repl from .snap_diff import diff, snap +from .sync import sync +from .theme import theme +from .timewarp import timewarp from .tutorial import tutorial from .uuid import uuid +from .version import version install_traceback() __all__ = [ + "abort", "about", "add_locations", "albums", "cli_main", "debug_dump", "diff", + "docs_command", "dump", + "echo", + "echo_error", "exiftool_cli", "export", "exportdb", "grep", "help", + "import_cli", "info", "install", "keywords", + "kvstore", "labels", "list_libraries", "list_libraries", + "logger", "orphans", "persons", "photo_inspect", "places", "query", + "query_command", "repl", "run", + "selection_command", "set_debug", "snap", "tutorial", "uuid", + "verbose", ] diff --git a/osxphotos/cli/add_locations.py b/osxphotos/cli/add_locations.py index 226ab2bc..6069ceed 100644 --- a/osxphotos/cli/add_locations.py +++ b/osxphotos/cli/add_locations.py @@ -11,9 +11,9 @@ import osxphotos from osxphotos.queryoptions import IncompatibleQueryOptions, query_options_from_kwargs from osxphotos.utils import pluralize +from .cli_params import QUERY_OPTIONS, THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION from .click_rich_echo import rich_click_echo as echo from .click_rich_echo import rich_echo_error as echo_error -from .common import QUERY_OPTIONS, THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION from .param_types import TimeOffset from .rich_progress import rich_progress from .verbose import get_verbose_console, verbose_print diff --git a/osxphotos/cli/albums.py b/osxphotos/cli/albums.py index efcb19ab..6731748d 100644 --- a/osxphotos/cli/albums.py +++ b/osxphotos/cli/albums.py @@ -6,12 +6,12 @@ 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 +from .cli_params import DB_ARGUMENT, DB_OPTION, JSON_OPTION +from .common import get_photos_db +from .list import _list_libraries + @click.command() @DB_OPTION @@ -36,7 +36,8 @@ def albums(ctx, cli_obj, db, json_, photos_library): if photosdb.db_version > _PHOTOS_4_VERSION: albums["shared albums"] = photosdb.albums_shared_as_dict - if json_ or cli_obj.json: + # cli_obj will be None if called from pytest + if json_ or (cli_obj and 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 index c20b516e..bb24d29a 100644 --- a/osxphotos/cli/cli.py +++ b/osxphotos/cli/cli.py @@ -13,9 +13,10 @@ from osxphotos._version import __version__ from .about import about from .add_locations import add_locations from .albums import albums -from .common import DB_OPTION, JSON_OPTION, OSXPHOTOS_HIDDEN +from .cli_params import DB_OPTION, DEBUG_OPTIONS, JSON_OPTION +from .common import OSXPHOTOS_HIDDEN from .debug_dump import debug_dump -from .docs import docs +from .docs import docs_command from .dump import dump from .exiftool_cli import exiftool from .export import export @@ -41,7 +42,6 @@ from .timewarp import timewarp from .tutorial import tutorial from .uuid import uuid from .version import version -from .common import DEBUG_OPTIONS # Click CLI object & context settings @@ -110,7 +110,7 @@ for command in [ albums, debug_dump, diff, - docs, + docs_command, dump, exiftool, export, diff --git a/osxphotos/cli/cli_commands.py b/osxphotos/cli/cli_commands.py new file mode 100644 index 00000000..0d8770d1 --- /dev/null +++ b/osxphotos/cli/cli_commands.py @@ -0,0 +1,305 @@ +"""Helper functions to make writing an osxphotos CLI tool easy. + +Includes decorator to create an osxphotos query command to be run via `osxphotos run example.py`. + +May also be run via `python example.py` if you have pip installed osxphotos +""" + +from __future__ import annotations + +import logging +import sys +import typing as t # match style used in Click source code + +import click + +from osxphotos.photosdb import PhotosDB +from osxphotos.queryoptions import QueryOptions, query_options_from_kwargs +from osxphotos.sqlitekvstore import SQLiteKVStore + +from .cli_params import ( + _DB_PARAMETER, + _QUERY_PARAMETERS_DICT, + DB_OPTION, + THEME_OPTION, + TIMESTAMP_OPTION, + VERBOSE_OPTION, +) +from .click_rich_echo import rich_click_echo as echo +from .click_rich_echo import rich_echo_error as echo_error +from .verbose import verbose, verbose_print + +logger = logging.getLogger("osxphotos") + +__all__ = [ + "abort", + "echo", + "echo_error", + "logger", + "query_command", + "selection_command", + "verbose", +] + + +def abort(message: str, exit_code: int = 1): + """Abort with error message and exit code""" + echo_error(f"[error]{message}[/]") + sys.exit(exit_code) + + +def config_verbose_callback(ctx: click.Context, param: click.Parameter, value: t.Any): + """Callback for --verbose option""" + # calling verbose_print() will set the verbose level for the verbose() function + theme = ctx.params.get("theme") + timestamp = ctx.params.get("timestamp") + verbose_print(verbose=value, timestamp=timestamp, theme=theme) + return value + + +def get_photos_for_query(ctx: click.Context): + """Return list of PhotoInfo objects for the photos matching the query options in ctx.params""" + db = ctx.params.get("db") + photosdb = PhotosDB(dbfile=db, verbose=verbose) + options = query_options_from_kwargs(**ctx.params) + return photosdb.query(options=options) + + +def get_selected_photos(ctx: click.Context): + """Return list of PhotoInfo objects for the photos currently selected in Photos.app""" + photosdb = PhotosDB(verbose=verbose) + return photosdb.query(options=QueryOptions(selected=True)) + + +class QueryCommand(click.Command): + """ + Click command to create an osxphotos query command. + + This class is used by the query_command decorator to create a click command + that runs an osxphotos query. It will automatically add the query options as + well as the --verbose, --timestamp, --theme, and --db options. + """ + + standalone_mode = False + + def __init__( + self, + name: t.Optional[str], + context_settings: t.Optional[t.Dict[str, t.Any]] = None, + callback: t.Optional[t.Callable[..., t.Any]] = None, + params: t.Optional[t.List[click.Parameter]] = None, + help: t.Optional[str] = None, + epilog: t.Optional[str] = None, + short_help: t.Optional[str] = None, + options_metavar: t.Optional[str] = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + ) -> None: + self.params = params or [] + self.params.append( + click.Option( + param_decls=["--verbose", "-V", "verbose_flag"], + count=True, + help="Print verbose output; may be specified multiple times for more verbose output.", + callback=config_verbose_callback, + ) + ) + self.params.append( + click.Option( + param_decls=["--timestamp"], + is_flag=True, + help="Add time stamp to verbose output", + ) + ) + self.params.append( + click.Option( + param_decls=["--theme"], + metavar="THEME", + type=click.Choice( + ["dark", "light", "mono", "plain"], case_sensitive=False + ), + help="Specify the color theme to use for output. " + "Valid themes are 'dark', 'light', 'mono', and 'plain'. " + "Defaults to 'dark' or 'light' depending on system dark mode setting.", + ) + ) + self.params.append(_DB_PARAMETER) + self.params.extend(_QUERY_PARAMETERS_DICT.values()) + + super().__init__( + name, + context_settings, + callback, + self.params, + help, + epilog, + short_help, + options_metavar, + add_help_option, + no_args_is_help, + hidden, + deprecated, + ) + + def make_context( + self, + info_name: t.Optional[str], + args: t.List[str], + parent: t.Optional[click.Context] = None, + **extra: t.Any, + ) -> click.Context: + ctx = super().make_context(info_name, args, parent, **extra) + ctx.obj = self + photos = get_photos_for_query(ctx) + ctx.params["photos"] = photos + + # remove params handled by this class + ctx.params.pop("verbose_flag") + ctx.params.pop("timestamp") + ctx.params.pop("theme") + return ctx + + +class SelectionCommand(click.Command): + """ + Click command to create an osxphotos selection command that runs on selected photos. + + This class is used by the query_command decorator to create a click command + that runs on currently selected photos. + + The --verbose, --timestamp, --theme, and --db options will also be added to the command. + """ + + standalone_mode = False + + def __init__( + self, + name: t.Optional[str], + context_settings: t.Optional[t.Dict[str, t.Any]] = None, + callback: t.Optional[t.Callable[..., t.Any]] = None, + params: t.Optional[t.List[click.Parameter]] = None, + help: t.Optional[str] = None, + epilog: t.Optional[str] = None, + short_help: t.Optional[str] = None, + options_metavar: t.Optional[str] = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + ) -> None: + self.params = params or [] + self.params.append( + click.Option( + param_decls=["--verbose", "-V", "verbose_flag"], + count=True, + help="Print verbose output; may be specified multiple times for more verbose output.", + callback=config_verbose_callback, + ) + ) + self.params.append( + click.Option( + param_decls=["--timestamp"], + is_flag=True, + help="Add time stamp to verbose output", + ) + ) + self.params.append( + click.Option( + param_decls=["--theme"], + metavar="THEME", + type=click.Choice( + ["dark", "light", "mono", "plain"], case_sensitive=False + ), + help="Specify the color theme to use for output. " + "Valid themes are 'dark', 'light', 'mono', and 'plain'. " + "Defaults to 'dark' or 'light' depending on system dark mode setting.", + ) + ) + self.params.append( + click.Option( + param_decls=["--library", "--db"], + required=False, + metavar="PHOTOS_LIBRARY_PATH", + 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), + ) + ) + super().__init__( + name, + context_settings, + callback, + self.params, + help, + epilog, + short_help, + options_metavar, + add_help_option, + no_args_is_help, + hidden, + deprecated, + ) + + def make_context( + self, + info_name: t.Optional[str], + args: t.List[str], + parent: t.Optional[click.Context] = None, + **extra: t.Any, + ) -> click.Context: + ctx = super().make_context(info_name, args, parent, **extra) + ctx.obj = self + photos = get_selected_photos(ctx) + ctx.params["photos"] = photos + + # remove params handled by this class + ctx.params.pop("verbose_flag") + ctx.params.pop("timestamp") + ctx.params.pop("theme") + return ctx + + +def query_command(name=None, cls=QueryCommand, **attrs): + """Decorator to create an osxphotos command to be run via `osxphotos run example.py` + + The command will be passed a list of PhotoInfo objects for all photos in Photos + matching the query options or all photos if no query options are specified. + + The standard osxphotos query options will be added to the command. + + The CLI will also be passed the following options: + + --verbose + --timestamp + --theme + --db + """ + if callable(name) and cls: + return click.command(cls=cls, **attrs)(name) + + return click.command(name, cls=cls, **attrs) + + +def selection_command(name=None, cls=SelectionCommand, **attrs): + """Decorator to create an osxphotos command to be run via `osxphotos run example.py` + + The command will be passed a list of PhotoInfo objects for all photos selected in Photos. + The CLI will also be passed the following options: + + --verbose + --timestamp + --theme + --db + """ + if callable(name) and cls: + return click.command(cls=cls, **attrs)(name) + + return click.command(name, cls=cls, **attrs) diff --git a/osxphotos/cli/cli_params.py b/osxphotos/cli/cli_params.py new file mode 100644 index 00000000..a0028023 --- /dev/null +++ b/osxphotos/cli/cli_params.py @@ -0,0 +1,677 @@ +"""Common options & parameters for osxphotos CLI commands""" + +from __future__ import annotations + +import functools +from typing import Any, Callable + +import click + +from .common import OSXPHOTOS_HIDDEN +from .param_types import * + +__all__ = [ + "DB_ARGUMENT", + "DB_OPTION", + "DEBUG_OPTIONS", + "DELETED_OPTIONS", + "FIELD_OPTION", + "JSON_OPTION", + "QUERY_OPTIONS", + "THEME_OPTION", + "VERBOSE_OPTION", + "TIMESTAMP_OPTION", +] + + +def _param_memo(f: Callable[..., Any], param: click.Parameter) -> None: + """Add param to the list of params for a click.Command + This is directly from the click source code and + the implementation is thus tightly coupled to click internals + """ + if isinstance(f, click.Command): + f.params.append(param) + else: + if not hasattr(f, "__click_params__"): + f.__click_params__ = [] # type: ignore + + f.__click_params__.append(param) # type: ignore + + +def make_click_option_decorator(*params: click.Parameter) -> Callable[..., Any]: + """Make a decorator for a click option from one or more click Parameter objects""" + + def decorator(wrapped=None) -> Callable[..., Any]: + """Function decorator to add option to a click command. + + Args: + wrapped: function to decorate (this is normally passed automatically + """ + + if wrapped is None: + return decorator + + def _add_options(wrapped): + """Add query options to wrapped function""" + for param in params: + _param_memo(wrapped, param) + return wrapped + + return _add_options(wrapped) + + return decorator + + +VERSION_CHECK_OPTION = click.option("--no-version-check", required=False, is_flag=True) + +_DB_PARAMETER = click.Option( + ["--library", "--db", "db"], + required=False, + metavar="PHOTOS_LIBRARY_PATH", + default=None, + help=( + "Specify path to Photos library. " + "If not 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_OPTION = make_click_option_decorator(_DB_PARAMETER) + +DB_ARGUMENT = click.argument( + "photos_library", + nargs=-1, + type=DeprecatedPath( + exists=True, + deprecation_warning="The PHOTOS_LIBRARY argument is deprecated and " + "will be removed in a future version of osxphotos. " + "Use --library instead to specify the Photos Library path.", + ), +) + +_JSON_PARAMETER = click.Option( + ["--json", "json_"], + required=False, + is_flag=True, + default=False, + help="Print output in JSON format.", +) + +JSON_OPTION = make_click_option_decorator(_JSON_PARAMETER) + +_FIELD_PARAMETER = click.Option( + ["--field", "-f"], + metavar="FIELD TEMPLATE", + multiple=True, + nargs=2, + help="Output only specified custom fields. " + "FIELD is the name of the field and TEMPLATE is the template to use as the field value. " + "May be repeated to output multiple fields. " + "For example, to output photo uuid, name, and title: " + '`--field uuid "{uuid}" --field name "{original_name}" --field title "{title}"`.', +) + +FIELD_OPTION = make_click_option_decorator(_FIELD_PARAMETER) + +_DELETED_PARAMETERS = [ + click.Option( + ["--deleted"], + is_flag=True, + help="Include photos from the 'Recently Deleted' folder.", + ), + click.Option( + ["--deleted-only"], + is_flag=True, + help="Include only photos from the 'Recently Deleted' folder.", + ), +] + +DELETED_OPTIONS = make_click_option_decorator(*_DELETED_PARAMETERS) + +# The following are used by the query command and by +# QUERY_OPTIONS to add the query options to other commands +# To add new query options, add them to _QUERY_OPTIONS as +# a click.Option, add them to osxphotos/photosdb/photosdb.py::PhotosDB.query(), +# and to osxphotos/query_options.py::QueryOptions +_QUERY_PARAMETERS_DICT = { + "--keyword": click.Option( + ["--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', + ), + "--no-keyword": click.Option( + ["--no-keyword"], + is_flag=True, + help="Search for photos with no keyword.", + ), + "--person": click.Option( + ["--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', + ), + "--album": click.Option( + ["--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', + ), + "--folder": click.Option( + ["--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)", + ), + "--name": click.Option( + ["--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. ", + ), + "--uuid": click.Option( + ["--uuid"], + metavar="UUID", + default=None, + multiple=True, + help="Search for photos with UUID(s). " + "May be repeated to include multiple UUIDs.", + ), + "--uuid-from-file": click.Option( + ["--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), + ), + "--title": click.Option( + ["--title"], + metavar="TITLE", + default=None, + multiple=True, + help="Search for TITLE in title of photo.", + ), + "--no-title": click.Option( + ["--no-title"], is_flag=True, help="Search for photos with no title." + ), + "--description": click.Option( + ["--description"], + metavar="DESC", + default=None, + multiple=True, + help="Search for DESC in description of photo.", + ), + "--no-description": click.Option( + ["--no-description"], + is_flag=True, + help="Search for photos with no description.", + ), + "--place": click.Option( + ["--place"], + metavar="PLACE", + default=None, + multiple=True, + help="Search for PLACE in photo's reverse geolocation info", + ), + "--no-place": click.Option( + ["--no-place"], + is_flag=True, + help="Search for photos with no associated place name info (no reverse geolocation info)", + ), + "--location": click.Option( + ["--location"], + is_flag=True, + help="Search for photos with associated location info (e.g. GPS coordinates)", + ), + "--no-location": click.Option( + ["--no-location"], + is_flag=True, + help="Search for photos with no associated location info (e.g. no GPS coordinates)", + ), + "--label": click.Option( + ["--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', + ), + "--uti": click.Option( + ["--uti"], + metavar="UTI", + default=None, + multiple=False, + help="Search for photos whose uniform type identifier (UTI) matches UTI", + ), + "--ignore_case": click.Option( + ["-i", "--ignore-case"], + is_flag=True, + help="Case insensitive search for title, description, place, keyword, person, or album.", + ), + "--edited": click.Option( + ["--edited"], + is_flag=True, + help="Search for photos that have been edited.", + ), + "--not-edited": click.Option( + ["--not-edited"], + is_flag=True, + help="Search for photos that have not been edited.", + ), + "--external-edit": click.Option( + ["--external-edit"], + is_flag=True, + help="Search for photos edited in external editor.", + ), + "--favorite": click.Option( + ["--favorite"], is_flag=True, help="Search for photos marked favorite." + ), + "--not-favorite": click.Option( + ["--not-favorite"], + is_flag=True, + help="Search for photos not marked favorite.", + ), + "--hidden": click.Option( + ["--hidden"], is_flag=True, help="Search for photos marked hidden." + ), + "--not-hidden": click.Option( + ["--not-hidden"], + is_flag=True, + help="Search for photos not marked hidden.", + ), + "--shared": click.Option( + ["--shared"], + is_flag=True, + help="Search for photos in shared iCloud album (Photos 5+ only).", + ), + "--not-shared": click.Option( + ["--not-shared"], + is_flag=True, + help="Search for photos not in shared iCloud album (Photos 5+ only).", + ), + "--burst": click.Option( + ["--burst"], + is_flag=True, + help="Search for photos that were taken in a burst.", + ), + "--not-burst": click.Option( + ["--not-burst"], + is_flag=True, + help="Search for photos that are not part of a burst.", + ), + "--live": click.Option( + ["--live"], is_flag=True, help="Search for Apple live photos" + ), + "--not-live": click.Option( + ["--not-live"], + is_flag=True, + help="Search for photos that are not Apple live photos.", + ), + "--portrait": click.Option( + ["--portrait"], + is_flag=True, + help="Search for Apple portrait mode photos.", + ), + "--not-portrait": click.Option( + ["--not-portrait"], + is_flag=True, + help="Search for photos that are not Apple portrait mode photos.", + ), + "--screenshot": click.Option( + ["--screenshot"], is_flag=True, help="Search for screenshot photos." + ), + "--not-screenshot": click.Option( + ["--not-screenshot"], + is_flag=True, + help="Search for photos that are not screenshot photos.", + ), + "--slow-mo": click.Option( + ["--slow-mo"], is_flag=True, help="Search for slow motion videos." + ), + "--not-slow-mo": click.Option( + ["--not-slow-mo"], + is_flag=True, + help="Search for photos that are not slow motion videos.", + ), + "--time-lapse": click.Option( + ["--time-lapse"], is_flag=True, help="Search for time lapse videos." + ), + "--not-time-lapse": click.Option( + ["--not-time-lapse"], + is_flag=True, + help="Search for photos that are not time lapse videos.", + ), + "--hdr": click.Option( + ["--hdr"], + is_flag=True, + help="Search for high dynamic range (HDR) photos.", + ), + "--not-hdr": click.Option( + ["--not-hdr"], + is_flag=True, + help="Search for photos that are not HDR photos.", + ), + "--selfie": click.Option( + ["--selfie"], + is_flag=True, + help="Search for selfies (photos taken with front-facing cameras).", + ), + "--not-selfie": click.Option( + ["--not-selfie"], + is_flag=True, + help="Search for photos that are not selfies.", + ), + "--panorama": click.Option( + ["--panorama"], is_flag=True, help="Search for panorama photos." + ), + "--not-panorama": click.Option( + ["--not-panorama"], + is_flag=True, + help="Search for photos that are not panoramas.", + ), + "--has-raw": click.Option( + ["--has-raw"], + is_flag=True, + help="Search for photos with both a jpeg and raw version", + ), + "--only-movies": click.Option( + ["--only-movies"], + is_flag=True, + help="Search only for movies (default searches both images and movies).", + ), + "--only-photos": click.Option( + ["--only-photos"], + is_flag=True, + help="Search only for photos/images (default searches both images and movies).", + ), + "--from-date": click.Option( + ["--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(), + ), + "--to-date": click.Option( + ["--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(), + ), + "--from-time": click.Option( + ["--from-time"], + help="Search by item start time of day, e.g. 12:00, or 12:00:00.", + type=TimeISO8601(), + ), + "--to-time": click.Option( + ["--to-time"], + help="Search by item end time of day, e.g. 12:00 or 12:00:00.", + type=TimeISO8601(), + ), + "--year": click.Option( + ["--year"], + metavar="YEAR", + help="Search for items from a specific year, e.g. --year 2022 to find all photos from the year 2022. " + "May be repeated to search multiple years.", + multiple=True, + type=int, + ), + "--added-before": click.Option( + ["--added-before"], + metavar="DATE", + help="Search for items added to the library before a specific date/time, " + "e.g. --added-before 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(), + ), + "--added-after": click.Option( + ["--added-after"], + metavar="DATE", + help="Search for items added to the libray after a specific date/time, " + "e.g. --added-after 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(), + ), + "--added-in-last": click.Option( + ["--added-in-last"], + metavar="TIME_DELTA", + help="Search for items added to the library in the last TIME_DELTA, " + "where TIME_DELTA is a string like " + "'12 hrs', '1 day', '1d', '1 week', '2weeks', '1 month', '1 year'. " + "for example, `--added-in-last 7d` and `--added-in-last '1 week'` are equivalent. " + "months are assumed to be 30 days and years are assumed to be 365 days. " + "Common English abbreviations are accepted, e.g. d, day, days or m, min, minutes.", + type=TimeOffset(), + ), + "--has-comment": click.Option( + ["--has-comment"], + is_flag=True, + help="Search for photos that have comments.", + ), + "--no-comment": click.Option( + ["--no-comment"], + is_flag=True, + help="Search for photos with no comments.", + ), + "--has-likes": click.Option( + ["--has-likes"], is_flag=True, help="Search for photos that have likes." + ), + "--no-likes": click.Option( + ["--no-likes"], is_flag=True, help="Search for photos with no likes." + ), + "--is-reference": click.Option( + ["--is-reference"], + is_flag=True, + help="Search for photos that were imported as referenced files (not copied into Photos library).", + ), + "--not-reference": click.Option( + ["--not-reference"], + is_flag=True, + help="Search for photos that are not references, that is, they were copied into the Photos library " + "and are managed by Photos.", + ), + "--in-album": click.Option( + ["--in-album"], + is_flag=True, + help="Search for photos that are in one or more albums.", + ), + "--not-in-album": click.Option( + ["--not-in-album"], + is_flag=True, + help="Search for photos that are not in any albums.", + ), + "--duplicate": click.Option( + ["--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.", + ), + "--min-size": click.Option( + ["--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'.", + ), + "--max-size": click.Option( + ["--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'.", + ), + "--missing": click.Option( + ["--missing"], is_flag=True, help="Search for photos missing from disk." + ), + "--not-missing": click.Option( + ["--not-missing"], + is_flag=True, + help="Search for photos present on disk (e.g. not missing).", + ), + "--cloudasset": click.Option( + ["--cloudasset"], + is_flag=True, + help="Search for photos that are part of an iCloud library", + ), + "--not-cloudasset": click.Option( + ["--not-cloudasset"], + is_flag=True, + help="Search for photos that are not part of an iCloud library", + ), + "--incloud": click.Option( + ["--incloud"], + is_flag=True, + help="Search for photos that are in iCloud (have been synched)", + ), + "--not-incloud": click.Option( + ["--not-incloud"], + is_flag=True, + help="Search for photos that are not in iCloud (have not been synched)", + ), + "--regex": click.Option( + ["--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.", + ), + "--selected": click.Option( + ["--selected"], + is_flag=True, + help="Filter for photos that are currently selected in Photos.", + ), + "--exif": click.Option( + ["--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.", + ), + "--query-eval": click.Option( + ["--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.", + ), + "--query-function": click.Option( + ["--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.", + ), +} + + +def QUERY_OPTIONS( + wrapped=None, *, exclude: list[str] | None = None +) -> Callable[..., Any]: + """Function decorator to add query options to a click command. + + Args: + wrapped: function to decorate (this is normally passed automatically + exclude: list of query options to exclude from the command, for example `exclude=["--shared"] + """ + if wrapped is None: + return functools.partial(QUERY_OPTIONS, exclude=exclude) + + exclude = exclude or [] + + def _add_options(wrapped): + """Add query options to wrapped function""" + for option in reversed(_QUERY_PARAMETERS_DICT.keys()): + if option in exclude: + continue + click_opt = _QUERY_PARAMETERS_DICT[option] + _param_memo(wrapped, click_opt) + return wrapped + + return _add_options(wrapped) + + +_DEBUG_PARAMETERS = [ + click.Option( + ["--debug"], + is_flag=True, + help="Enable debug output.", + hidden=OSXPHOTOS_HIDDEN, + ), + click.Option( + ["--watch"], + metavar="MODULE::NAME", + multiple=True, + help="Watch function or method calls. The function to watch must be in the form " + "MODULE::NAME where MODULE is the module path and NAME is the function or method name " + "contained in the module. For example, to watch all calls to FileUtil.copy() which is in " + "osxphotos.fileutil, use: " + "'--watch osxphotos.fileutil::FileUtil.copy'. More than one --watch option can be specified.", + hidden=OSXPHOTOS_HIDDEN, + ), + click.Option( + ["--breakpoint"], + metavar="MODULE::NAME", + multiple=True, + help="Add breakpoint to function calls. The function to watch must be in the form " + "MODULE::NAME where MODULE is the module path and NAME is the function or method name " + "contained in the module. For example, to set a breakpoint for calls to " + "FileUtil.copy() which is in osxphotos.fileutil, use: " + "'--breakpoint osxphotos.fileutil::FileUtil.copy'. More than one --breakpoint option can be specified.", + hidden=OSXPHOTOS_HIDDEN, + ), +] +DEBUG_OPTIONS = make_click_option_decorator(*_DEBUG_PARAMETERS) + +_THEME_PARAMETER = click.Option( + ["--theme"], + metavar="THEME", + type=click.Choice(["dark", "light", "mono", "plain"], case_sensitive=False), + help="Specify the color theme to use for output. " + "Valid themes are 'dark', 'light', 'mono', and 'plain'. " + "Defaults to 'dark' or 'light' depending on system dark mode setting.", +) +THEME_OPTION = make_click_option_decorator(_THEME_PARAMETER) + +_VERBOSE_PARAMETER = click.Option( + ["--verbose", "-V", "verbose_flag"], + count=True, + help="Print verbose output; may be specified multiple times for more verbose output.", +) +VERBOSE_OPTION = make_click_option_decorator(_VERBOSE_PARAMETER) + +_TIMESTAMP_PARAMETER = click.Option( + ["--timestamp"], is_flag=True, help="Add time stamp to verbose output" +) +TIMESTAMP_OPTION = make_click_option_decorator(_TIMESTAMP_PARAMETER) diff --git a/osxphotos/cli/common.py b/osxphotos/cli/common.py index 337809d4..59f52ee1 100644 --- a/osxphotos/cli/common.py +++ b/osxphotos/cli/common.py @@ -1,5 +1,6 @@ """Globals and constants use by the CLI commands""" +from __future__ import annotations import os import pathlib @@ -14,8 +15,6 @@ from osxphotos._constants import APP_NAME from osxphotos._version import __version__ from osxphotos.utils import get_latest_version -from .param_types import * - # used to show/hide hidden commands OSXPHOTOS_HIDDEN = not bool(os.getenv("OSXPHOTOS_SHOW_HIDDEN", default=False)) @@ -31,16 +30,6 @@ CLI_COLOR_WARNING = "yellow" __all__ = [ "CLI_COLOR_ERROR", "CLI_COLOR_WARNING", - "DB_ARGUMENT", - "DB_OPTION", - "DEBUG_OPTIONS", - "DELETED_OPTIONS", - "FIELD_OPTION", - "JSON_OPTION", - "QUERY_OPTIONS", - "THEME_OPTION", - "VERBOSE_OPTION", - "TIMESTAMP_OPTION", "get_photos_db", "noop", "time_stamp", @@ -90,540 +79,6 @@ def get_photos_db(*db_options): return None -VERSION_CHECK_OPTION = click.option("--no-version-check", required=False, is_flag=True) - -DB_OPTION = click.option( - "--db", - required=False, - metavar="PHOTOS_LIBRARY_PATH", - 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.", -) - -FIELD_OPTION = click.option( - "--field", - "-f", - metavar="FIELD TEMPLATE", - multiple=True, - nargs=2, - help="Output only specified custom fields. " - "FIELD is the name of the field and TEMPLATE is the template to use as the field value. " - "May be repeated to output multiple fields. " - "For example, to output photo uuid, name, and title: " - '`--field uuid "{uuid}" --field name "{original_name}" --field title "{title}"`.', -) - - -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( - "--no-keyword", - is_flag=True, - help="Search for photos with no 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( - "--not-edited", - is_flag=True, - help="Search for photos that have not 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( - "--year", - metavar="YEAR", - help="Search for items from a specific year, e.g. --year 2022 to find all photos from the year 2022. " - "May be repeated to search multiple years.", - multiple=True, - type=int, - ), - o( - "--added-before", - metavar="DATE", - help="Search for items added to the library before a specific date/time, " - "e.g. --added-before 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( - "--added-after", - metavar="DATE", - help="Search for items added to the libray after a specific date/time, " - "e.g. --added-after 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( - "--added-in-last", - metavar="TIME_DELTA", - help="Search for items added to the library in the last TIME_DELTA, " - "where TIME_DELTA is a string like " - "'12 hrs', '1 day', '1d', '1 week', '2weeks', '1 month', '1 year'. " - "for example, `--added-in-last 7d` and `--added-in-last '1 week'` are equivalent. " - "months are assumed to be 30 days and years are assumed to be 365 days. " - "Common English abbreviations are accepted, e.g. d, day, days or m, min, minutes.", - type=TimeOffset(), - ), - 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( - "--not-reference", - is_flag=True, - help="Search for photos that are not references, that is, they were copied into the Photos library " - "and are managed by Photos.", - ), - 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("--missing", is_flag=True, help="Search for photos missing from disk."), - o( - "--not-missing", - is_flag=True, - help="Search for photos present on disk (e.g. not missing).", - ), - o( - "--cloudasset", - is_flag=True, - help="Search for photos that are part of an iCloud library", - ), - o( - "--not-cloudasset", - is_flag=True, - help="Search for photos that are not part of an iCloud library", - ), - o( - "--incloud", - is_flag=True, - help="Search for photos that are in iCloud (have been synched)", - ), - o( - "--not-incloud", - is_flag=True, - help="Search for photos that are not in iCloud (have not been synched)", - ), - 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 DEBUG_OPTIONS(f): - o = click.option - options = [ - o( - "--debug", - is_flag=True, - help="Enable debug output.", - hidden=OSXPHOTOS_HIDDEN, - ), - o( - "--watch", - metavar="MODULE::NAME", - multiple=True, - help="Watch function or method calls. The function to watch must be in the form " - "MODULE::NAME where MODULE is the module path and NAME is the function or method name " - "contained in the module. For example, to watch all calls to FileUtil.copy() which is in " - "osxphotos.fileutil, use: " - "'--watch osxphotos.fileutil::FileUtil.copy'. More than one --watch option can be specified.", - hidden=OSXPHOTOS_HIDDEN, - ), - o( - "--breakpoint", - metavar="MODULE::NAME", - multiple=True, - help="Add breakpoint to function calls. The function to watch must be in the form " - "MODULE::NAME where MODULE is the module path and NAME is the function or method name " - "contained in the module. For example, to set a breakpoint for calls to " - "FileUtil.copy() which is in osxphotos.fileutil, use: " - "'--breakpoint osxphotos.fileutil::FileUtil.copy'. More than one --breakpoint option can be specified.", - hidden=OSXPHOTOS_HIDDEN, - ), - ] - for o in options[::-1]: - f = o(f) - return f - - -THEME_OPTION = click.option( - "--theme", - metavar="THEME", - type=click.Choice(["dark", "light", "mono", "plain"], case_sensitive=False), - help="Specify the color theme to use for output. " - "Valid themes are 'dark', 'light', 'mono', and 'plain'. " - "Defaults to 'dark' or 'light' depending on system dark mode setting.", -) - -VERBOSE_OPTION = click.option( - "--verbose", - "-V", - "verbose_flag", - count=True, - help="Print verbose output; may be specified multiple times for more verbose output.", -) - -TIMESTAMP_OPTION = click.option( - "--timestamp", is_flag=True, help="Add time stamp to verbose output" -) - - def get_config_dir() -> pathlib.Path: """Get the directory where config files are stored; create it if necessary.""" config_dir = xdg_config_home() / APP_NAME diff --git a/osxphotos/cli/debug_dump.py b/osxphotos/cli/debug_dump.py index 211c1116..1908ac10 100644 --- a/osxphotos/cli/debug_dump.py +++ b/osxphotos/cli/debug_dump.py @@ -8,16 +8,17 @@ from rich import print import osxphotos from osxphotos._constants import _PHOTOS_4_VERSION, _UNKNOWN_PLACE +from osxphotos.queryoptions import query_options_from_kwargs -from .common import ( +from .cli_params import ( DB_ARGUMENT, DB_OPTION, JSON_OPTION, - OSXPHOTOS_HIDDEN, + QUERY_OPTIONS, TIMESTAMP_OPTION, VERBOSE_OPTION, - get_photos_db, ) +from .common import OSXPHOTOS_HIDDEN, get_photos_db from .list import _list_libraries from .verbose import verbose_print @@ -32,22 +33,24 @@ from .verbose import verbose_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, -) @VERBOSE_OPTION @TIMESTAMP_OPTION +@QUERY_OPTIONS @click.pass_obj @click.pass_context -def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose_flag, timestamp): - """Print out debug info""" +def debug_dump( + ctx, cli_obj, db, photos_library, dump, verbose_flag, timestamp, **kwargs +): + """Print out debug info. + + When run with --dump photos, any of the query options can be used to limit the + photos printed. For example, to print info on currently selected photos: + + osxphotos debug-dump --dump photos --selected + """ verbose = verbose_print(verbose_flag, timestamp) - db = get_photos_db(*photos_library, db, cli_obj.db) + db = get_photos_db(*photos_library, db, cli_obj.db if cli_obj else None) 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) @@ -87,16 +90,15 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose_flag, times 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) + query_options = query_options_from_kwargs(**kwargs) + photos = photosdb.query(options=query_options) + uuid = [photo.uuid for photo in photos] + 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: try: val = getattr(photosdb, attr) diff --git a/osxphotos/cli/docs.py b/osxphotos/cli/docs.py index cf6cdb62..51dac234 100644 --- a/osxphotos/cli/docs.py +++ b/osxphotos/cli/docs.py @@ -13,10 +13,10 @@ from osxphotos._version import __version__ from .common import get_config_dir, get_data_dir -@click.command() +@click.command(name="docs") @click.pass_obj @click.pass_context -def docs(ctx, cli_obj): +def docs_command(ctx, cli_obj): """Open osxphotos documentation in your browser.""" # first check if docs installed in old location in confir dir and if so, delete them diff --git a/osxphotos/cli/dump.py b/osxphotos/cli/dump.py index 731c8bd3..ce3f5957 100644 --- a/osxphotos/cli/dump.py +++ b/osxphotos/cli/dump.py @@ -11,15 +11,15 @@ from osxphotos.cli.click_rich_echo import ( from osxphotos.phototemplate import RenderOptions from osxphotos.queryoptions import QueryOptions -from .color_themes import get_default_theme -from .common import ( +from .cli_params import ( DB_ARGUMENT, DB_OPTION, DELETED_OPTIONS, FIELD_OPTION, JSON_OPTION, - get_photos_db, ) +from .color_themes import get_default_theme +from .common import get_photos_db from .list import _list_libraries from .print_photo_info import print_photo_fields, print_photo_info from .verbose import get_verbose_console @@ -56,7 +56,11 @@ def dump( photos_library, print_template, ): - """Print list of all photos & associated info from the Photos library.""" + """Print list of all photos & associated info from the Photos library. + + NOTE: dump is DEPRECATED and will be removed in a future release. + Use `osxphotos query` instead. + """ # below needed for to make CliRunner work for testing cli_db = cli_obj.db if cli_obj is not None else None diff --git a/osxphotos/cli/exiftool_cli.py b/osxphotos/cli/exiftool_cli.py index 593595fe..d47f3457 100644 --- a/osxphotos/cli/exiftool_cli.py +++ b/osxphotos/cli/exiftool_cli.py @@ -17,14 +17,9 @@ from osxphotos.fileutil import FileUtil, FileUtilNoOp from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter from osxphotos.utils import pluralize +from .cli_params import DB_OPTION, THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION from .click_rich_echo import rich_click_echo, rich_echo_error -from .common import ( - DB_OPTION, - THEME_OPTION, - TIMESTAMP_OPTION, - VERBOSE_OPTION, - get_photos_db, -) +from .common import get_photos_db from .export import export, render_and_validate_report from .param_types import ExportDBType, TemplateString from .report_writer import ReportWriterNoOp, export_report_writer_factory diff --git a/osxphotos/cli/export.py b/osxphotos/cli/export.py index 4406720e..c2a8753e 100644 --- a/osxphotos/cli/export.py +++ b/osxphotos/cli/export.py @@ -1,5 +1,6 @@ """export command for osxphotos CLI""" +import inspect import os import pathlib import platform @@ -55,29 +56,31 @@ from osxphotos.photokit import ( ) from osxphotos.photosalbum import PhotosAlbum from osxphotos.phototemplate import PhotoTemplate, RenderOptions -from osxphotos.queryoptions import QueryOptions, load_uuid_from_file +from osxphotos.queryoptions import load_uuid_from_file, query_options_from_kwargs from osxphotos.uti import get_preferred_uti_extension from osxphotos.utils import ( - get_macos_version, format_sec_to_hhmmss, + get_macos_version, normalize_fs_path, pluralize, ) -from .click_rich_echo import rich_click_echo, rich_echo, rich_echo_error -from .common import ( - CLI_COLOR_ERROR, - CLI_COLOR_WARNING, +from .cli_params import ( DB_ARGUMENT, DB_OPTION, DELETED_OPTIONS, JSON_OPTION, - OSXPHOTOS_CRASH_LOG, - OSXPHOTOS_HIDDEN, QUERY_OPTIONS, THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION, +) +from .click_rich_echo import rich_click_echo, rich_echo, rich_echo_error +from .common import ( + CLI_COLOR_ERROR, + CLI_COLOR_WARNING, + OSXPHOTOS_CRASH_LOG, + OSXPHOTOS_HIDDEN, get_photos_db, noop, ) @@ -940,7 +943,8 @@ def export( # re-set the local vars to the corresponding config value # this isn't elegant but avoids having to rewrite this function to use cfg.varname for every parameter - + # the query options appear to be unaccessed but they are used below by query_options_from_kwargs + # which accesses them via locals() to avoid a long list of parameters add_exported_to_album = cfg.add_exported_to_album add_missing_to_album = cfg.add_missing_to_album add_skipped_to_album = cfg.add_skipped_to_album @@ -1165,14 +1169,14 @@ def export( if config_only and not save_config: rich_click_echo( - "[error]--config-only must be used with --save-config", + "[error]Incompatible export options: --config-only must be used with --save-config", err=True, ) sys.exit(1) if all(x in [s.lower() for s in sidecar] for x in ["json", "exiftool"]): rich_click_echo( - "[error]Cannot use --sidecar json with --sidecar exiftool due to name collisions", + "[error]Incompatible export options:: cannot use --sidecar json with --sidecar exiftool due to name collisions", err=True, ) sys.exit(1) @@ -1263,12 +1267,6 @@ def export( 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) @@ -1345,92 +1343,12 @@ def export( # enable beta features if requested photosdb._beta = beta - query_options = QueryOptions( - added_after=added_after, - added_before=added_before, - added_in_last=added_in_last, - album=album, - burst_photos=export_bursts, - burst=burst, - cloudasset=cloudasset, - deleted_only=deleted_only, - deleted=deleted, - description=description, - duplicate=duplicate, - edited=edited, - exif=exif, - external_edit=external_edit, - favorite=favorite, - folder=folder, - from_date=from_date, - from_time=from_time, - function=query_function, - has_comment=has_comment, - has_likes=has_likes, - has_raw=has_raw, - hdr=hdr, - hidden=hidden, - ignore_case=ignore_case, - in_album=in_album, - incloud=incloud, - is_reference=is_reference, - keyword=keyword, - label=label, - live=live, - location=location, - max_size=max_size, - min_size=min_size, - # skip missing bursts if using --download-missing by itself as AppleScript otherwise causes errors - missing_bursts=(download_missing and use_photokit) or not download_missing, - missing=missing, - movies=movies, - name=name, - no_comment=no_comment, - no_description=no_description, - no_likes=no_likes, - no_location=no_location, - no_keyword=no_keyword, - no_place=no_place, - no_title=no_title, - not_burst=not_burst, - not_cloudasset=not_cloudasset, - not_edited=not_edited, - not_favorite=not_favorite, - not_hdr=not_hdr, - not_hidden=not_hidden, - not_in_album=not_in_album, - not_incloud=not_incloud, - not_live=not_live, - not_missing=not_missing, - not_panorama=not_panorama, - not_portrait=not_portrait, - not_reference=not_reference, - not_screenshot=not_screenshot, - not_selfie=not_selfie, - not_shared=not_shared, - not_slow_mo=not_slow_mo, - not_time_lapse=not_time_lapse, - panorama=panorama, - person=person, - photos=photos, - place=place, - portrait=portrait, - query_eval=query_eval, - regex=regex, - screenshot=screenshot, - selected=selected, - selfie=selfie, - shared=shared, - slow_mo=slow_mo, - time_lapse=time_lapse, - title=title, - to_date=to_date, - to_time=to_time, - uti=uti, - uuid=uuid, - year=year, + query_kwargs = locals() + # skip missing bursts if using --download-missing by itself as AppleScript otherwise causes errors + query_kwargs["missing_bursts"] = ( + (download_missing and use_photokit) or not download_missing, ) - + query_options = query_options_from_kwargs(**query_kwargs) try: photos = photosdb.query(query_options) except ValueError as e: @@ -1495,59 +1413,16 @@ def export( ) for p in photos: photo_num += 1 - export_results = export_photo( - photo=p, - dest=dest, - album_keyword=album_keyword, - convert_to_jpeg=convert_to_jpeg, - description_template=description_template, - directory=directory, - download_missing=download_missing, - dry_run=dry_run, - edited_suffix=edited_suffix, - exiftool_merge_keywords=exiftool_merge_keywords, - exiftool_merge_persons=exiftool_merge_persons, - exiftool_option=exiftool_option, - exiftool=exiftool, - export_as_hardlink=export_as_hardlink, - export_by_date=export_by_date, - export_db=export_db, - export_dir=dest, - export_edited=export_edited, - export_live=export_live, - export_preview=preview, - export_raw=export_raw, - favorite_rating=favorite_rating, - filename_template=filename_template, - fileutil=fileutil, - force_update=force_update, - ignore_date_modified=ignore_date_modified, - ignore_signature=ignore_signature, - jpeg_ext=jpeg_ext, - jpeg_quality=jpeg_quality, - keyword_template=keyword_template, - num_photos=num_photos, - original_name=original_name, - original_suffix=original_suffix, - overwrite=overwrite, - person_keyword=person_keyword, - photo_num=photo_num, - preview_if_missing=preview_if_missing, - preview_suffix=preview_suffix, - replace_keywords=replace_keywords, - retry=retry, - sidecar_drop_ext=sidecar_drop_ext, - sidecar=sidecar, - skip_original_if_edited=skip_original_if_edited, - strip=strip, - touch_file=touch_file, - update=update, - update_errors=update_errors, - use_photokit=use_photokit, - use_photos_export=use_photos_export, - verbose=verbose, - tmpdir=tmpdir, - ) + # hack to avoid passing all the options to export_photo + kwargs = { + k: v + for k, v in locals().items() + if k in inspect.getfullargspec(export_photo).args + } + kwargs["photo"] = p + kwargs["export_dir"] = dest + kwargs["export_preview"] = preview + export_results = export_photo(**kwargs) if post_function: for function in post_function: @@ -1896,7 +1771,6 @@ def export_photo( Raises: ValueError on invalid filename_template """ - export_original = not (skip_original_if_edited and photo.hasadjustments) # can't export edited if photo doesn't have edited versions diff --git a/osxphotos/cli/exportdb.py b/osxphotos/cli/exportdb.py index a0256782..b85ca333 100644 --- a/osxphotos/cli/exportdb.py +++ b/osxphotos/cli/exportdb.py @@ -34,7 +34,7 @@ from .click_rich_echo import ( set_rich_theme, ) from .color_themes import get_theme -from .common import TIMESTAMP_OPTION, VERBOSE_OPTION +from .cli_params import TIMESTAMP_OPTION, VERBOSE_OPTION from .export import render_and_validate_report from .param_types import TemplateString from .report_writer import export_report_writer_factory diff --git a/osxphotos/cli/grep.py b/osxphotos/cli/grep.py index 43c335d4..8c0aaa63 100644 --- a/osxphotos/cli/grep.py +++ b/osxphotos/cli/grep.py @@ -8,7 +8,8 @@ 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 +from .cli_params import DB_OPTION, OSXPHOTOS_HIDDEN +from .common import get_photos_db @click.command(name="grep", hidden=OSXPHOTOS_HIDDEN) diff --git a/osxphotos/cli/import_cli.py b/osxphotos/cli/import_cli.py index d55753c0..69c74a21 100644 --- a/osxphotos/cli/import_cli.py +++ b/osxphotos/cli/import_cli.py @@ -27,7 +27,8 @@ from strpdatetime import strpdatetime from osxphotos._constants import _OSXPHOTOS_NONE_SENTINEL from osxphotos._version import __version__ -from osxphotos.cli.common import TIMESTAMP_OPTION, VERBOSE_OPTION, get_data_dir +from osxphotos.cli.cli_params import TIMESTAMP_OPTION, VERBOSE_OPTION +from osxphotos.cli.common import get_data_dir from osxphotos.cli.help import HELP_WIDTH from osxphotos.cli.param_types import FunctionCall, StrpDateTimePattern, TemplateString from osxphotos.datetime_utils import ( @@ -44,8 +45,8 @@ from osxphotos.phototemplate import PhotoTemplate, RenderOptions from osxphotos.sqlitekvstore import SQLiteKVStore from osxphotos.utils import pluralize +from .cli_params import THEME_OPTION from .click_rich_echo import rich_click_echo, rich_echo_error -from .common import THEME_OPTION from .rich_progress import rich_progress from .verbose import get_verbose_console, verbose_print diff --git a/osxphotos/cli/info.py b/osxphotos/cli/info.py index ec3e51be..84012d07 100644 --- a/osxphotos/cli/info.py +++ b/osxphotos/cli/info.py @@ -8,7 +8,8 @@ import yaml import osxphotos from osxphotos._constants import _PHOTOS_4_VERSION -from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db +from .cli_params import DB_ARGUMENT, DB_OPTION, JSON_OPTION +from .common import get_photos_db from .list import _list_libraries diff --git a/osxphotos/cli/install_uninstall_run.py b/osxphotos/cli/install_uninstall_run.py index 2fe17b37..c9900401 100644 --- a/osxphotos/cli/install_uninstall_run.py +++ b/osxphotos/cli/install_uninstall_run.py @@ -1,5 +1,7 @@ """install/uninstall/run commands for osxphotos CLI""" + +import contextlib import sys from runpy import run_module, run_path @@ -50,14 +52,19 @@ def uninstall(packages, yes): @click.command(name="run", cls=RunCommand) -# help command passed just to keep click from intercepting help -# and allowing --help to be passed to the script being run @click.option("--help", "-h", is_flag=True, help="Show this message and exit") @click.argument("python_file", nargs=1, type=click.Path(exists=True)) @click.argument("args", metavar="ARGS", nargs=-1) def run(python_file, help, args): """Run a python file using same environment as osxphotos. Any args are made available to the python file.""" - # drop first two arguments, which are the osxphotos script and run command - sys.argv = sys.argv[2:] + + # Need to drop all the args from sys.argv up to and including the run command + # For example, command could be one of the following: + # osxphotos run example.py --help + # osxphotos --debug run example.py --verbose --db /path/to/photos.db + # etc. + with contextlib.suppress(ValueError): + index = sys.argv.index("run") + sys.argv = sys.argv[index + 1 :] run_path(python_file, run_name="__main__") diff --git a/osxphotos/cli/keywords.py b/osxphotos/cli/keywords.py index 919b44ae..f592a55f 100644 --- a/osxphotos/cli/keywords.py +++ b/osxphotos/cli/keywords.py @@ -7,7 +7,8 @@ import yaml import osxphotos -from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db +from .cli_params import DB_ARGUMENT, DB_OPTION, JSON_OPTION +from .common import get_photos_db from .list import _list_libraries diff --git a/osxphotos/cli/kvstore.py b/osxphotos/cli/kvstore.py new file mode 100644 index 00000000..a9a71f00 --- /dev/null +++ b/osxphotos/cli/kvstore.py @@ -0,0 +1,50 @@ +"""Simple interface to SQLiteKVStore for storing state between runs of the CLI tool.""" + + +from __future__ import annotations + +import atexit +import contextlib +import datetime + +from osxphotos.sqlitekvstore import SQLiteKVStore + +from .common import get_data_dir + +__all__ = ["kvstore"] + +# Store open connections +__kvstores = [] + + +@atexit.register +def close_kvstore(): + """Close any open SQLiteKVStore databases""" + global __kvstores + for kv in __kvstores: + with contextlib.suppress(Exception): + kv.close() + + +def kvstore(name: str) -> SQLiteKVStore: + """Return a key/value store for storing state between commands. + + The key/value store is a SQLite database stored in the user's XDG data directory, + usually `~/.local/share/`. The key/value store can be used like a dict to store + arbitrary key/value pairs which persist between runs of the CLI tool. + + Args: + name: a unique name for the key/value store + + Returns: + SQLiteKVStore object + """ + global __kvstores + data_dir = get_data_dir() + if not name.endswith(".db"): + name += ".db" + kv = SQLiteKVStore(str(data_dir / name), wal=True) + if not kv.about: + kv.about = f"Key/value store for {name}, created by osxphotos CLI on {datetime.datetime.now()}" + __kvstores.append(kv) + return kv diff --git a/osxphotos/cli/labels.py b/osxphotos/cli/labels.py index 2e55178e..459f748b 100644 --- a/osxphotos/cli/labels.py +++ b/osxphotos/cli/labels.py @@ -7,7 +7,8 @@ import yaml import osxphotos -from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db +from .cli_params import DB_ARGUMENT, DB_OPTION, JSON_OPTION +from .common import get_photos_db from .list import _list_libraries diff --git a/osxphotos/cli/list.py b/osxphotos/cli/list.py index 836da420..fbee4a93 100644 --- a/osxphotos/cli/list.py +++ b/osxphotos/cli/list.py @@ -6,7 +6,7 @@ import click import osxphotos -from .common import JSON_OPTION +from .cli_params import JSON_OPTION @click.command(name="list") diff --git a/osxphotos/cli/orphans.py b/osxphotos/cli/orphans.py index 75214fa5..5188ca3b 100644 --- a/osxphotos/cli/orphans.py +++ b/osxphotos/cli/orphans.py @@ -18,14 +18,9 @@ from osxphotos._constants import _PHOTOS_4_VERSION from osxphotos.fileutil import FileUtil from osxphotos.utils import increment_filename, pluralize +from .cli_params import DB_OPTION, THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION from .click_rich_echo import rich_click_echo as echo -from .common import ( - DB_OPTION, - THEME_OPTION, - TIMESTAMP_OPTION, - VERBOSE_OPTION, - get_photos_db, -) +from .common import get_photos_db from .help import get_help_msg from .list import _list_libraries from .verbose import verbose_print diff --git a/osxphotos/cli/param_types.py b/osxphotos/cli/param_types.py index 762f9417..c95b85c1 100644 --- a/osxphotos/cli/param_types.py +++ b/osxphotos/cli/param_types.py @@ -20,6 +20,7 @@ __all__ = [ "BitMathSize", "DateOffset", "DateTimeISO8601", + "DeprecatedPath", "ExportDBType", "FunctionCall", "StrpDateTimePattern", @@ -31,6 +32,26 @@ __all__ = [ ] +class DeprecatedPath(click.Path): + """A click.Path that prints a deprecation warning when used.""" + + name = "DEPRECATED_PATH" + + def __init__(self, *args, **kwargs): + if "deprecation_warning" in kwargs: + self.deprecation_warning = kwargs.pop("deprecation_warning") + else: + self.deprecation_warning = "This option is deprecated and will be removed in a future version of osxphotos." + super().__init__(*args, **kwargs) + + def convert(self, value, param, ctx): + click.echo( + f"WARNING: {param.name} is deprecated. {self.deprecation_warning}", + err=True, + ) + return super().convert(value, param, ctx) + + class DateTimeISO8601(click.ParamType): name = "DATETIME" diff --git a/osxphotos/cli/persons.py b/osxphotos/cli/persons.py index 30faa244..763d138b 100644 --- a/osxphotos/cli/persons.py +++ b/osxphotos/cli/persons.py @@ -6,7 +6,8 @@ import yaml import osxphotos -from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db +from .cli_params import DB_ARGUMENT, DB_OPTION, JSON_OPTION +from .common import get_photos_db from .list import _list_libraries diff --git a/osxphotos/cli/photo_inspect.py b/osxphotos/cli/photo_inspect.py index 75da84ed..d26a29c5 100644 --- a/osxphotos/cli/photo_inspect.py +++ b/osxphotos/cli/photo_inspect.py @@ -26,8 +26,9 @@ from osxphotos.rich_utils import add_rich_markup_tag from osxphotos.text_detection import detect_text as detect_text_in_photo from osxphotos.utils import dd_to_dms_str +from .cli_params import DB_OPTION, THEME_OPTION from .color_themes import get_theme -from .common import DB_OPTION, THEME_OPTION, get_photos_db +from .common import get_photos_db # global that tracks UUID being inspected CURRENT_UUID = None diff --git a/osxphotos/cli/places.py b/osxphotos/cli/places.py index 235b4517..73486635 100644 --- a/osxphotos/cli/places.py +++ b/osxphotos/cli/places.py @@ -8,7 +8,8 @@ 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 .cli_params import DB_ARGUMENT, DB_OPTION, JSON_OPTION +from .common import get_photos_db from .list import _list_libraries diff --git a/osxphotos/cli/query.py b/osxphotos/cli/query.py index e3c3a4dc..a719fdf2 100644 --- a/osxphotos/cli/query.py +++ b/osxphotos/cli/query.py @@ -11,21 +11,18 @@ from osxphotos.cli.click_rich_echo import ( from osxphotos.debug import set_debug from osxphotos.photosalbum import PhotosAlbum from osxphotos.phototemplate import RenderOptions -from osxphotos.queryoptions import QueryOptions, load_uuid_from_file +from osxphotos.queryoptions import query_options_from_kwargs -from .color_themes import get_default_theme -from .common import ( - CLI_COLOR_ERROR, - CLI_COLOR_WARNING, +from .cli_params import ( DB_ARGUMENT, DB_OPTION, DELETED_OPTIONS, FIELD_OPTION, JSON_OPTION, - OSXPHOTOS_HIDDEN, QUERY_OPTIONS, - get_photos_db, ) +from .color_themes import get_default_theme +from .common import CLI_COLOR_ERROR, CLI_COLOR_WARNING, OSXPHOTOS_HIDDEN, get_photos_db from .list import _list_libraries from .print_photo_info import print_photo_fields, print_photo_info from .verbose import get_verbose_console @@ -36,7 +33,6 @@ from .verbose import get_verbose_console @JSON_OPTION @QUERY_OPTIONS @DELETED_OPTIONS - @click.option( "--add-to-album", metavar="ALBUM", @@ -63,9 +59,6 @@ from .verbose import get_verbose_console "Most useful with --quiet. " "May be repeated to print multiple template strings. ", ) -@click.option( - "--debug", required=False, is_flag=True, default=False, hidden=OSXPHOTOS_HIDDEN -) @DB_ARGUMENT @click.pass_obj @click.pass_context @@ -73,94 +66,13 @@ def query( ctx, cli_obj, db, - photos_library, - add_to_album, - added_after, - added_before, - added_in_last, - album, - burst, - cloudasset, - deleted_only, - deleted, - description, - duplicate, - edited, - exif, - external_edit, - favorite, field, - folder, - from_date, - from_time, - has_comment, - has_likes, - has_raw, - hdr, - hidden, - ignore_case, - in_album, - incloud, - is_reference, json_, - keyword, - label, - live, - location, - max_size, - min_size, - missing, - name, - no_comment, - no_description, - no_likes, - no_location, - no_keyword, - no_place, - no_title, - not_burst, - not_cloudasset, - not_edited, - not_favorite, - not_hdr, - not_hidden, - not_in_album, - not_incloud, - not_live, - not_missing, - not_panorama, - not_portrait, - not_reference, - not_screenshot, - not_selfie, - not_shared, - not_slow_mo, - not_time_lapse, - only_movies, - only_photos, - panorama, - person, - place, - portrait, print_template, - query_eval, - query_function, quiet, - regex, - screenshot, - selected, - selfie, - shared, - slow_mo, - time_lapse, - title, - to_date, - to_time, - uti, - uuid_from_file, - uuid, - year, - debug, # handled in cli/__init__.py + add_to_album, + photos_library, + **kwargs, ): """Query the Photos database using 1 or more search options; if more than one different option is provided, they are treated as "AND" @@ -173,95 +85,14 @@ def query( osxphotos query --person "John Doe" --person "Jane Doe" --keyword "vacation" will return all photos with either person of ("John Doe" OR "Jane Doe") AND keyword of "vacation" - """ - # if no query terms, show help and return - # sanity check input args - nonexclusive = [ - added_after, - added_before, - added_in_last, - album, - duplicate, - exif, - external_edit, - folder, - from_date, - from_time, - has_raw, - keyword, - label, - max_size, - min_size, - name, - person, - query_eval, - query_function, - regex, - selected, - to_date, - to_time, - uti, - uuid_from_file, - uuid, - year, - ] - exclusive = [ - (any(description), no_description), - (any(place), no_place), - (any(title), no_title), - (any(keyword), no_keyword), - (burst, not_burst), - (cloudasset, not_cloudasset), - (deleted, deleted_only), - (edited, not_edited), - (favorite, not_favorite), - (has_comment, no_comment), - (has_likes, no_likes), - (hdr, not_hdr), - (hidden, not_hidden), - (in_album, not_in_album), - (incloud, not_incloud), - (live, not_live), - (location, no_location), - (missing, not_missing), - (only_photos, only_movies), - (panorama, not_panorama), - (portrait, not_portrait), - (screenshot, not_screenshot), - (selfie, not_selfie), - (shared, not_shared), - (slow_mo, not_slow_mo), - (time_lapse, not_time_lapse), - (is_reference, not_reference), - ] - # 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 + If not query options are provided, all photos in the library will be returned. + """ # set console for rich_echo to be same as for verbose_ set_rich_console(get_verbose_console()) set_rich_theme(get_default_theme()) - # 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) @@ -272,99 +103,21 @@ def query( return photosdb = osxphotos.PhotosDB(dbfile=db) - query_options = QueryOptions( - added_after=added_after, - added_before=added_before, - added_in_last=added_in_last, - album=album, - burst=burst, - cloudasset=cloudasset, - deleted_only=deleted_only, - deleted=deleted, - description=description, - duplicate=duplicate, - edited=edited, - exif=exif, - external_edit=external_edit, - favorite=favorite, - folder=folder, - from_date=from_date, - from_time=from_time, - function=query_function, - has_comment=has_comment, - has_likes=has_likes, - has_raw=has_raw, - hdr=hdr, - hidden=hidden, - ignore_case=ignore_case, - in_album=in_album, - incloud=incloud, - is_reference=is_reference, - keyword=keyword, - label=label, - live=live, - location=location, - max_size=max_size, - min_size=min_size, - missing=missing, - movies=movies, - name=name, - no_comment=no_comment, - no_description=no_description, - no_likes=no_likes, - no_location=no_location, - no_keyword=no_keyword, - no_place=no_place, - no_title=no_title, - not_burst=not_burst, - not_cloudasset=not_cloudasset, - not_edited=not_edited, - not_favorite=not_favorite, - not_hdr=not_hdr, - not_hidden=not_hidden, - not_in_album=not_in_album, - not_incloud=not_incloud, - not_live=not_live, - not_missing=not_missing, - not_panorama=not_panorama, - not_portrait=not_portrait, - not_reference=not_reference, - not_screenshot=not_screenshot, - not_selfie=not_selfie, - not_shared=not_shared, - not_slow_mo=not_slow_mo, - not_time_lapse=not_time_lapse, - panorama=panorama, - person=person, - photos=photos, - place=place, - portrait=portrait, - query_eval=query_eval, - regex=regex, - screenshot=screenshot, - selected=selected, - selfie=selfie, - shared=shared, - slow_mo=slow_mo, - time_lapse=time_lapse, - title=title, - to_date=to_date, - to_time=to_time, - uti=uti, - uuid=uuid, - year=year, - ) + try: + query_options = query_options_from_kwargs(**kwargs) + except Exception as e: + raise click.BadOptionUsage("query", str(e)) from e 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) + 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 # below needed for to make CliRunner work for testing cli_json = cli_obj.json if cli_obj is not None else None diff --git a/osxphotos/cli/repl.py b/osxphotos/cli/repl.py index 77f76f04..87920d88 100644 --- a/osxphotos/cli/repl.py +++ b/osxphotos/cli/repl.py @@ -23,13 +23,8 @@ from osxphotos.queryoptions import ( query_options_from_kwargs, ) -from .common import ( - DB_ARGUMENT, - DB_OPTION, - DELETED_OPTIONS, - QUERY_OPTIONS, - get_photos_db, -) +from .cli_params import DB_ARGUMENT, DB_OPTION, DELETED_OPTIONS, QUERY_OPTIONS +from .common import get_photos_db @click.command(name="repl") diff --git a/osxphotos/cli/snap_diff.py b/osxphotos/cli/snap_diff.py index 0dd4e27a..c37d7f04 100644 --- a/osxphotos/cli/snap_diff.py +++ b/osxphotos/cli/snap_diff.py @@ -12,13 +12,8 @@ from rich.syntax import Syntax import osxphotos -from .common import ( - DB_OPTION, - OSXPHOTOS_SNAPSHOT_DIR, - TIMESTAMP_OPTION, - VERBOSE_OPTION, - get_photos_db, -) +from .cli_params import DB_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION +from .common import OSXPHOTOS_SNAPSHOT_DIR, get_photos_db from .verbose import verbose_print @@ -36,7 +31,7 @@ def snap(ctx, cli_obj, db): Works only on Photos library versions since Catalina (10.15) or newer. """ - db = get_photos_db(db, cli_obj.db) + db = get_photos_db(db, cli_obj.db if cli_obj else None) db_path = pathlib.Path(db) if db_path.is_file(): # assume it's the sqlite file @@ -127,7 +122,7 @@ def diff(ctx, cli_obj, db, raw_output, style, db2, verbose_flag, timestamp): ctx.exit(2) verbose(f"sqldiff found at '{sqldiff}'") - db = get_photos_db(db, cli_obj.db) + db = get_photos_db(db, cli_obj.db if cli_obj else None) db_path = pathlib.Path(db) if db_path.is_file(): # assume it's the sqlite file diff --git a/osxphotos/cli/sync.py b/osxphotos/cli/sync.py index 48786ace..c98ed993 100644 --- a/osxphotos/cli/sync.py +++ b/osxphotos/cli/sync.py @@ -24,15 +24,15 @@ from osxphotos.queryoptions import ( from osxphotos.sqlitekvstore import SQLiteKVStore from osxphotos.utils import pluralize -from .click_rich_echo import rich_click_echo as echo -from .click_rich_echo import rich_echo_error as echo_error -from .common import ( +from .cli_params import ( DB_OPTION, QUERY_OPTIONS, THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION, ) +from .click_rich_echo import rich_click_echo as echo +from .click_rich_echo import rich_echo_error as echo_error from .param_types import TemplateString from .report_writer import sync_report_writer_factory from .rich_progress import rich_progress @@ -640,7 +640,7 @@ def print_import_summary(results: SyncResults): ) @VERBOSE_OPTION @TIMESTAMP_OPTION -@QUERY_OPTIONS +@QUERY_OPTIONS(exclude=["--shared", "--not-shared"]) @DB_OPTION @THEME_OPTION @click.pass_obj @@ -721,14 +721,6 @@ def sync( ctx.exit(1) # filter out photos in shared albums as these cannot be updated - # Not elegant but works for now without completely refactoring QUERY_OPTIONS - if kwargs.get("shared"): - echo_error( - "[warning]--shared cannot be used with --import/--export " - "as photos in shared iCloud albums cannot be updated; " - "--shared will be ignored[/]" - ) - kwargs["shared"] = False kwargs["not_shared"] = True set_ = parse_set_merge(set_) diff --git a/osxphotos/cli/timewarp.py b/osxphotos/cli/timewarp.py index 7f1cb8c4..493ed148 100644 --- a/osxphotos/cli/timewarp.py +++ b/osxphotos/cli/timewarp.py @@ -25,10 +25,10 @@ from osxphotos.photosalbum import PhotosAlbumPhotoScript from osxphotos.phototz import PhotoTimeZone, PhotoTimeZoneUpdater from osxphotos.utils import noop, pluralize +from .cli_params import THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION from .click_rich_echo import rich_click_echo as echo from .click_rich_echo import rich_echo_error as echo_error from .color_themes import get_theme -from .common import THEME_OPTION, TIMESTAMP_OPTION, VERBOSE_OPTION from .darkmode import is_dark_mode from .help import HELP_WIDTH, rich_text from .param_types import ( diff --git a/osxphotos/cli/verbose.py b/osxphotos/cli/verbose.py index ba191768..57e745a7 100644 --- a/osxphotos/cli/verbose.py +++ b/osxphotos/cli/verbose.py @@ -56,21 +56,22 @@ def noop(*args, **kwargs): pass -def verbose(*args, level: int = 1, **kwargs): +def verbose(*args, level: int = 1): """Print verbose output Args: *args: arguments to pass to verbose function for printing level: verbose level; if level > get_verbose_level(), output is suppressed - - Notes: - Normally you should use verbose_print() to get the verbose function instead of calling this directly - """ + + # Notes: + # Normally you should use verbose_print() to get the verbose function instead of calling this directly + # This is here so that verbose can be directly imported and used in other modules without calling verbose_print() + # Use of verbose_print() will set the verbose function so that calling verbose() will work as expected global __verbose_function if __verbose_function is None: return - __verbose_function(*args, level=level, **kwargs) + __verbose_function(*args, level=level) def set_verbose_level(level: int): diff --git a/osxphotos/queryoptions.py b/osxphotos/queryoptions.py index a3449ae6..62241bea 100644 --- a/osxphotos/queryoptions.py +++ b/osxphotos/queryoptions.py @@ -7,6 +7,7 @@ from dataclasses import asdict, dataclass from typing import Iterable, List, Optional, Tuple import bitmath +import click __all__ = ["QueryOptions", "query_options_from_kwargs", "IncompatibleQueryOptions"] @@ -249,6 +250,8 @@ def query_options_from_kwargs(**kwargs) -> QueryOptions: ("slow_mo", "not_slow_mo"), ("time_lapse", "not_time_lapse"), ("deleted", "not_deleted"), + ("deleted", "deleted_only"), + ("deleted_only", "not_deleted"), ] # TODO: add option to validate requiring at least one query arg for arg, not_arg in exclusive: @@ -256,7 +259,7 @@ def query_options_from_kwargs(**kwargs) -> QueryOptions: arg = arg.replace("_", "-") not_arg = not_arg.replace("_", "-") raise IncompatibleQueryOptions( - f"--{arg} and --{not_arg} are mutually exclusive" + f"Incompatible query options: --{arg} and --{not_arg} are mutually exclusive" ) # some options like title can be specified multiple times @@ -276,17 +279,18 @@ def query_options_from_kwargs(**kwargs) -> QueryOptions: include_movies = False # load UUIDs if necessary and append to any uuids passed with --uuid - uuid = None + uuids = list(kwargs.get("uuid", [])) # Click option is a tuple if uuid_from_file := kwargs.get("uuid_from_file"): - uuid_list = list(kwargs.get("uuid", [])) # Click option is a tuple - uuid_list.extend(load_uuid_from_file(uuid_from_file)) - uuid = tuple(uuid_list) + uuids.extend(load_uuid_from_file(uuid_from_file)) + uuids = tuple(uuids) 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 + query_dict["uuid"] = uuids + query_dict["function"] = kwargs.get("query_function") + return QueryOptions(**query_dict) diff --git a/osxphotos/sqlitekvstore.py b/osxphotos/sqlitekvstore.py index 8afad05c..f1053baf 100644 --- a/osxphotos/sqlitekvstore.py +++ b/osxphotos/sqlitekvstore.py @@ -176,6 +176,17 @@ class SQLiteKVStore: ) conn.commit() + @property + def path(self) -> str: + """Return path to the database""" + return self._dbpath + + def wipe(self): + """Wipe the database""" + self.connection().execute("DELETE FROM data;") + self.connection().commit() + self.vacuum() + def vacuum(self): """Vacuum the database, ref: https://www.sqlite.org/matrix/lang_vacuum.html""" self.connection().execute("VACUUM;") diff --git a/tests/test_cli.py b/tests/test_cli.py index 11448612..3716a15b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3616,7 +3616,7 @@ def test_export_sidecar_invalid(): ], ) assert result.exit_code != 0 - assert "Cannot use --sidecar json with --sidecar exiftool" in result.output + assert "cannot use --sidecar json with --sidecar exiftool" in result.output def test_export_live(): @@ -4242,7 +4242,9 @@ def test_places(): cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): - result = runner.invoke(places, [os.path.join(cwd, PLACES_PHOTOS_DB), "--json"]) + result = runner.invoke( + places, ["--db", os.path.join(cwd, PLACES_PHOTOS_DB), "--json"] + ) assert result.exit_code == 0 json_got = json.loads(result.output) assert json_got == json.loads(CLI_PLACES_JSON) @@ -4257,7 +4259,13 @@ def test_place_13(): with runner.isolated_filesystem(): result = runner.invoke( query, - [os.path.join(cwd, PLACES_PHOTOS_DB_13), "--json", "--place", "Adelaide"], + [ + "--db", + os.path.join(cwd, PLACES_PHOTOS_DB_13), + "--json", + "--place", + "Adelaide", + ], ) assert result.exit_code == 0 json_got = json.loads(result.output) @@ -4274,7 +4282,8 @@ def test_no_place_13(): # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - query, [os.path.join(cwd, PLACES_PHOTOS_DB_13), "--json", "--no-place"] + query, + ["--db", os.path.join(cwd, PLACES_PHOTOS_DB_13), "--json", "--no-place"], ) assert result.exit_code == 0 json_got = json.loads(result.output) @@ -4292,7 +4301,13 @@ def test_place_15_1(): with runner.isolated_filesystem(): result = runner.invoke( query, - [os.path.join(cwd, PLACES_PHOTOS_DB), "--json", "--place", "Washington"], + [ + "--db", + os.path.join(cwd, PLACES_PHOTOS_DB), + "--json", + "--place", + "Washington", + ], ) assert result.exit_code == 0 json_got = json.loads(result.output) @@ -4310,7 +4325,13 @@ def test_place_15_2(): with runner.isolated_filesystem(): result = runner.invoke( query, - [os.path.join(cwd, PLACES_PHOTOS_DB), "--json", "--place", "United States"], + [ + "--db", + os.path.join(cwd, PLACES_PHOTOS_DB), + "--json", + "--place", + "United States", + ], ) assert result.exit_code == 0 json_got = json.loads(result.output) @@ -4329,7 +4350,7 @@ def test_no_place_15(): # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - query, [os.path.join(cwd, PLACES_PHOTOS_DB), "--json", "--no-place"] + query, ["--db", os.path.join(cwd, PLACES_PHOTOS_DB), "--json", "--no-place"] ) assert result.exit_code == 0 json_got = json.loads(result.output) @@ -4346,7 +4367,14 @@ def test_no_folder_1_15(): # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - query, [os.path.join(cwd, PHOTOS_DB_15_7), "--json", "--folder", "Folder1"] + query, + [ + "--db", + os.path.join(cwd, PHOTOS_DB_15_7), + "--json", + "--folder", + "Folder1", + ], ) assert result.exit_code == 0 json_got = json.loads(result.output) @@ -4381,6 +4409,7 @@ def test_no_folder_2_15(): result = runner.invoke( query, [ + "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json", "--folder", @@ -4408,7 +4437,14 @@ def test_no_folder_1_14(): # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( - query, [os.path.join(cwd, PHOTOS_DB_14_6), "--json", "--folder", "Folder1"] + query, + [ + "--db", + os.path.join(cwd, PHOTOS_DB_14_6), + "--json", + "--folder", + "Folder1", + ], ) assert result.exit_code == 0 json_got = json.loads(result.output) @@ -5727,19 +5763,6 @@ def test_keywords(): assert json_got == KEYWORDS_JSON -# TODO: this fails with result.exit_code == 1 but I think this has to -# do with how pytest is invoking the command -# def test_albums_str(): -# """Test osxphotos albums string output """ - -# runner = CliRunner() -# cwd = os.getcwd() -# result = runner.invoke(albums, ["--db", os.path.join(cwd, PHOTOS_DB_15_7), ]) -# assert result.exit_code == 0 - -# assert result.output == ALBUMS_STR - - def test_albums_json(): """Test osxphotos albums json output""" @@ -6648,6 +6671,57 @@ def test_config_only(): assert "config.toml" in files +def test_config_command_line_precedence(): + """Test that command line options take precedence over config file""" + + runner = CliRunner() + cwd = os.getcwd() + + with runner.isolated_filesystem(): + # create a config file + with open("config.toml", "w") as fd: + fd.write("[export]\n") + fd.write( + "uuid = [" + + ", ".join(f'"{u}"' for u in UUID_EXPECTED_FROM_FILE) + + "]\n" + ) + + result = runner.invoke( + export, + [ + "--db", + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "-V", + "--load-config", + "config.toml", + ], + ) + assert result.exit_code == 0 + for uuid in UUID_EXPECTED_FROM_FILE: + assert uuid in result.output + + # now run with a command line option that should override the config file + result = runner.invoke( + export, + [ + "--db", + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "-V", + "--uuid", + UUID_NOT_FROM_FILE, + "--load-config", + "config.toml", + ], + ) + assert result.exit_code == 0 + assert UUID_NOT_FROM_FILE in result.output + for uuid in UUID_EXPECTED_FROM_FILE: + assert uuid not in result.output + + def test_export_exportdb(): """test --exportdb""" @@ -6657,7 +6731,14 @@ def test_export_exportdb(): with runner.isolated_filesystem(): result = runner.invoke( export, - [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "export.db"], + [ + "--db", + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "-V", + "--exportdb", + "export.db", + ], ) assert result.exit_code == 0 assert re.search(r"Created export database.*export\.db", result.output) @@ -7921,6 +8002,7 @@ def test_query_function(): result = runner.invoke( query, [ + "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--query-function", f"{tmpdir}/query1.py::query", @@ -7942,6 +8024,7 @@ def test_query_added_after(): results = runner.invoke( query, [ + "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json", "--added-after", @@ -7962,6 +8045,7 @@ def test_query_added_before(): results = runner.invoke( query, [ + "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json", "--added-before", diff --git a/tests/test_cli_all_commands.py b/tests/test_cli_all_commands.py new file mode 100644 index 00000000..0b41823f --- /dev/null +++ b/tests/test_cli_all_commands.py @@ -0,0 +1,96 @@ +""" Test osxphotos cli commands to verify they run without error. + +These tests simply run the commands to verify no errors are thrown. +They do not verify the output of the commands. More complex tests are +in test_cli.py and test_cli__xxx.py for specific commands. + +Complex commands such as export are not tested here. +""" + +from __future__ import annotations + +import os +from typing import Any, Callable + +import pytest +from click.testing import CliRunner + +TEST_DB = "tests/Test-13.0.0.photoslibrary" +TEST_DB = os.path.join(os.getcwd(), TEST_DB) +TEST_RUN_SCRIPT = "examples/cli_example_1.py" + + +@pytest.fixture(scope="module") +def runner() -> CliRunner: + return CliRunner() + + +from osxphotos.cli import ( + about, + albums, + debug_dump, + docs_command, + dump, + grep, + help, + info, + keywords, + labels, + list_libraries, + orphans, + persons, + places, + theme, + tutorial, + uuid, + version, +) + + +def test_about(runner: CliRunner): + with runner.isolated_filesystem(): + result = runner.invoke(about) + assert result.exit_code == 0 + + +@pytest.mark.parametrize( + "command", + [ + albums, + docs_command, + dump, + help, + info, + keywords, + labels, + list_libraries, + orphans, + persons, + places, + tutorial, + uuid, + version, + ], +) +def test_cli_comands(runner: CliRunner, command: Callable[..., Any]): + with runner.isolated_filesystem(): + result = runner.invoke(albums, ["--db", TEST_DB]) + assert result.exit_code == 0 + + +def test_grep(runner: CliRunner): + with runner.isolated_filesystem(): + result = runner.invoke(grep, ["--db", TEST_DB, "test"]) + assert result.exit_code == 0 + + +def test_debug_dump(runner: CliRunner): + with runner.isolated_filesystem(): + result = runner.invoke(debug_dump, ["--db", TEST_DB, "--dump", "persons"]) + assert result.exit_code == 0 + + +def test_theme(runner: CliRunner): + with runner.isolated_filesystem(): + result = runner.invoke(theme, ["--list"]) + assert result.exit_code == 0 diff --git a/tests/test_sqlitekvstore.py b/tests/test_sqlitekvstore.py index 13e61979..bd201eef 100644 --- a/tests/test_sqlitekvstore.py +++ b/tests/test_sqlitekvstore.py @@ -8,7 +8,7 @@ from typing import Any import pytest -import osxphotos.sqlitekvstore +from osxphotos.sqlitekvstore import SQLiteKVStore def pickle_and_zip(data: Any) -> bytes: @@ -41,7 +41,7 @@ def unzip_and_unpickle(data: bytes) -> Any: def test_basic_get_set(tmpdir): """Test basic functionality""" dbpath = tmpdir / "kvtest.db" - kvstore = osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) + kvstore = SQLiteKVStore(dbpath) kvstore.set("foo", "bar") assert kvstore.get("foo") == "bar" assert kvstore.get("FOOBAR") is None @@ -61,7 +61,7 @@ def test_basic_get_set(tmpdir): def test_basic_get_set_wal(tmpdir): """Test basic functionality with WAL mode""" dbpath = tmpdir / "kvtest.db" - kvstore = osxphotos.sqlitekvstore.SQLiteKVStore(dbpath, wal=True) + kvstore = SQLiteKVStore(dbpath, wal=True) kvstore.set("foo", "bar") assert kvstore.get("foo") == "bar" assert kvstore.get("FOOBAR") is None @@ -84,14 +84,14 @@ def test_set_many(tmpdir): """Test set_many()""" dbpath = tmpdir / "kvtest.db" - kvstore = osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) + kvstore = SQLiteKVStore(dbpath) kvstore.set_many([("foo", "bar"), ("baz", "qux")]) assert kvstore.get("foo") == "bar" assert kvstore.get("baz") == "qux" kvstore.close() # make sure values got committed - kvstore = osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) + kvstore = SQLiteKVStore(dbpath) assert kvstore.get("foo") == "bar" assert kvstore.get("baz") == "qux" kvstore.close() @@ -101,14 +101,14 @@ def test_set_many_dict(tmpdir): """Test set_many() with dict of values""" dbpath = tmpdir / "kvtest.db" - kvstore = osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) + kvstore = SQLiteKVStore(dbpath) kvstore.set_many({"foo": "bar", "baz": "qux"}) assert kvstore.get("foo") == "bar" assert kvstore.get("baz") == "qux" kvstore.close() # make sure values got committed - kvstore = osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) + kvstore = SQLiteKVStore(dbpath) assert kvstore.get("foo") == "bar" assert kvstore.get("baz") == "qux" kvstore.close() @@ -118,7 +118,7 @@ def test_basic_context_handler(tmpdir): """Test basic functionality with context handler""" dbpath = tmpdir / "kvtest.db" - with osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) as kvstore: + with SQLiteKVStore(dbpath) as kvstore: kvstore.set("foo", "bar") assert kvstore.get("foo") == "bar" assert kvstore.get("FOOBAR") is None @@ -134,7 +134,7 @@ def test_basic_context_handler(tmpdir): def test_about(tmpdir): """Test about property""" dbpath = tmpdir / "kvtest.db" - with osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) as kvstore: + with SQLiteKVStore(dbpath) as kvstore: kvstore.about = "My description" assert kvstore.about == "My description" kvstore.about = "My new description" @@ -144,17 +144,17 @@ def test_about(tmpdir): def test_existing_db(tmpdir): """Test that opening an existing database works as expected""" dbpath = tmpdir / "kvtest.db" - with osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) as kvstore: + with SQLiteKVStore(dbpath) as kvstore: kvstore.set("foo", "bar") - with osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) as kvstore: + with SQLiteKVStore(dbpath) as kvstore: assert kvstore.get("foo") == "bar" def test_dict_interface(tmpdir): """ "Test dict interface""" dbpath = tmpdir / "kvtest.db" - with osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) as kvstore: + with SQLiteKVStore(dbpath) as kvstore: kvstore["foo"] = "bar" assert kvstore["foo"] == "bar" assert len(kvstore) == 1 @@ -186,9 +186,7 @@ def test_dict_interface(tmpdir): def test_serialize_deserialize(tmpdir): """Test serialize/deserialize""" dbpath = tmpdir / "kvtest.db" - kvstore = osxphotos.sqlitekvstore.SQLiteKVStore( - dbpath, serialize=json.dumps, deserialize=json.loads - ) + kvstore = SQLiteKVStore(dbpath, serialize=json.dumps, deserialize=json.loads) kvstore.set("foo", {"bar": "baz"}) assert kvstore.get("foo") == {"bar": "baz"} assert kvstore.get("FOOBAR") is None @@ -197,7 +195,7 @@ def test_serialize_deserialize(tmpdir): def test_serialize_deserialize_binary_data(tmpdir): """Test serialize/deserialize with binary data""" dbpath = tmpdir / "kvtest.db" - kvstore = osxphotos.sqlitekvstore.SQLiteKVStore( + kvstore = SQLiteKVStore( dbpath, serialize=pickle_and_zip, deserialize=unzip_and_unpickle ) kvstore.set("foo", {"bar": "baz"}) @@ -209,16 +207,16 @@ def test_serialize_deserialize_bad_callable(tmpdir): """Test serialize/deserialize with bad values""" dbpath = tmpdir / "kvtest.db" with pytest.raises(TypeError): - osxphotos.sqlitekvstore.SQLiteKVStore(dbpath, serialize=1, deserialize=None) + SQLiteKVStore(dbpath, serialize=1, deserialize=None) with pytest.raises(TypeError): - osxphotos.sqlitekvstore.SQLiteKVStore(dbpath, serialize=None, deserialize=1) + SQLiteKVStore(dbpath, serialize=None, deserialize=1) def test_iter(tmpdir): """Test generator behavior""" dbpath = tmpdir / "kvtest.db" - kvstore = osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) + kvstore = SQLiteKVStore(dbpath) kvstore.set("foo", "bar") kvstore.set("baz", "qux") kvstore.set("quux", "corge") @@ -230,7 +228,7 @@ def test_iter(tmpdir): def test_keys_values_items(tmpdir): """Test keys, values, items""" dbpath = tmpdir / "kvtest.db" - kvstore = osxphotos.sqlitekvstore.SQLiteKVStore(dbpath) + kvstore = SQLiteKVStore(dbpath) kvstore.set("foo", "bar") kvstore.set("baz", "qux") kvstore.set("quux", "corge") @@ -243,3 +241,26 @@ def test_keys_values_items(tmpdir): ("grault", "garply"), ("quux", "corge"), ] + + +def test_path(tmpdir): + """Test path property""" + dbpath = tmpdir / "kvtest.db" + kvstore = SQLiteKVStore(dbpath) + assert kvstore.path == dbpath + + +def test_wipe(tmpdir): + """Test wipe""" + dbpath = tmpdir / "kvtest.db" + kvstore = SQLiteKVStore(dbpath) + kvstore.set("foo", "bar") + kvstore.set("baz", "qux") + kvstore.set("quux", "corge") + kvstore.set("grault", "garply") + assert len(kvstore) == 4 + kvstore.wipe() + assert len(kvstore) == 0 + assert "foo" + kvstore.set("foo", "bar") + assert kvstore.get("foo") == "bar"