From b03670dc700eb0efa995a9f8461fd46a085f683f Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sun, 12 Feb 2023 08:11:38 -0800 Subject: [PATCH] Implemented show command, #964 (#982) --- osxphotos/_constants.py | 4 + osxphotos/cli/__init__.py | 2 + osxphotos/cli/cli.py | 2 + osxphotos/cli/cli_commands.py | 5 + osxphotos/cli/orphans.py | 5 +- osxphotos/cli/show_command.py | 74 ++++++++++++ osxphotos/photoscript_utils.py | 172 +++++++++++++++++++++++++++ osxphotos/photosdb/photosdb_utils.py | 43 ++++++- osxphotos/queryoptions.py | 24 ++-- tests/test_cli_import.py | 5 +- tests/test_photosdb_utils.py | 41 +++++++ 11 files changed, 353 insertions(+), 24 deletions(-) create mode 100644 osxphotos/cli/show_command.py create mode 100644 osxphotos/photoscript_utils.py create mode 100644 tests/test_photosdb_utils.py diff --git a/osxphotos/_constants.py b/osxphotos/_constants.py index 668ba7fb..562ca481 100644 --- a/osxphotos/_constants.py +++ b/osxphotos/_constants.py @@ -459,3 +459,7 @@ PROFILE_SORT_KEYS = [ "time", "tottime", ] + +UUID_PATTERN = ( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" +) diff --git a/osxphotos/cli/__init__.py b/osxphotos/cli/__init__.py index ec26e036..37e1ccb8 100644 --- a/osxphotos/cli/__init__.py +++ b/osxphotos/cli/__init__.py @@ -79,6 +79,7 @@ from .photo_inspect import photo_inspect from .places import places from .query import query from .repl import repl +from .show_command import show from .snap_diff import diff, snap from .sync import sync from .theme import theme @@ -125,6 +126,7 @@ __all__ = [ "run", "selection_command", "set_debug", + "show", "snap", "tutorial", "uuid", diff --git a/osxphotos/cli/cli.py b/osxphotos/cli/cli.py index bb24d29a..6e819919 100644 --- a/osxphotos/cli/cli.py +++ b/osxphotos/cli/cli.py @@ -35,6 +35,7 @@ from .photo_inspect import photo_inspect from .places import places from .query import query from .repl import repl +from .show_command import show from .snap_diff import diff, snap from .sync import sync from .theme import theme @@ -130,6 +131,7 @@ for command in [ query, repl, run, + show, snap, sync, theme, diff --git a/osxphotos/cli/cli_commands.py b/osxphotos/cli/cli_commands.py index 26a73bb3..df2ae31e 100644 --- a/osxphotos/cli/cli_commands.py +++ b/osxphotos/cli/cli_commands.py @@ -27,10 +27,15 @@ from .cli_params import ( ) from .click_rich_echo import rich_click_echo as echo from .click_rich_echo import rich_echo_error as echo_error +from .click_rich_echo import set_rich_theme +from .color_themes import get_theme from .verbose import verbose, verbose_print logger = logging.getLogger("osxphotos") +# ensure echo, echo_error are configured with correct theme +set_rich_theme(get_theme()) + __all__ = [ "abort", "echo", diff --git a/osxphotos/cli/orphans.py b/osxphotos/cli/orphans.py index 5188ca3b..311d213d 100644 --- a/osxphotos/cli/orphans.py +++ b/osxphotos/cli/orphans.py @@ -14,7 +14,7 @@ from typing import Dict import click from osxphotos import PhotosDB -from osxphotos._constants import _PHOTOS_4_VERSION +from osxphotos._constants import _PHOTOS_4_VERSION, UUID_PATTERN from osxphotos.fileutil import FileUtil from osxphotos.utils import increment_filename, pluralize @@ -131,8 +131,7 @@ def scan_for_files(directory: str, uuid_dict: Dict): Note: modifies uuid_dict """ - uuid_pattern = r"([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})" - uuid_regex = re.compile(uuid_pattern) + uuid_regex = re.compile(UUID_PATTERN) for dirpath, dirname, filenames in os.walk(directory): for filename in filenames: if match := uuid_regex.match(filename): diff --git a/osxphotos/cli/show_command.py b/osxphotos/cli/show_command.py new file mode 100644 index 00000000..c5ed5bc5 --- /dev/null +++ b/osxphotos/cli/show_command.py @@ -0,0 +1,74 @@ +"""osxphotos show command""" + +import re + +import click + +from osxphotos._constants import UUID_PATTERN +from osxphotos.photoscript_utils import ( + photoscript_object_from_name, + photoscript_object_from_uuid, +) +from osxphotos.photosdb.photosdb_utils import get_photos_library_version +from osxphotos.utils import get_last_library_path + +from .cli_commands import echo, echo_error +from .cli_params import DB_OPTION +from .click_rich_echo import set_rich_theme + + +@click.command(name="show") +@DB_OPTION +@click.argument("uuid_or_name", metavar="UUID_OR_NAME", nargs=1, required=True) +@click.pass_context +def show(ctx, db, uuid_or_name): + """Show photo, album, or folder in Photos from UUID_OR_NAME + + Examples: + + osxphotos show 12345678-1234-1234-1234-123456789012 + + osxphotos show "My Album" + + osxphotos show "My Folder" + + osxphotos show IMG_1234.JPG + + Notes: + + This command requires Photos library version 5 or higher. + Currently this command cannot be used to show subfolders in Photos. + """ + db = db or get_last_library_path() + if not db: + echo( + "Could not find Photos library. Use --library/--db to specify path to Photos library." + ) + ctx.exit(1) + + if get_photos_library_version(db) < 5: + echo_error("[error]show command requires Photos library version 5 or higher") + ctx.exit(1) + + try: + if re.match(UUID_PATTERN, uuid_or_name): + if not (obj := photoscript_object_from_uuid(uuid_or_name, db)): + raise ValueError( + f"could not find asset with UUID [uuid]{uuid_or_name}[/]" + ) + obj_type = obj.__class__.__name__ + echo(f"Found [filename]{obj_type}[/] with UUID: [uuid]{uuid_or_name}[/]") + obj.spotlight() + elif obj := photoscript_object_from_name(uuid_or_name, db): + obj_type = obj.__class__.__name__ + echo( + f"Found [filename]{obj_type}[/] with name: [filepath]{uuid_or_name}[/]" + ) + obj.spotlight() + else: + raise ValueError( + f"could not find asset with name [filepath]{uuid_or_name}[/]" + ) + except Exception as e: + echo_error(f"[error]Error finding asset [uuid]{uuid_or_name}[/]: {e}") + ctx.exit(1) diff --git a/osxphotos/photoscript_utils.py b/osxphotos/photoscript_utils.py new file mode 100644 index 00000000..3cfe1e0d --- /dev/null +++ b/osxphotos/photoscript_utils.py @@ -0,0 +1,172 @@ +"""Utilities for creating photoscript objects from a name or UUID""" + +from __future__ import annotations + +import sqlite3 + +import photoscript + +from ._constants import _DB_TABLE_NAMES, _PHOTOS_5_ALBUM_KIND, _PHOTOS_5_FOLDER_KIND +from .photosdb.photosdb_utils import get_db_path_for_library, get_photos_library_version +from .sqlite_utils import sqlite_open_ro + + +def casefold(s: str | None) -> str | None: + return s.casefold() if s else None + + +def photoscript_object_from_uuid( + uuid: str, photos_database: str +) -> photoscript.Photo | None: + """Return a photoscript object from a uuid""" + photos_database = get_db_path_for_library(photos_database) + photos_version = get_photos_library_version(photos_database) + connection, cursor = sqlite_open_ro(photos_database) + uuid = uuid.upper() + if _uuid_is_asset(uuid, connection, photos_version): + return photoscript.Photo(uuid) + elif _uuid_is_album(uuid, connection, photos_version): + return photoscript.Album(uuid) + elif _uuid_is_folder(uuid, connection, photos_version): + return photoscript.Folder(uuid) + else: + return None + + +def photoscript_object_from_name( + name: str, photos_database: str +) -> photoscript.Photo | None: + """Return a photoscript object from a name""" + photos_database = get_db_path_for_library(photos_database) + photos_version = get_photos_library_version(photos_database) + connection, cursor = sqlite_open_ro(photos_database) + connection.create_function("CASEFOLD", 1, casefold) + if uuid := _asset_uuid_for_name(name, connection, photos_version): + return photoscript.Photo(uuid) + elif uuid := _album_uuid_for_name(name, connection, photos_version): + return photoscript.Album(uuid) + elif uuid := _folder_uuid_for_name(name, connection, photos_version): + return photoscript.Folder(uuid) + else: + return None + + +def _uuid_is_asset( + uuid: str, connection: sqlite3.Connection, version: int +) -> str | None: + """Return uuid if uuid is an asset uuid otherwise None""" + asset_table = _DB_TABLE_NAMES[version]["ASSET"] + cursor = connection.cursor() + if results := cursor.execute( + f""" + SELECT ZUUID + FROM {asset_table} + WHERE ZUUID=? + """, + (uuid,), + ).fetchone(): + return results[0] + else: + return None + + +def _uuid_is_album( + uuid: str, connection: sqlite3.Connection, version: int +) -> str | None: + """Return uuid if uuid is an album uuid otherwise None""" + cursor = connection.cursor() + if results := cursor.execute( + """ + SELECT ZUUID + FROM ZGENERICALBUM + WHERE ZUUID=? + AND ZKIND=? + """, + (uuid, _PHOTOS_5_ALBUM_KIND), + ).fetchone(): + return results[0] + else: + return None + + +def _uuid_is_folder( + uuid: str, connection: sqlite3.Connection, version: int +) -> str | None: + """Return uuid if uuid is an folder uuid otherwise None""" + cursor = connection.cursor() + if results := cursor.execute( + """ + SELECT ZUUID + FROM ZGENERICALBUM + WHERE ZUUID=? + AND ZKIND=? + """, + (uuid, _PHOTOS_5_FOLDER_KIND), + ).fetchone(): + return results[0] + else: + return None + + +def _asset_uuid_for_name( + name: str, connection: sqlite3.Connection, version: int +) -> str | None: + """Return uuid for asset with name or None if not found""" + asset_table = _DB_TABLE_NAMES[version]["ASSET"] + cursor = connection.cursor() + if results := cursor.execute( + f""" + SELECT {asset_table}.ZUUID + FROM {asset_table} + JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK + WHERE CASEFOLD(ZADDITIONALASSETATTRIBUTES.ZORIGINALFILENAME)=? + ORDER BY {asset_table}.ZDATECREATED DESC + """, + (casefold(name),), + ).fetchone(): + return results[0] + else: + return None + + +def _album_uuid_for_name( + name: str, connection: sqlite3.Connection, version: int +) -> str | None: + """Return uuid for album with name or None if not found""" + return _folder_album_uuid_for_name(name, connection, version, album=True) + + +def _folder_uuid_for_name( + name: str, connection: sqlite3.Connection, version: int +) -> str | None: + """Return uuid for album with name or None if not found""" + return _folder_album_uuid_for_name(name, connection, version, folder=True) + + +def _folder_album_uuid_for_name( + name: str, + connection: sqlite3.Connection, + version: int, + album: bool = False, + folder: bool = False, +) -> str | None: + """Return uuid for album with name or None if not found""" + if album and folder: + raise ValueError("album and folder cannot both be True") + if not album and not folder: + raise ValueError("album and folder cannot both be False") + kind = _PHOTOS_5_ALBUM_KIND if album else _PHOTOS_5_FOLDER_KIND + cursor = connection.cursor() + if results := cursor.execute( + """ + SELECT ZUUID + FROM ZGENERICALBUM + WHERE CASEFOLD(ZTITLE)=? + AND ZKIND=? + ORDER BY ZCREATIONDATE DESC + """, + (casefold(name), kind), + ).fetchone(): + return results[0] + else: + return None diff --git a/osxphotos/photosdb/photosdb_utils.py b/osxphotos/photosdb/photosdb_utils.py index 824d99de..6e228570 100644 --- a/osxphotos/photosdb/photosdb_utils.py +++ b/osxphotos/photosdb/photosdb_utils.py @@ -1,5 +1,7 @@ """ utility functions used by PhotosDB """ +from __future__ import annotations + import logging import pathlib import plistlib @@ -63,7 +65,8 @@ def get_db_version(db_file): if version not in _TESTED_DB_VERSIONS: print( f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]" - + f" You have database version={version} which has not been tested", file=sys.stderr + + f" You have database version={version} which has not been tested", + file=sys.stderr, ) return version @@ -115,11 +118,18 @@ def get_db_model_version(db_file: str) -> int: return 8 -def get_photos_library_version(library_path): - """Return int indicating which Photos version a library was created with""" +def get_photos_library_version(library_path: str | pathlib.Path) -> int: + """Return int indicating which Photos version a library was created with + + Args: + library_path: path to Photos library; may be path to the root of the library or the photos.db file + + Returns: int of major Photos version number (e.g. 5, 6, ...) + """ library_path = pathlib.Path(library_path) - db_ver = get_db_version(str(library_path / "database" / "photos.db")) - db_ver = int(db_ver) + if library_path.is_dir(): + library_path = library_path / "database" / "photos.db" + db_ver = int(get_db_version(str(library_path))) if db_ver == int(_PHOTOS_2_VERSION): return 2 if db_ver == int(_PHOTOS_3_VERSION): @@ -128,7 +138,8 @@ def get_photos_library_version(library_path): return 4 # assume it's a Photos 5+ library, get the model version to determine which version - model_ver = get_model_version(str(library_path / "database" / "Photos.sqlite")) + library_path = library_path.parent / "Photos.sqlite" + model_ver = get_model_version(str(library_path)) model_ver = int(model_ver) if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]: return 5 @@ -142,3 +153,23 @@ def get_photos_library_version(library_path): f"Unknown db / model version: db_ver={db_ver}, model_ver={model_ver}; assuming Photos 8" ) return 8 + + +def get_db_path_for_library(photos_library: str | pathlib.Path) -> pathlib.Path: + """Returns path to Photos database file for Photos library + + Args: + photos_library: path to Photos library; may be path to the root of the library or the photos.db file + + Returns: pathlib.Path to Photos database file + """ + photos_library = pathlib.Path(photos_library) + if photos_library.is_file(): + return photos_library + photos_version = get_photos_library_version(photos_library) + if photos_version < 5: + if photos_library.is_dir(): + photos_library = photos_library / "database" / "photos.db" + elif photos_library.is_dir(): + photos_library = photos_library / "database" / "Photos.sqlite" + return photos_library diff --git a/osxphotos/queryoptions.py b/osxphotos/queryoptions.py index 3670cc59..6860a6be 100644 --- a/osxphotos/queryoptions.py +++ b/osxphotos/queryoptions.py @@ -11,6 +11,8 @@ from typing import Iterable, List, Optional, Tuple import bitmath +from ._constants import UUID_PATTERN + __all__ = ["QueryOptions", "query_options_from_kwargs", "IncompatibleQueryOptions"] @@ -196,12 +198,12 @@ class QueryOptions: def query_options_from_kwargs(**kwargs) -> QueryOptions: - """ Validate query options and create a QueryOptions instance. - Note: this will block on stdin if uuid_from_file is set to "-" - so it is best to call function before creating the PhotosDB instance - so that the validation of query options can happen before the database - is loaded. - """ + """Validate query options and create a QueryOptions instance. + Note: this will block on stdin if uuid_from_file is set to "-" + so it is best to call function before creating the PhotosDB instance + so that the validation of query options can happen before the database + is loaded. + """ # sanity check input args nonexclusive = [ "added_after", @@ -301,9 +303,9 @@ def query_options_from_kwargs(**kwargs) -> QueryOptions: return QueryOptions(**query_dict) -def load_uuid_from_file(filename: str) ->list[str]: +def load_uuid_from_file(filename: str) -> list[str]: """ - Load UUIDs from file. + Load UUIDs from file. Does not validate UUIDs but does validate that the UUIDs are in the correct format. Format is 1 UUID per line, any line beginning with # is ignored. Whitespace is stripped. @@ -328,6 +330,7 @@ def load_uuid_from_file(filename: str) ->list[str]: with open(filename, "r") as f: return _load_uuid_from_stream(f) + def _load_uuid_from_stream(stream: io.IOBase) -> list[str]: """ Load UUIDs from stream. @@ -349,10 +352,7 @@ def _load_uuid_from_stream(stream: io.IOBase) -> list[str]: for line in stream: line = line.strip() if len(line) and line[0] != "#": - if not re.match( - r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", - line, - ): + if not re.match(f"^{UUID_PATTERN}$", line): raise ValueError(f"Invalid UUID: {line}") line = line.upper() uuid.append(line) diff --git a/tests/test_cli_import.py b/tests/test_cli_import.py index f349b75e..fe49afc1 100644 --- a/tests/test_cli_import.py +++ b/tests/test_cli_import.py @@ -19,6 +19,7 @@ from photoscript import Photo from pytest import MonkeyPatch, approx from osxphotos import PhotosDB, QueryOptions +from osxphotos._constants import UUID_PATTERN from osxphotos.cli.import_cli import import_cli from osxphotos.datetime_utils import datetime_remove_tz from osxphotos.exiftool import get_exiftool_path @@ -101,9 +102,7 @@ def parse_import_output(output: str) -> Dict[str, str]: results = {} for line in output.split("\n"): - pattern = re.compile( - r"Imported ([\w\.]+)\s.*UUID\s([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})" - ) + pattern = re.compile(r"Imported ([\w\.]+)\s.*UUID\s(" + UUID_PATTERN + r")") if match := re.match(pattern, line): file = match[1] uuid = match[2] diff --git a/tests/test_photosdb_utils.py b/tests/test_photosdb_utils.py new file mode 100644 index 00000000..aa0b4b7a --- /dev/null +++ b/tests/test_photosdb_utils.py @@ -0,0 +1,41 @@ +"""Test photosdb_utils """ + +import pathlib + +import pytest + +from osxphotos.photosdb.photosdb_utils import ( + get_db_path_for_library, + get_photos_library_version, +) + +LIBRARIES = { + 2: pathlib.Path("tests/Test-10.12.6.photoslibrary"), + 3: pathlib.Path("tests/Test-10.13.6.photoslibrary"), + 4: pathlib.Path("tests/Test-10.14.6.photoslibrary"), + 5: pathlib.Path("tests/Test-10.15.7.photoslibrary"), + 6: pathlib.Path("tests/Test-10.16.0.photoslibrary"), + 7: pathlib.Path("tests/Test-12.0.1.photoslibrary"), + 8: pathlib.Path("tests/Test-13.0.0.photoslibrary"), +} + + +@pytest.mark.parametrize("version,library_path", list(LIBRARIES.items())) +def test_get_photos_library_version_library_path(version, library_path): + """Test get_photos_library_version with library path""" + photos_version = get_photos_library_version(library_path) + assert photos_version == version + + +@pytest.mark.parametrize("version,library_path", list(LIBRARIES.items())) +def test_get_photos_library_version_db_path(version, library_path): + """Test get_photos_library_version with database path""" + photos_version = get_photos_library_version(library_path / "database" / "photos.db") + assert photos_version == version + + +@pytest.mark.parametrize("library_path", list(LIBRARIES.values())) +def test_get_db_path_for_library(library_path): + """Test get_db_path_for_library""" + db_path = get_db_path_for_library(library_path) + assert db_path.is_file()