From d04914aac7cc371dd7b6d6e69befea95134713ed Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sun, 2 Jun 2024 17:41:55 -0500 Subject: [PATCH] Added --exportdir --- osxphotos/cli/import_cli.py | 55 +++++++++++++++++++++++++---- osxphotos/export_db_utils.py | 4 ++- tests/conftest.py | 12 ++++--- tests/test_cli_import.py | 63 +++++++++++++++++++++++++++++++--- tests/test_import_cli_utils.py | 7 ++++ 5 files changed, 125 insertions(+), 16 deletions(-) diff --git a/osxphotos/cli/import_cli.py b/osxphotos/cli/import_cli.py index ebe83cbd5..4e3d8167a 100644 --- a/osxphotos/cli/import_cli.py +++ b/osxphotos/cli/import_cli.py @@ -56,7 +56,10 @@ datetime_utc_to_local, ) from osxphotos.exiftool import get_exiftool_path -from osxphotos.export_db_utils import export_db_get_photoinfo_for_filepath +from osxphotos.export_db_utils import ( + export_db_get_photoinfo_for_filepath, + export_db_migrate_photos_library, +) from osxphotos.fingerprintquery import FingerprintQuery from osxphotos.image_file_utils import ( EDITED_RE, @@ -735,9 +738,29 @@ def get_help(self, ctx): "--exportdb", "-B", metavar="EXPORTDB_PATH", + type=click.Path(exists=True), help="Use an osxphotos export database (created by 'osxphotos export') " "to set metadata (title, description, keywords, location, album). " - "See also --sidecar, --sidecar-filename, --exiftool.", + "See also --exportdir, --sidecar, --sidecar-filename, --exiftool.", +) +@click.option( + "--exportdir", + "-I", + metavar="EXPORT_DIR_PATH", + type=click.Path(exists=True), + help="Specify the path to the export directory when using --exportdb " + "to import metadata from an osxphotos export database created by 'osxphotos export'. " + "This is only needed if you have moved the exported files to a new location since the export. " + "If you have moved the exported files, osxphotos will need to know the path to the top-level of " + "the export directory in order to use --exportdb to read the metadata for the files. " + "For example, if you used 'osxphotos export' to export photos to '/Volumes/Exported' " + "but you subsequently moved the files to '/Volumes/PhotosBackup' you would use: " + "'--exportdb /Volumes/PhotosBackup --exportdir /Volumes/PhotosBackup' to import the metadata " + "from the export database. This is needed because osxphotos needs to know both the path to the " + "export database and the root folder of the exported files in order to match the files to the " + "correct entry in the export database and the database may be in a different location than the " + "exported files. " + "See also --exportdb.", ) @click.option( "--relative-to", @@ -929,6 +952,7 @@ def import_main( exiftool: bool, exiftool_path: str | None, exportdb: str | None, + exportdir: str | None, favorite_rating: int | None, files_or_dirs: tuple[str, ...], glob: tuple[str, ...], @@ -988,7 +1012,6 @@ def import_main( If edited files do not contain an associated .AAE or if they do not match one of these two conventions, they will be imported as separate assets. """ - kwargs = locals() kwargs.pop("ctx") kwargs.pop("cli_obj") @@ -1012,6 +1035,7 @@ def import_cli( exiftool: bool = False, exiftool_path: str | None = None, exportdb: str | None = None, + exportdir: str | None = None, favorite_rating: int | None = None, files_or_dirs: tuple[str, ...] = (), glob: tuple[str, ...] = (), @@ -1176,6 +1200,7 @@ def import_cli( exiftool=exiftool, exiftool_path=exiftool_path, exportdb=exportdb, + exportdir=exportdir, favorite_rating=favorite_rating, sidecar=sidecar, sidecar_ignore_date=sidecar_ignore_date, @@ -1364,6 +1389,7 @@ def add_photo_to_albums_from_exportdb( photo: Photo | None, filepath: pathlib.Path, exportdb_path: str, + exportdir_path: str | None, exiftool_path: str, verbose: Callable[..., None], dry_run: bool, @@ -1371,7 +1397,10 @@ def add_photo_to_albums_from_exportdb( """Add photo to one or more albums from data found in export database""" with suppress(ValueError): if photoinfo := export_db_get_photoinfo_for_filepath( - exportdb_path=exportdb_path, filepath=filepath, exiftool=exiftool_path + exportdb_path=exportdb_path, + filepath=filepath, + exiftool=exiftool_path, + exportdir_path=exportdir_path, ): if photoinfo.album_info: verbose( @@ -1444,6 +1473,7 @@ def add_duplicate_to_albums_from_exportdb( duplicates: list[tuple[str, datetime.datetime, str]], filepath: pathlib.Path, exportdb_path: str, + exportdir_path: str | None, exiftool_path: str, verbose: Callable[..., None], dry_run: bool, @@ -1453,6 +1483,7 @@ def add_duplicate_to_albums_from_exportdb( duplicates: list of tuples of (uuid, date, filename) for duplicates as returned by FingerprintQuery.possible_duplicates filepath: path to file to import exportdb_path: path to the export db + exportdir_path: path to the export directory if it cannot be determined from exportdb_path (e.g. the export was moved) exiftool_path: path to exiftool verbose: verbose function dry_run: dry run @@ -1479,6 +1510,7 @@ def add_duplicate_to_albums_from_exportdb( dup_photo, filepath, exportdb_path, + exportdir_path, exiftool_path, verbose, dry_run, @@ -1551,6 +1583,7 @@ def set_photo_metadata_from_exportdb( photo: Photo | None, filepath: pathlib.Path, exportdb_path: pathlib.Path, + exportdir_path: pathlib.Path | None, exiftool_path: str, merge_keywords: bool, verbose: Callable[..., None], @@ -1560,7 +1593,10 @@ def set_photo_metadata_from_exportdb( photoinfo = None with suppress(ValueError): photoinfo = export_db_get_photoinfo_for_filepath( - exportdb_path=exportdb_path, filepath=filepath, exiftool=exiftool_path + exportdb_path=exportdb_path, + filepath=filepath, + exiftool=exiftool_path, + exportdir_path=exportdir_path, ) if photoinfo: metadata = metadata_from_photoinfo(photoinfo) @@ -1893,6 +1929,7 @@ def apply_photo_metadata( exiftool: bool, exiftool_path: str, exportdb: pathlib.Path | None, + exportdir: pathlib.Path | None, favorite_rating: bool, filepath: pathlib.Path, keyword: str | None, @@ -1920,6 +1957,7 @@ def apply_photo_metadata( photo, filepath, exportdb, + exportdir, exiftool_path, merge_keywords, verbose, @@ -2020,6 +2058,7 @@ def apply_photo_albums( exiftool: bool, exiftool_path: str | None, exportdb: str | None, + exportdir: str | None, filepath: pathlib.Path, photo: Photo, relative_filepath: pathlib.Path, @@ -2045,7 +2084,7 @@ def apply_photo_albums( if exportdb: # add photo to any albums defined in the exportdb data report_record.albums += add_photo_to_albums_from_exportdb( - photo, filepath, exportdb, exiftool_path, verbose, dry_run + photo, filepath, exportdb, exportdir, exiftool_path, verbose, dry_run ) @@ -2954,6 +2993,7 @@ def import_files( exiftool: bool, exiftool_path: str, exportdb: str | None, + exportdir: str | None, favorite_rating: int | None, sidecar: bool, sidecar_ignore_date: bool, @@ -3117,6 +3157,7 @@ def import_files( duplicates, filepath, exportdb, + exportdir, exiftool_path, verbose, dry_run, @@ -3192,6 +3233,7 @@ def import_files( exiftool=exiftool, exiftool_path=exiftool_path, exportdb=exportdb, + exportdir=exportdir, favorite_rating=favorite_rating, filepath=filepath, keyword=keyword, @@ -3213,6 +3255,7 @@ def import_files( exiftool=exiftool, exiftool_path=exiftool_path, exportdb=exportdb, + exportdir=exportdir, filepath=filepath, photo=photo, relative_filepath=relative_filepath, diff --git a/osxphotos/export_db_utils.py b/osxphotos/export_db_utils.py index e45c9746d..0fde32ff9 100644 --- a/osxphotos/export_db_utils.py +++ b/osxphotos/export_db_utils.py @@ -605,17 +605,19 @@ def export_db_get_photoinfo_for_filepath( exportdb_path: str | os.PathLike, filepath: str | os.PathLike, exiftool: str | os.PathLike | None = None, + exportdir_path: str | os.PathLike | None = None, ) -> PhotoInfoFromDict | None: """Return photoinfo object for a given filepath Args: exportdb: path to the export database + exportdir: path to the export directory or None filepath: absolute path to the file to retrieve info for from the database exiftool: optional path to exiftool to be passed to the PhotoInfoFromDict object Returns: PhotoInfoFromDict | None """ - last_export_dir = export_db_get_last_export_dir(exportdb_path) + last_export_dir = exportdir_path or export_db_get_last_export_dir(exportdb_path) if not pathlib.Path(exportdb_path).is_file(): exportdb_path = find_export_db_for_filepath(exportdb_path) if not exportdb_path: diff --git a/tests/conftest.py b/tests/conftest.py index d81024a80..6102734af 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,8 @@ # don't clean up crash logs (configured with --no-cleanup) NO_CLEANUP = False +LIBRARY_COPY_DELAY = 5 + def get_os_version(): if not is_macos: @@ -96,35 +98,35 @@ def get_os_version(): def setup_photos_timewarp(): if not TEST_TIMEWARP: return - copy_photos_library(TEST_LIBRARY_TIMEWARP, delay=5) + copy_photos_library(TEST_LIBRARY_TIMEWARP, delay=LIBRARY_COPY_DELAY) @pytest.fixture(scope="session", autouse=is_macos) def setup_photos_import(): if not TEST_IMPORT: return - copy_photos_library(TEST_LIBRARY_IMPORT, delay=10) + copy_photos_library(TEST_LIBRARY_IMPORT, delay=LIBRARY_COPY_DELAY) @pytest.fixture(scope="session", autouse=is_macos) def setup_photos_import_takeout(): if not TEST_IMPORT_TAKEOUT: return - copy_photos_library(TEST_LIBRARY_TAKEOUT, delay=10) + copy_photos_library(TEST_LIBRARY_TAKEOUT, delay=LIBRARY_COPY_DELAY) @pytest.fixture(scope="session", autouse=is_macos) def setup_photos_sync(): if not TEST_SYNC: return - copy_photos_library(TEST_LIBRARY_SYNC, delay=10) + copy_photos_library(TEST_LIBRARY_SYNC, delay=LIBRARY_COPY_DELAY) @pytest.fixture(scope="session", autouse=is_macos) def setup_photos_add_locations(): if not TEST_ADD_LOCATIONS: return - copy_photos_library(TEST_LIBRARY_ADD_LOCATIONS, delay=10) + copy_photos_library(TEST_LIBRARY_ADD_LOCATIONS, delay=LIBRARY_COPY_DELAY) @pytest.fixture(autouse=True) diff --git a/tests/test_cli_import.py b/tests/test_cli_import.py index 8e197aafd..b1f831070 100644 --- a/tests/test_cli_import.py +++ b/tests/test_cli_import.py @@ -1,8 +1,11 @@ -""" Tests which require user interaction to run for osxphotos import command; run with pytest --test-import """ +""" Tests which require user interaction to run for osxphotos import command. +run with pytest tests/test_cli_import.py --test-import +""" import csv import datetime import filecmp +import hashlib import json import os import os.path @@ -13,17 +16,17 @@ import sys import time import unicodedata +from string import Template from tempfile import TemporaryDirectory from typing import Dict from zoneinfo import ZoneInfo -import hashlib import pytest from click.testing import CliRunner from pytest import MonkeyPatch, approx from osxphotos import PhotosDB, QueryOptions -from osxphotos._constants import UUID_PATTERN +from osxphotos._constants import OSXPHOTOS_EXPORT_DB, UUID_PATTERN from osxphotos.datetime_utils import datetime_remove_tz, get_local_tz from osxphotos.exiftool import get_exiftool_path from osxphotos.platform import is_macos @@ -63,7 +66,6 @@ TEST_LIVE_PHOTO_ORIGINAL_VIDEO = "IMG_1853.MOV" TEST_LIVE_PHOTO_EDITED_VIDEO = "IMG_E1853.mov" TEST_LIVE_PHOTO_AAE = "IMG_1853.AAE" -TEST_LIVE_PHOTO_GLOB = "*1853*" TEST_DATA = { TEST_IMAGE_1: { @@ -1588,6 +1590,7 @@ def test_import_exportdb(tmp_path): terminal_width=TERMINAL_WIDTH, ) assert result.exit_code == 0 + assert "Setting metadata and location from export database" in result.output results = parse_import_output(result.output) photosdb = PhotosDB() photo = photosdb.query(QueryOptions(uuid=[results["wedding.jpg"]]))[0] @@ -1598,6 +1601,58 @@ def test_import_exportdb(tmp_path): assert "AlbumInFolder" in photo.albums +@pytest.mark.test_import +def test_import_exportdb_exportdir(tmp_path): + """Test osxphotos import with --exportdb option and --exportdir""" + + # first, export an image + runner = CliRunner() + result = runner.invoke( + export, + [ + "--verbose", + str(tmp_path), + "--name", + "wedding.jpg", + "--library", + TEST_EXPORT_LIBRARY, + ], + ) + assert result.exit_code == 0 + + # move the export dir to a different directory + with TemporaryDirectory() as new_export_dir: + # need to get fully resolved path to the new temp dir as it may be /var but really -> /private/var + # which causes the exportdir lookup to fail + # this is a quirk of the test configuration + new_export_dir = str(pathlib.Path(new_export_dir).resolve().absolute()) + shutil.copy(str(tmp_path / "wedding.jpg"), new_export_dir) + + # now import that exported photo with --exportdb + result = runner.invoke( + import_main, + [ + new_export_dir, + "--verbose", + "--exportdb", + str(tmp_path), + "--exportdir", + new_export_dir, + ], + terminal_width=TERMINAL_WIDTH, + ) + assert result.exit_code == 0 + assert "Setting metadata and location from export database" in result.output + results = parse_import_output(result.output) + photosdb = PhotosDB() + photo = photosdb.query(QueryOptions(uuid=[results["wedding.jpg"]]))[0] + assert not photo.title + assert photo.description == "Bride Wedding day" + assert photo.keywords == ["wedding"] + assert "I have a deleted twin" in photo.albums + assert "AlbumInFolder" in photo.albums + + @pytest.mark.test_import def test_import_aae(tmp_path): """Test import with aae files; test that invalid AAE are ignored during import""" diff --git a/tests/test_import_cli_utils.py b/tests/test_import_cli_utils.py index 4ce7e8bbc..d2e684bc1 100644 --- a/tests/test_import_cli_utils.py +++ b/tests/test_import_cli_utils.py @@ -24,6 +24,8 @@ EDITED_FILE = "tests/test-images/wedding_edited.JPG" AAE_FILE = "tests/test-images/wedding.AAE" +# test data for rename_edited_group +# first tuple is input, second tuple is expected output RENAME_TEST_DATA = [ ( ("IMG_1234.JPG", "IMG_E1234.JPG", "IMG_1234.AAE"), @@ -71,6 +73,8 @@ ), ] +# test data for rename_edited_group with live photos +# first tuple is input, second tuple is expected output RENAME_TEST_DATA_LIVE_EDITED = [ ( ( @@ -146,6 +150,7 @@ "IMG_8208.JPG", ] +# test data for group_files_to_import GROUP_FILES_EXPECTED = [ ( LIVE_PHOTO_ORIGINAL_PHOTO, @@ -174,6 +179,8 @@ tuple(BURST_IMAGES), ] +# test data for sort_paths +# first list is input, second tuple is expected output SORT_PATHS_DATA = [ ( [