346 lines
10 KiB
Python
346 lines
10 KiB
Python
"""repl command for osxphotos CLI"""
|
|
|
|
import dataclasses
|
|
import os
|
|
import os.path
|
|
import pathlib
|
|
import sys
|
|
import time
|
|
from typing import List
|
|
|
|
import click
|
|
import photoscript
|
|
from rich import pretty, print
|
|
|
|
import osxphotos
|
|
from osxphotos._constants import _PHOTOS_4_VERSION
|
|
from osxphotos.photoinfo import PhotoInfo
|
|
from osxphotos.photosdb import PhotosDB
|
|
from osxphotos.pyrepl import embed_repl
|
|
from osxphotos.queryoptions import QueryOptions
|
|
|
|
from .common import (
|
|
DB_ARGUMENT,
|
|
DB_OPTION,
|
|
DELETED_OPTIONS,
|
|
QUERY_OPTIONS,
|
|
get_photos_db,
|
|
load_uuid_from_file,
|
|
)
|
|
|
|
|
|
class IncompatibleQueryOptions(Exception):
|
|
pass
|
|
|
|
|
|
@click.command(name="repl")
|
|
@DB_OPTION
|
|
@click.pass_obj
|
|
@click.pass_context
|
|
@click.option(
|
|
"--emacs",
|
|
required=False,
|
|
is_flag=True,
|
|
default=False,
|
|
help="Launch REPL with Emacs keybindings (default is vi bindings)",
|
|
)
|
|
@click.option(
|
|
"--beta",
|
|
is_flag=True,
|
|
default=False,
|
|
hidden=True,
|
|
help="Enable beta options.",
|
|
)
|
|
@QUERY_OPTIONS
|
|
@DELETED_OPTIONS
|
|
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
|
|
@click.option(
|
|
"--not-missing",
|
|
is_flag=True,
|
|
help="Search for photos present on disk (e.g. not missing).",
|
|
)
|
|
@click.option(
|
|
"--cloudasset",
|
|
is_flag=True,
|
|
help="Search for photos that are part of an iCloud library",
|
|
)
|
|
@click.option(
|
|
"--not-cloudasset",
|
|
is_flag=True,
|
|
help="Search for photos that are not part of an iCloud library",
|
|
)
|
|
@click.option(
|
|
"--incloud",
|
|
is_flag=True,
|
|
help="Search for photos that are in iCloud (have been synched)",
|
|
)
|
|
@click.option(
|
|
"--not-incloud",
|
|
is_flag=True,
|
|
help="Search for photos that are not in iCloud (have not been synched)",
|
|
)
|
|
def repl(ctx, cli_obj, db, emacs, beta, **kwargs):
|
|
"""Run interactive osxphotos REPL shell (useful for debugging, prototyping, and inspecting your Photos library)"""
|
|
import logging
|
|
|
|
from objexplore import explore
|
|
from photoscript import Album, Photo, PhotosLibrary
|
|
from rich import inspect as _inspect
|
|
|
|
from osxphotos import ExifTool, PhotoInfo, PhotosDB
|
|
from osxphotos.albuminfo import AlbumInfo
|
|
from osxphotos.momentinfo import MomentInfo
|
|
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
|
|
from osxphotos.placeinfo import PlaceInfo
|
|
from osxphotos.queryoptions import QueryOptions
|
|
from osxphotos.scoreinfo import ScoreInfo
|
|
from osxphotos.searchinfo import SearchInfo
|
|
|
|
logger = logging.getLogger()
|
|
logger.disabled = True
|
|
|
|
pretty.install()
|
|
print(f"python version: {sys.version}")
|
|
print(f"osxphotos version: {osxphotos._version.__version__}")
|
|
db = db or get_photos_db()
|
|
photosdb = _load_photos_db(db)
|
|
# enable beta features if requested
|
|
if beta:
|
|
photosdb._beta = beta
|
|
print("Beta mode enabled")
|
|
print("Getting photos")
|
|
tic = time.perf_counter()
|
|
try:
|
|
query_options = _query_options_from_kwargs(**kwargs)
|
|
except IncompatibleQueryOptions:
|
|
click.echo("Incompatible query options", err=True)
|
|
click.echo(ctx.obj.group.commands["repl"].get_help(ctx), err=True)
|
|
sys.exit(1)
|
|
photos = _query_photos(photosdb, query_options)
|
|
all_photos = _get_all_photos(photosdb)
|
|
toc = time.perf_counter()
|
|
tictoc = toc - tic
|
|
|
|
# shortcut for helper functions
|
|
get_photo = photosdb.get_photo
|
|
show = _show_photo
|
|
spotlight = _spotlight_photo
|
|
get_selected = _get_selected(photosdb)
|
|
try:
|
|
selected = get_selected()
|
|
except Exception:
|
|
# get_selected sometimes fails
|
|
selected = []
|
|
|
|
def inspect(obj):
|
|
"""inspect object"""
|
|
return _inspect(obj, methods=True)
|
|
|
|
print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds\n")
|
|
print("The following classes have been imported from osxphotos:")
|
|
print(
|
|
"- AlbumInfo, ExifTool, PhotoInfo, PhotoExporter, ExportOptions, ExportResults, PhotosDB, PlaceInfo, QueryOptions, MomentInfo, ScoreInfo, SearchInfo\n"
|
|
)
|
|
print("The following variables are defined:")
|
|
print(f"- photosdb: PhotosDB() instance for '{photosdb.library_path}'")
|
|
print(
|
|
f"- photos: list of PhotoInfo objects for all photos filtered with any query options passed on command line (len={len(photos)})"
|
|
)
|
|
print(
|
|
f"- all_photos: list of PhotoInfo objects for all photos in photosdb, including those in the trash (len={len(all_photos)})"
|
|
)
|
|
print(
|
|
f"- selected: list of PhotoInfo objects for any photos selected in Photos (len={len(selected)})"
|
|
)
|
|
print(f"\nThe following functions may be helpful:")
|
|
print(
|
|
f"- get_photo(uuid): return a PhotoInfo object for photo with uuid; e.g. get_photo('B13F4485-94E0-41CD-AF71-913095D62E31')"
|
|
)
|
|
print(
|
|
f"- get_selected(); return list of PhotoInfo objects for photos selected in Photos"
|
|
)
|
|
print(
|
|
f"- show(photo): open a photo object in the default viewer; e.g. show(selected[0])"
|
|
)
|
|
print(
|
|
f"- show(path): open a file at path in the default viewer; e.g. show('/path/to/photo.jpg')"
|
|
)
|
|
print(f"- spotlight(photo): open a photo and spotlight it in Photos")
|
|
# print(
|
|
# f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)"
|
|
# )
|
|
print(
|
|
f"- inspect(object): print information about an object; e.g. inspect(PhotoInfo)"
|
|
)
|
|
print(
|
|
f"- explore(object): interactively explore an object with objexplore; e.g. explore(PhotoInfo)"
|
|
)
|
|
print(f"- q, quit, quit(), exit, exit(): exit this interactive shell\n")
|
|
|
|
embed_repl(
|
|
globals=globals(),
|
|
locals=locals(),
|
|
history_filename=str(pathlib.Path.home() / ".osxphotos_repl_history"),
|
|
quit_words=["q", "quit", "exit"],
|
|
vi_mode=not emacs,
|
|
)
|
|
|
|
|
|
def _show_photo(photo: PhotoInfo):
|
|
"""open image with default image viewer
|
|
|
|
Note: This is for debugging only -- it will actually open any filetype which could
|
|
be very, very bad.
|
|
|
|
Args:
|
|
photo: PhotoInfo object or a path to a photo on disk
|
|
"""
|
|
photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo
|
|
|
|
if not os.path.isfile(photopath):
|
|
return f"'{photopath}' does not appear to be a valid photo path"
|
|
|
|
os.system(f"open '{photopath}'")
|
|
|
|
|
|
def _load_photos_db(dbpath):
|
|
print("Loading database")
|
|
tic = time.perf_counter()
|
|
photosdb = osxphotos.PhotosDB(dbfile=dbpath, verbose=print)
|
|
toc = time.perf_counter()
|
|
tictoc = toc - tic
|
|
print(f"Done: took {tictoc:0.2f} seconds")
|
|
return photosdb
|
|
|
|
|
|
def _get_all_photos(photosdb):
|
|
"""get list of all photos in photosdb"""
|
|
photos = photosdb.photos(images=True, movies=True)
|
|
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
|
|
return photos
|
|
|
|
|
|
def _get_selected(photosdb):
|
|
"""get list of PhotoInfo objects for photos selected in Photos"""
|
|
|
|
def get_selected():
|
|
selected = photoscript.PhotosLibrary().selection
|
|
if not selected:
|
|
return []
|
|
return photosdb.photos(uuid=[p.uuid for p in selected])
|
|
|
|
return get_selected
|
|
|
|
|
|
def _spotlight_photo(photo: PhotoInfo):
|
|
photo_ = photoscript.Photo(photo.uuid)
|
|
photo_.spotlight()
|
|
|
|
|
|
def _query_options_from_kwargs(**kwargs) -> QueryOptions:
|
|
"""Validate query options and create a QueryOptions instance"""
|
|
# sanity check input args
|
|
nonexclusive = [
|
|
"added_after",
|
|
"added_before",
|
|
"added_in_last",
|
|
"album",
|
|
"duplicate",
|
|
"edited",
|
|
"exif",
|
|
"external_edit",
|
|
"folder",
|
|
"from_date",
|
|
"from_time",
|
|
"has_raw",
|
|
"is_reference",
|
|
"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 = [
|
|
("burst", "not_burst"),
|
|
("cloudasset", "not_cloudasset"),
|
|
("deleted", "deleted_only"),
|
|
("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"),
|
|
("keyword", "no_keyword"),
|
|
("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"),
|
|
]
|
|
# print help if no non-exclusive term or a double exclusive term is given
|
|
# TODO: add option to validate requiring at least one query arg
|
|
if any(all([kwargs[b], kwargs[n]]) for b, n in exclusive) or any(
|
|
[
|
|
all([any(kwargs["title"]), kwargs["no_title"]]),
|
|
all([any(kwargs["description"]), kwargs["no_description"]]),
|
|
all([any(kwargs["place"]), kwargs["no_place"]]),
|
|
all([any(kwargs["keyword"]), kwargs["no_keyword"]]),
|
|
]
|
|
):
|
|
raise IncompatibleQueryOptions
|
|
|
|
# actually have something to query
|
|
include_photos = True
|
|
include_movies = True # default searches for everything
|
|
if kwargs["only_movies"]:
|
|
include_photos = False
|
|
if kwargs["only_photos"]:
|
|
include_movies = False
|
|
|
|
# load UUIDs if necessary and append to any uuids passed with --uuid
|
|
uuid = None
|
|
if kwargs["uuid_from_file"]:
|
|
uuid_list = list(kwargs["uuid"]) # Click option is a tuple
|
|
uuid_list.extend(load_uuid_from_file(kwargs["uuid_from_file"]))
|
|
uuid = tuple(uuid_list)
|
|
|
|
query_fields = [field.name for field in dataclasses.fields(QueryOptions)]
|
|
query_dict = {field: kwargs.get(field) for field in query_fields}
|
|
query_dict["photos"] = include_photos
|
|
query_dict["movies"] = include_movies
|
|
query_dict["uuid"] = uuid
|
|
return QueryOptions(**query_dict)
|
|
|
|
|
|
def _query_photos(photosdb: PhotosDB, query_options: QueryOptions) -> List:
|
|
"""Query photos given a QueryOptions instance"""
|
|
try:
|
|
photos = photosdb.query(query_options)
|
|
except ValueError as e:
|
|
if "Invalid query_eval CRITERIA:" not in str(e):
|
|
raise ValueError(e) from e
|
|
msg = str(e).split(":")[1]
|
|
raise click.BadOptionUsage(
|
|
"query_eval", f"Invalid query-eval CRITERIA: {msg}"
|
|
) from e
|
|
|
|
return photos
|