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
This commit is contained in:
Rhet Turnbull 2023-02-05 14:48:42 -08:00 committed by GitHub
parent 0f1866e39d
commit 007f0e0960
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 2322 additions and 1334 deletions

View File

@ -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.
<!--[[[cog
cog.out("```python\n")
with open("examples/cli_example_1.py", "r") as f:
cog.out(f.read())
cog.out("```\n")
]]]-->
```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()
```
<!--[[[end]]]-->
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.
<!--[[[cog
cog.out("```python\n")
with open("examples/cli_example_2.py", "r") as f:
cog.out(f.read())
cog.out("```\n")
]]]-->
```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()
```
<!--[[[end]]]-->
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.
<!--[[[cog
cog.out("```python\n")
with open("examples/cli_example_3.py", "r") as f:
cog.out(f.read())
cog.out("```\n")
]]]-->
```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()
```
<!--[[[end]]]-->
## 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.
#### <A name="photosdbphotos">`photos(keywords=None, uuid=None, persons=None, albums=None, images=True, movies=True, from_date=None, to_date=None, intrash=False)`</a>
```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
>>>
```
#### <a name="getphoto">`get_photo(uuid)`</A>
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).
#### <A name="photosdbquery">`query(options: QueryOptions) -> List[PhotoInfo]:`</a>
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()
```
#### <A name="photos">`photos(keywords=None, uuid=None, persons=None, albums=None, images=True, movies=True, from_date=None, to_date=None, intrash=False)`</a>
```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
>>>
```
#### <a name="getphoto">`get_photo(uuid)`</A>
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.

67
examples/cli_example_1.py Normal file
View File

@ -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()

160
examples/cli_example_2.py Normal file
View File

@ -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()

60
examples/cli_example_3.py Normal file
View File

@ -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()

View File

@ -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)

View File

@ -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",
]

View File

@ -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

View File

@ -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))

View File

@ -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,

View File

@ -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)

677
osxphotos/cli/cli_params.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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:
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:
print("_dbphotos:")
pprint.pprint(photosdb._dbphotos)
else:
try:
val = getattr(photosdb, attr)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
query_kwargs = locals()
# 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["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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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__")

View File

@ -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

50
osxphotos/cli/kvstore.py Normal file
View File

@ -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

View File

@ -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

View File

@ -6,7 +6,7 @@ import click
import osxphotos
from .common import JSON_OPTION
from .cli_params import JSON_OPTION
@click.command(name="list")

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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):
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}"
)
else:
raise ValueError(e)
) from e
# below needed for to make CliRunner work for testing
cli_json = cli_obj.json if cli_obj is not None else None

View File

@ -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")

View File

@ -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

View File

@ -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_)

View File

@ -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 (

View File

@ -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):

View File

@ -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)

View File

@ -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;")

View File

@ -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",

View File

@ -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

View File

@ -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"