""" Test the command line interface (CLI) """ import csv import datetime import glob import json import locale import os import os.path import pathlib import re import shutil import subprocess import sqlite3 import tempfile import time from tempfile import TemporaryDirectory import pytest from click.testing import CliRunner from osxmetadata import OSXMetaData, Tag import osxphotos from osxphotos._constants import OSXPHOTOS_EXPORT_DB from osxphotos._version import __version__ from osxphotos.cli import ( about, albums, cli_main, export, exportdb, keywords, labels, persons, places, query, ) from osxphotos.exiftool import ExifTool, get_exiftool_path from osxphotos.fileutil import FileUtil from osxphotos.utils import noop, normalize_fs_path, normalize_unicode from .conftest import copy_photos_library_to_path CLI_PHOTOS_DB = "tests/Test-10.15.7.photoslibrary" LIVE_PHOTOS_DB = "tests/Test-Cloud-10.15.1.photoslibrary" RAW_PHOTOS_DB = "tests/Test-RAW-10.15.1.photoslibrary" COMMENTS_PHOTOS_DB = "tests/Test-Cloud-10.15.6.photoslibrary" PLACES_PHOTOS_DB = "tests/Test-Places-Catalina-10_15_1.photoslibrary" PLACES_PHOTOS_DB_13 = "tests/Test-Places-High-Sierra-10.13.6.photoslibrary" PHOTOS_DB_15_7 = "tests/Test-10.15.7.photoslibrary" PHOTOS_DB_TOUCH = PHOTOS_DB_15_7 PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary" PHOTOS_DB_MOVIES = "tests/Test-Movie-5_0.photoslibrary" # my personal library which some tests require LOCAL_PHOTOSDB = os.path.expanduser("~/Pictures/Photos Library.photoslibrary") UUID_SKIP_LIVE_PHOTOKIT = { "54A01B04-16D7-4FDE-8860-19F2A641E433": ["IMG_3203_edited.jpeg"], "1F3DF341-B822-4531-999E-724D642FD8E7": ["IMG_4179.jpeg"], } UUID_DOWNLOAD_MISSING = "C6C712C5-9316-408D-A3C3-125661422DA9" # IMG_8844.JPG UUID_FILE = "tests/uuid_from_file.txt" SKIP_UUID_FILE = "tests/skip_uuid_from_file.txt" CLI_OUTPUT_QUERY_UUID = '[{"uuid": "D79B8D77-BFFC-460B-9312-034F2877D35B", "filename": "D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg", "original_filename": "Pumkins2.jpg", "date": "2018-09-28T16:07:07-04:00", "description": "Girl holding pumpkin", "title": "I found one!", "keywords": ["Kids"], "albums": ["Pumpkin Farm", "Test Album", "Multi Keyword"], "persons": ["Katie"], "path": "/tests/Test-10.15.7.photoslibrary/originals/D/D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg", "ismissing": false, "hasadjustments": false, "external_edit": false, "favorite": false, "hidden": false, "latitude": 41.256566, "longitude": -95.940257, "path_edited": null, "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": false, "incloud": null}]' CLI_EXPORT_FILENAMES = [ "[2020-08-29] AAF035 (1).jpg", "[2020-08-29] AAF035 (2).jpg", "[2020-08-29] AAF035 (3).jpg", "[2020-08-29] AAF035.jpg", "DSC03584.dng", "Frítest (1).jpg", "Frítest (2).jpg", "Frítest (3).jpg", "Frítest_edited (1).jpeg", "Frítest_edited.jpeg", "Frítest.jpg", "IMG_1693.tif", "IMG_1994.cr2", "IMG_1994.JPG", "IMG_1997.cr2", "IMG_1997.JPG", "IMG_3092_edited.jpeg", "IMG_3092.heic", "IMG_4547.jpg", "Jellyfish.MOV", "Jellyfish1.mp4", "Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg", "screenshot-really-a-png.jpeg", "St James Park_edited.jpeg", "St James Park.jpg", "Tulips_edited.jpeg", "Tulips.jpg", "wedding_edited.jpeg", "wedding.jpg", "winebottle (1).jpeg", "winebottle.jpeg", ] CLI_EXPORT_FILENAMES_DRY_RUN = [ "[2020-08-29] AAF035.jpg", "DSC03584.dng", "Frítest_edited.jpeg", "Frítest.jpg", "IMG_1693.tif", "IMG_1994.cr2", "IMG_1994.JPG", "IMG_1997.cr2", "IMG_1997.JPG", "IMG_3092_edited.jpeg", "IMG_3092.heic", "IMG_4547.jpg", "Jellyfish.MOV", "Jellyfish1.mp4", "Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg", "screenshot-really-a-png.jpeg", "St James Park_edited.jpeg", "St James Park.jpg", "Tulips_edited.jpeg", "Tulips.jpg", "wedding_edited.jpeg", "wedding.jpg", "winebottle.jpeg", "winebottle.jpeg", ] CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES = ["Tulips.jpg", "wedding.jpg"] CLI_EXPORT_FILENAMES_ALBUM = ["Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg"] CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"] CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"] CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten" CLI_EXPORT_EDITED_SUFFIX_TEMPLATE = "{edited?_edited,}" CLI_EXPORT_ORIGINAL_SUFFIX = "_original" CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE = "{edited?_original,}" CLI_EXPORT_PREVIEW_SUFFIX = "_lowres" CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [ "[2020-08-29] AAF035 (1).jpg", "[2020-08-29] AAF035 (2).jpg", "[2020-08-29] AAF035 (3).jpg", "[2020-08-29] AAF035.jpg", "DSC03584.dng", "Frítest (1).jpg", "Frítest (2).jpg", "Frítest (3).jpg", "Frítest_bearbeiten (1).jpeg", "Frítest_bearbeiten.jpeg", "Frítest.jpg", "IMG_1693.tif", "IMG_1994.cr2", "IMG_1994.JPG", "IMG_1997.cr2", "IMG_1997.JPG", "IMG_3092_bearbeiten.jpeg", "IMG_3092.heic", "IMG_4547.jpg", "Jellyfish.MOV", "Jellyfish1.mp4", "Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg", "screenshot-really-a-png.jpeg", "St James Park_bearbeiten.jpeg", "St James Park.jpg", "Tulips_bearbeiten.jpeg", "Tulips.jpg", "wedding_bearbeiten.jpeg", "wedding.jpg", "winebottle (1).jpeg", "winebottle.jpeg", ] CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE = [ "[2020-08-29] AAF035 (1).jpg", "[2020-08-29] AAF035 (2).jpg", "[2020-08-29] AAF035 (3).jpg", "[2020-08-29] AAF035.jpg", "DSC03584.dng", "Frítest (1).jpg", "Frítest (2).jpg", "Frítest (3).jpg", "Frítest_edited (1).jpeg", "Frítest_edited.jpeg", "Frítest.jpg", "IMG_1693.tif", "IMG_1994.cr2", "IMG_1994.JPG", "IMG_1997.cr2", "IMG_1997.JPG", "IMG_3092_edited.jpeg", "IMG_3092.heic", "IMG_4547.jpg", "Jellyfish.MOV", "Jellyfish1.mp4", "Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg", "screenshot-really-a-png.jpeg", "St James Park_edited.jpeg", "St James Park.jpg", "Tulips_edited.jpeg", "Tulips.jpg", "wedding_edited.jpeg", "wedding.jpg", "winebottle (1).jpeg", "winebottle.jpeg", ] CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [ "[2020-08-29] AAF035_original (1).jpg", "[2020-08-29] AAF035_original (2).jpg", "[2020-08-29] AAF035_original (3).jpg", "[2020-08-29] AAF035_original.jpg", "DSC03584_original.dng", "Frítest_edited (1).jpeg", "Frítest_edited.jpeg", "Frítest_original (1).jpg", "Frítest_original (2).jpg", "Frítest_original (3).jpg", "Frítest_original.jpg", "IMG_1693_original.tif", "IMG_1994_original.cr2", "IMG_1994_original.JPG", "IMG_1997_original.cr2", "IMG_1997_original.JPG", "IMG_3092_edited.jpeg", "IMG_3092_original.heic", "IMG_4547_original.jpg", "Jellyfish_original.MOV", "Jellyfish1_original.mp4", "Pumkins1_original.jpg", "Pumkins2_original.jpg", "Pumpkins3_original.jpg", "screenshot-really-a-png_original.jpeg", "St James Park_edited.jpeg", "St James Park_original.jpg", "Tulips_edited.jpeg", "Tulips_original.jpg", "wedding_edited.jpeg", "wedding_original.jpg", "winebottle_original (1).jpeg", "winebottle_original.jpeg", ] CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE = [ "[2020-08-29] AAF035 (1).jpg", "[2020-08-29] AAF035 (2).jpg", "[2020-08-29] AAF035 (3).jpg", "[2020-08-29] AAF035.jpg", "DSC03584.dng", "Frítest (1).jpg", "Frítest_edited (1).jpeg", "Frítest_edited.jpeg", "Frítest_original (1).jpg", "Frítest_original.jpg", "Frítest.jpg", "IMG_1693.tif", "IMG_1994.cr2", "IMG_1994.JPG", "IMG_1997.cr2", "IMG_1997.JPG", "IMG_3092_edited.jpeg", "IMG_3092_original.heic", "IMG_4547.jpg", "Jellyfish.MOV", "Jellyfish1.mp4", "Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg", "screenshot-really-a-png.jpeg", "St James Park_edited.jpeg", "St James Park_original.jpg", "Tulips_edited.jpeg", "Tulips_original.jpg", "wedding_edited.jpeg", "wedding_original.jpg", "winebottle (1).jpeg", "winebottle.jpeg", ] CLI_EXPORT_FILENAMES_CURRENT = [ "1793FAAB-DE75-4E25-886C-2BD66C780D6A_edited.jpeg", # Frítest.jpg "1793FAAB-DE75-4E25-886C-2BD66C780D6A.jpeg", # Frítest.jpg "1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg", "2DFD33F1-A5D8-486F-A3A9-98C07995535A.jpeg", "35329C57-B963-48D6-BB75-6AFF9370CBBC.mov", "3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "4D521201-92AC-43E5-8F7C-59BC41C37A96.cr2", "4D521201-92AC-43E5-8F7C-59BC41C37A96.jpeg", "52083079-73D5-4921-AC1B-FE76F279133F.jpeg", "54E76FCB-D353-4557-9997-0A457BCB4D48.jpeg", "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4_edited.jpeg", "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4.jpeg", "7783E8E6-9CAC-40F3-BE22-81FB7051C266_edited.jpeg", "7783E8E6-9CAC-40F3-BE22-81FB7051C266.heic", "7F74DD34-5920-4DA3-B284-479887A34F66.jpeg", "7FD37B5F-6FAA-4DB1-8A29-BF9C37E38091.jpeg", "8846E3E6-8AC8-4857-8448-E3D025784410.tiff", "A8266C97-9BAF-4AF4-99F3-0013832869B8.jpeg", # Frítest.jpg "A92D9C26-3A50-4197-9388-CB5F7DB9FA91.cr2", "A92D9C26-3A50-4197-9388-CB5F7DB9FA91.jpeg", "B13F4485-94E0-41CD-AF71-913095D62E31.jpeg", # Frítest.jpg "D05A5FE3-15FB-49A1-A15D-AB3DA6F8B068.dng", "D1359D09-1373-4F3B-B0E3-1A4DE573E4A3.mp4", "D1D4040D-D141-44E8-93EA-E403D9F63E07_edited.jpeg", # Frítest.jpg "D1D4040D-D141-44E8-93EA-E403D9F63E07.jpeg", # Frítest.jpg "D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg", "DC99FBDD-7A52-4100-A5BB-344131646C30_edited.jpeg", "DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg", "E2078879-A29C-4D6F-BACB-E3BBE6C3EB91.jpeg", "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51_edited.jpeg", "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg", "F12384F6-CD17-4151-ACBA-AE0E3688539E.jpeg", "F207D5DE-EFAD-4217-8424-0764AAC971D0.jpeg", ] CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG = [ "[2020-08-29] AAF035 (1).jpg", "[2020-08-29] AAF035 (2).jpg", "[2020-08-29] AAF035 (3).jpg", "[2020-08-29] AAF035.jpg", "DSC03584.jpeg", "Frítest (1).jpg", "Frítest (2).jpg", "Frítest (3).jpg", "Frítest_edited (1).jpeg", "Frítest_edited.jpeg", "Frítest.jpg", "IMG_1693.jpeg", "IMG_1994.cr2", "IMG_1994.JPG", "IMG_1997.cr2", "IMG_1997.JPG", "IMG_3092_edited.jpeg", "IMG_3092.jpeg", "IMG_4547.jpg", "Jellyfish.MOV", "Jellyfish1.mp4", "Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg", "screenshot-really-a-png.jpeg", "St James Park_edited.jpeg", "St James Park.jpg", "Tulips_edited.jpeg", "Tulips.jpg", "wedding_edited.jpeg", "wedding.jpg", "winebottle (1).jpeg", "winebottle.jpeg", ] CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG_SKIP_RAW = [ "[2020-08-29] AAF035 (1).jpg", "[2020-08-29] AAF035 (2).jpg", "[2020-08-29] AAF035 (3).jpg", "[2020-08-29] AAF035.jpg", "DSC03584.jpeg", "Frítest (1).jpg", "Frítest (2).jpg", "Frítest (3).jpg", "Frítest_edited (1).jpeg", "Frítest_edited.jpeg", "Frítest.jpg", "IMG_1693.jpeg", "IMG_1994.JPG", "IMG_1997.JPG", "IMG_3092_edited.jpeg", "IMG_3092.jpeg", "IMG_4547.jpg", "Jellyfish.MOV", "Jellyfish1.mp4", "Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg", "screenshot-really-a-png.jpeg", "St James Park_edited.jpeg", "St James Park.jpg", "Tulips_edited.jpeg", "Tulips.jpg", "wedding_edited.jpeg", "wedding.jpg", "winebottle (1).jpeg", "winebottle.jpeg", ] CLI_EXPORT_CONVERT_TO_JPEG_LARGE_FILE = "DSC03584.jpeg" CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1 = [ "2019/April/wedding.jpg", "2019/July/Tulips.jpg", "2018/October/St James Park.jpg", "2018/September/Pumpkins3.jpg", "2018/September/Pumkins2.jpg", "2018/September/Pumkins1.jpg", ] CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_LOCALE = [ "2019/September/IMG_9975.JPEG", "2020/Februar/IMG_1064.JPEG", "2016/März/IMG_3984.JPEG", ] CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1 = [ "Multi Keyword/wedding.jpg", "_/Tulips.jpg", "_/St James Park.jpg", "Pumpkin Farm/Pumpkins3.jpg", "Pumpkin Farm/Pumkins2.jpg", "Pumpkin Farm/Pumkins1.jpg", "Test Album/Pumkins1.jpg", ] CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM2 = [ "Multi Keyword/wedding.jpg", "NOALBUM/Tulips.jpg", "NOALBUM/St James Park.jpg", "Pumpkin Farm/Pumpkins3.jpg", "Pumpkin Farm/Pumkins2.jpg", "Pumpkin Farm/Pumkins1.jpg", "Test Album/Pumkins1.jpg", ] CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES2 = [ "St James's Park, Great Britain, Westminster, England, United Kingdom/St James Park.jpg", "_/Pumpkins3.jpg", "Omaha, Nebraska, United States/Pumkins2.jpg", "_/Pumkins1.jpg", "_/Tulips.jpg", "_/wedding.jpg", ] CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES3 = [ "2019/{foo}/wedding.jpg", "2019/{foo}/Tulips.jpg", "2018/{foo}/St James Park.jpg", "2018/{foo}/Pumpkins3.jpg", "2018/{foo}/Pumkins2.jpg", "2018/{foo}/Pumkins1.jpg", ] CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1 = [ "2019-wedding.jpg", "2019-wedding_edited.jpeg", "2019-Tulips.jpg", "2018-St James Park.jpg", "2018-St James Park_edited.jpeg", "2018-Pumpkins3.jpg", "2018-Pumkins2.jpg", "2018-Pumkins1.jpg", ] CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2 = [ "Folder1_SubFolder2_AlbumInFolder-IMG_4547.jpg", "Folder1_SubFolder2_AlbumInFolder-wedding.jpg", "Folder1_SubFolder2_AlbumInFolder-wedding_edited.jpeg", "Folder2_Raw-DSC03584.dng", "Folder2_Raw-IMG_1994.cr2", "Folder2_Raw-IMG_1994.JPG", "Folder2_Raw-IMG_1997.cr2", "Folder2_Raw-IMG_1997.JPG", "None-St James Park.jpg", "None-St James Park_edited.jpeg", "None-Tulips.jpg", "None-Tulips_edited.jpeg", "Pumpkin Farm-Pumkins1.jpg", "Pumpkin Farm-Pumkins2.jpg", "Pumpkin Farm-Pumpkins3.jpg", "Test Album-Pumkins1.jpg", "Test Album-Pumkins2.jpg", "None-IMG_1693.tif", "I have a deleted twin-wedding.jpg", "I have a deleted twin-wedding_edited.jpeg", ] CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_PATHSEP = [ "2018-10 - Sponsion, Museum, Frühstück, Römermuseum/IMG_4547.jpg", "Folder1/SubFolder2/AlbumInFolder/IMG_4547.jpg", "2019-10:11 Paris Clermont/IMG_4547.jpg", ] CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_KEYWORD_PATHSEP = [ "foo:bar/foo:bar_IMG_3092.heic" ] CLI_EXPORTED_FILENAME_TEMPLATE_LONG_DESCRIPTION = [ "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. " "Aenean commodo ligula eget dolor. Aenean massa. " "Cum sociis natoque penatibus et magnis dis parturient montes, " "nascetur ridiculus mus. Donec quam felis, ultricies nec, " "pellentesque eu, pretium q.tif" ] CLI_EXPORT_UUID = "D79B8D77-BFFC-460B-9312-034F2877D35B" CLI_EXPORT_UUID_STATUE = "3DD2C897-F19E-4CA6-8C22-B027D5A71907" CLI_EXPORT_UUID_KEYWORD_PATHSEP = "7783E8E6-9CAC-40F3-BE22-81FB7051C266" CLI_EXPORT_UUID_LONG_DESCRIPTION = "8846E3E6-8AC8-4857-8448-E3D025784410" CLI_EXPORT_UUID_MISSING = "8E1D7BC9-9321-44F9-8CFB-4083F6B9232A" # IMG_2000.JPG CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg" CLI_EXPORT_UUID_FILENAME_PREVIEW = "Pumkins2_preview.jpeg" CLI_EXPORT_UUID_FILENAME_PREVIEW_TEMPLATE = "Pumkins2_lowres.jpeg" CLI_EXPORT_BY_DATE_TOUCH_UUID = [ "1EB2B765-0765-43BA-A90C-0D0580E6172C", # Pumpkins3.jpg "F12384F6-CD17-4151-ACBA-AE0E3688539E", # Pumkins1.jpg ] CLI_EXPORT_BY_DATE_TOUCH_TIMES = [1538165373, 1538163349] CLI_EXPORT_BY_DATE_NEED_TOUCH = [ "2018/09/28/Pumkins2.jpg", "2018/10/13/St James Park.jpg", ] CLI_EXPORT_BY_DATE_NEED_TOUCH_UUID = [ "D79B8D77-BFFC-460B-9312-034F2877D35B", "DC99FBDD-7A52-4100-A5BB-344131646C30", ] CLI_EXPORT_BY_DATE_NEED_TOUCH_TIMES = [1538165227, 1539436692] CLI_EXPORT_BY_DATE = ["2018/09/28/Pumpkins3.jpg", "2018/09/28/Pumkins1.jpg"] CLI_EXPORT_SIDECAR_FILENAMES = ["Pumkins2.jpg", "Pumkins2.jpg.json", "Pumkins2.jpg.xmp"] CLI_EXPORT_SIDECAR_DROP_EXT_FILENAMES = [ "Pumkins2.jpg", "Pumkins2.json", "Pumkins2.xmp", ] CLI_EXPORT_LIVE = [ "51F2BEF7-431A-4D31-8AC1-3284A57826AE.jpeg", "51F2BEF7-431A-4D31-8AC1-3284A57826AE.mov", ] CLI_EXPORT_LIVE_ORIGINAL = ["IMG_0728.JPG", "IMG_0728.mov"] CLI_EXPORT_RAW = ["441DFE2A-A69B-4C79-A69B-3F51D1B9B29C.cr2"] CLI_EXPORT_RAW_ORIGINAL = ["IMG_0476_2.CR2"] CLI_EXPORT_RAW_EDITED = [ "441DFE2A-A69B-4C79-A69B-3F51D1B9B29C.cr2", "441DFE2A-A69B-4C79-A69B-3F51D1B9B29C_edited.jpeg", ] CLI_EXPORT_RAW_EDITED_ORIGINAL = ["IMG_0476_2.CR2", "IMG_0476_2_edited.jpeg"] CLI_UUID_DICT_15_7 = { "intrash": "71E3E212-00EB-430D-8A63-5E294B268554", "template": "F12384F6-CD17-4151-ACBA-AE0E3688539E", } CLI_TEMPLATE_SIDECAR_FILENAME = "Pumkins1.jpg.json" CLI_TEMPLATE_FILENAME = "Pumkins1.jpg" CLI_UUID_DICT_14_6 = {"intrash": "3tljdX43R8+k6peNHVrJNQ"} PHOTOS_NOT_IN_TRASH_LEN_14_6 = 12 PHOTOS_IN_TRASH_LEN_14_6 = 1 PHOTOS_MISSING_14_6 = 1 PHOTOS_NOT_IN_TRASH_LEN_15_7 = 27 PHOTOS_IN_TRASH_LEN_15_7 = 2 PHOTOS_MISSING_15_7 = 2 PHOTOS_EDITED_15_7 = 6 PHOTOS_NOT_MISSING_15_7 = PHOTOS_NOT_IN_TRASH_LEN_15_7 - PHOTOS_MISSING_15_7 CLI_PLACES_JSON = """{"places": {"_UNKNOWN_": 1, "Maui, Wailea, Hawai'i, United States": 1, "Washington, District of Columbia, United States": 1}}""" CLI_EXIFTOOL = { "D79B8D77-BFFC-460B-9312-034F2877D35B": { "File:FileName": "Pumkins2.jpg", "IPTC:Keywords": "Kids", "XMP:TagsList": "Kids", "XMP:Title": "I found one!", "EXIF:ImageDescription": "Girl holding pumpkin", "EXIF:Make": "Canon", "XMP:Description": "Girl holding pumpkin", "XMP:PersonInImage": "Katie", "XMP:Subject": "Kids", "EXIF:GPSLatitudeRef": "N", "EXIF:GPSLongitudeRef": "W", "EXIF:GPSLatitude": 41.256566, "EXIF:GPSLongitude": 95.940257, } } CLI_EXIFTOOL_MERGE = { "1EB2B765-0765-43BA-A90C-0D0580E6172C": { "File:FileName": "Pumpkins3.jpg", "IPTC:Keywords": "Kids", "XMP:TagsList": "Kids", "EXIF:ImageDescription": "Kids in pumpkin field", "XMP:Description": "Kids in pumpkin field", "XMP:PersonInImage": ["Katie", "Suzy", "Tim"], "XMP:Subject": "Kids", }, "D79B8D77-BFFC-460B-9312-034F2877D35B": { "File:FileName": "Pumkins2.jpg", "XMP:Title": "I found one!", "EXIF:ImageDescription": "Girl holding pumpkin", "XMP:Description": "Girl holding pumpkin", "XMP:PersonInImage": "Katie", "IPTC:Keywords": ["Kids", "keyword1", "keyword2", "subject1", "tagslist1"], "XMP:TagsList": ["Kids", "keyword1", "keyword2", "subject1", "tagslist1"], "XMP:Subject": ["Kids", "keyword1", "keyword2", "subject1", "tagslist1"], }, } CLI_EXIFTOOL_QUICKTIME = { "35329C57-B963-48D6-BB75-6AFF9370CBBC": { "File:FileName": "Jellyfish.MOV", "XMP:Description": "Jellyfish Video", "XMP:Title": "Jellyfish", "XMP:TagsList": "Travel", "XMP:Subject": "Travel", "QuickTime:GPSCoordinates": "34.053345 -118.242349", "QuickTime:CreationDate": "2020:01:05 14:13:13-08:00", "QuickTime:CreateDate": "2020:01:05 22:13:13", "QuickTime:ModifyDate": "2020:01:05 22:13:13", }, "D1359D09-1373-4F3B-B0E3-1A4DE573E4A3": { "File:FileName": "Jellyfish1.mp4", "XMP:Description": "Jellyfish Video", "XMP:Title": "Jellyfish1", "XMP:TagsList": "Travel", "XMP:Subject": "Travel", "QuickTime:GPSCoordinates": "34.053345 -118.242349", "QuickTime:CreationDate": "2020:12:04 21:21:52-08:00", "QuickTime:CreateDate": "2020:12:05 05:21:52", "QuickTime:ModifyDate": "2020:12:05 05:21:52", }, } CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = { "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": { "File:FileName": "wedding.jpg", "EXIF:ImageDescription": "Bride Wedding day", "XMP:Description": "Bride Wedding day", "XMP:TagsList": ["Maria", "wedding"], "IPTC:Keywords": ["Maria", "wedding"], "XMP:PersonInImage": "Maria", "XMP:Subject": ["Maria", "wedding"], "EXIF:DateTimeOriginal": "2019:04:15 14:40:24", "EXIF:CreateDate": "2019:04:15 14:40:24", "EXIF:OffsetTimeOriginal": "-04:00", "IPTC:DigitalCreationDate": "2019:04:15", "IPTC:DateCreated": "2019:04:15", "EXIF:ModifyDate": "2019:04:15 14:40:24", } } CLI_EXIFTOOL_ERROR = ["E2078879-A29C-4D6F-BACB-E3BBE6C3EB91"] CLI_NOT_REALLY_A_JPEG = "E2078879-A29C-4D6F-BACB-E3BBE6C3EB91" CLI_EXIFTOOL_DUPLICATE_KEYWORDS = { "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": "wedding.jpg" } CLI_FINDER_TAGS = { "D79B8D77-BFFC-460B-9312-034F2877D35B": { "File:FileName": "Pumkins2.jpg", "IPTC:Keywords": "Kids", "XMP:TagsList": "Kids", "XMP:Title": "I found one!", "EXIF:ImageDescription": "Girl holding pumpkin", "XMP:Description": "Girl holding pumpkin", "XMP:PersonInImage": "Katie", "XMP:Subject": "Kids", }, "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": { "File:FileName": "wedding.jpg", "IPTC:Keywords": ["Maria", "wedding"], "XMP:TagsList": ["Maria", "wedding"], "XMP:Title": None, "EXIF:ImageDescription": "Bride Wedding day", "XMP:Description": "Bride Wedding day", "XMP:PersonInImage": "Maria", "XMP:Subject": ["Maria", "wedding"], }, } CLI_EXPORT_YEAR_2017 = ["IMG_4547.jpg"] LABELS_JSON = { "labels": { "Water": 2, "Underwater": 2, "Jellyfish": 2, "Animal": 2, "Wine Bottle": 2, "Drink": 2, "Wine": 2, "Vase": 1, "Flower": 1, "Plant": 1, "Flower Arrangement": 1, "Bouquet": 1, "Art": 1, "Container": 1, "Camera": 1, "Document": 1, } } KEYWORDS_JSON = { "keywords": { "Kids": 4, "wedding": 3, "Travel": 2, "UK": 1, "England": 1, "London": 1, "United Kingdom": 1, "London 2018": 1, "St. James's Park": 1, "flowers": 1, "foo/bar": 1, "Maria": 1, "Wine": 2, "Val d'Isère": 2, "Drink": 2, "Wine Bottle": 2, "Food": 2, "Furniture": 2, "Pizza": 2, "Table": 2, "Cloudy": 2, "Cord": 2, "Outdoor": 2, "Sky": 2, "Sunset Sunrise": 2, } } ALBUMS_JSON = { "albums": { "Raw": 4, "Pumpkin Farm": 3, "Test Album": 2, "AlbumInFolder": 2, "Multi Keyword": 2, "I have a deleted twin": 1, "2018-10 - Sponsion, Museum, Frühstück, Römermuseum": 1, "2019-10/11 Paris Clermont": 1, "EmptyAlbum": 0, "Sorted Manual": 3, "Sorted Newest First": 3, "Sorted Oldest First": 3, "Sorted Title": 3, "Água": 3, }, "shared albums": {}, } ALBUMS_STR = """albums: Raw: 4 Pumpkin Farm: 3 Test Album: 2 AlbumInFolder: 2 Multi Keyword: 2 I have a deleted twin: 1 2018-10 - Sponsion, Museum, Frühstück, Römermuseum: 1 2019-10/11 Paris Clermont: 1 EmptyAlbum: 0 Água: 3 shared albums: {} """ PERSONS_JSON = {"persons": {"Katie": 3, "Suzy": 2, "_UNKNOWN_": 1, "Maria": 2}} UUID_EXPECTED_FROM_FILE = [ "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4", "A92D9C26-3A50-4197-9388-CB5F7DB9FA91", ] UUID_NOT_FROM_FILE = "D79B8D77-BFFC-460B-9312-034F2877D35B" CLI_EXPORT_UUID_FROM_FILE_FILENAMES = [ "IMG_1994.JPG", "IMG_1994.cr2", "Tulips.jpg", "Tulips_edited.jpeg", "wedding.jpg", "wedding_edited.jpeg", ] CLI_EXPORT_SKIP_UUID = [ "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4", ] CLI_EXPORT_SKIP_UUID_FILENAMES = [ "Tulips.jpg", "Tulips_edited.jpeg", "wedding.jpg", "wedding_edited.jpeg", ] UUID_HAS_COMMENTS = [ "4E4944A0-3E5C-4028-9600-A8709F2FA1DB", "4AD7C8EF-2991-4519-9D3A-7F44A6F031BE", "7572C53E-1D6A-410C-A2B1-18CCA3B5AD9F", ] UUID_NO_COMMENTS = ["4F835581-5AB9-4DEC-9971-3E64A0894B04"] UUID_HAS_LIKES = [ "C008048F-8767-4992-85B8-13E798F6DC3C", "65BADBD7-A50C-4956-96BA-1BB61155DA17", "4AD7C8EF-2991-4519-9D3A-7F44A6F031BE", ] UUID_NO_LIKES = [ "45099D34-A414-464F-94A2-60D6823679C8", "1C1C8F1F-826B-4A24-B1CB-56628946A834", ] UUID_JPEGS_DICT = { "4D521201-92AC-43E5-8F7C-59BC41C37A96": ["IMG_1997", "JPG"], "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": ["wedding", "jpg"], "E2078879-A29C-4D6F-BACB-E3BBE6C3EB91": ["screenshot-really-a-png", "jpeg"], } UUID_JPEGS_DICT_NOT_JPEG = { "7783E8E6-9CAC-40F3-BE22-81FB7051C266": ["IMG_3092", "heic"], "D05A5FE3-15FB-49A1-A15D-AB3DA6F8B068": ["DSC03584", "dng"], "8846E3E6-8AC8-4857-8448-E3D025784410": ["IMG_1693", "tif"], } UUID_MOVIES_NOT_JPEGS_DICT = { "423C0683-672D-4DDD-979C-23A6A53D7256": ["IMG_0670B_NOGPS", "MOV"] } UUID_HEIC = {"7783E8E6-9CAC-40F3-BE22-81FB7051C266": "IMG_3092"} UUID_IS_REFERENCE = [ "8E1D7BC9-9321-44F9-8CFB-4083F6B9232A", "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C", ] UUID_EDITED = [ "D1D4040D-D141-44E8-93EA-E403D9F63E07", "7783E8E6-9CAC-40F3-BE22-81FB7051C266", "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4", "1793FAAB-DE75-4E25-886C-2BD66C780D6A", "DC99FBDD-7A52-4100-A5BB-344131646C30", ] UUID_NOT_EDITED = [ "F12384F6-CD17-4151-ACBA-AE0E3688539E", "7F74DD34-5920-4DA3-B284-479887A34F66", "4D521201-92AC-43E5-8F7C-59BC41C37A96", "54E76FCB-D353-4557-9997-0A457BCB4D48", "8E1D7BC9-9321-44F9-8CFB-4083F6B9232A", "F207D5DE-EFAD-4217-8424-0764AAC971D0", "D05A5FE3-15FB-49A1-A15D-AB3DA6F8B068", "52083079-73D5-4921-AC1B-FE76F279133F", "A92D9C26-3A50-4197-9388-CB5F7DB9FA91", "3DD2C897-F19E-4CA6-8C22-B027D5A71907", "7FD37B5F-6FAA-4DB1-8A29-BF9C37E38091", "B13F4485-94E0-41CD-AF71-913095D62E31", "A8266C97-9BAF-4AF4-99F3-0013832869B8", "1EB2B765-0765-43BA-A90C-0D0580E6172C", "E2078879-A29C-4D6F-BACB-E3BBE6C3EB91", "D79B8D77-BFFC-460B-9312-034F2877D35B", "8846E3E6-8AC8-4857-8448-E3D025784410", "D1359D09-1373-4F3B-B0E3-1A4DE573E4A3", "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C", "35329C57-B963-48D6-BB75-6AFF9370CBBC", "2DFD33F1-A5D8-486F-A3A9-98C07995535A", ] UUID_IN_ALBUM = [ "1EB2B765-0765-43BA-A90C-0D0580E6172C", "2DFD33F1-A5D8-486F-A3A9-98C07995535A", "3DD2C897-F19E-4CA6-8C22-B027D5A71907", "4D521201-92AC-43E5-8F7C-59BC41C37A96", "54E76FCB-D353-4557-9997-0A457BCB4D48", "7783E8E6-9CAC-40F3-BE22-81FB7051C266", "7FD37B5F-6FAA-4DB1-8A29-BF9C37E38091", "8E1D7BC9-9321-44F9-8CFB-4083F6B9232A", "A92D9C26-3A50-4197-9388-CB5F7DB9FA91", "D05A5FE3-15FB-49A1-A15D-AB3DA6F8B068", "D79B8D77-BFFC-460B-9312-034F2877D35B", "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", "F12384F6-CD17-4151-ACBA-AE0E3688539E", ] UUID_NOT_IN_ALBUM = [ "1793FAAB-DE75-4E25-886C-2BD66C780D6A", # Frítest.jpg "35329C57-B963-48D6-BB75-6AFF9370CBBC", "52083079-73D5-4921-AC1B-FE76F279133F", "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4", "7F74DD34-5920-4DA3-B284-479887A34F66", "8846E3E6-8AC8-4857-8448-E3D025784410", "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C", "A8266C97-9BAF-4AF4-99F3-0013832869B8", # Frítest.jpg "B13F4485-94E0-41CD-AF71-913095D62E31", # Frítest.jpg "D1359D09-1373-4F3B-B0E3-1A4DE573E4A3", "D1D4040D-D141-44E8-93EA-E403D9F63E07", # Frítest.jpg "DC99FBDD-7A52-4100-A5BB-344131646C30", "E2078879-A29C-4D6F-BACB-E3BBE6C3EB91", "F207D5DE-EFAD-4217-8424-0764AAC971D0", ] UUID_DUPLICATES = [ "2DFD33F1-A5D8-486F-A3A9-98C07995535A", "52083079-73D5-4921-AC1B-FE76F279133F", "54E76FCB-D353-4557-9997-0A457BCB4D48", "7F74DD34-5920-4DA3-B284-479887A34F66", "A92D9C26-3A50-4197-9388-CB5F7DB9FA91", "F207D5DE-EFAD-4217-8424-0764AAC971D0", ] UUID_LOCATION = "D79B8D77-BFFC-460B-9312-034F2877D35B" # Pumkins2.jpg UUID_NO_LOCATION = "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4" # Tulips.jpg" UUID_DICT_MISSING = { "8E1D7BC9-9321-44F9-8CFB-4083F6B9232A": "IMG_2000.jpeg", # missing "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C": "Pumpkins4.jpeg", # missing "D79B8D77-BFFC-460B-9312-034F2877D35B": "Pumkins2.jpg", # not missing } UUID_DICT_FOLDER_ALBUM_SEQ = { "7783E8E6-9CAC-40F3-BE22-81FB7051C266": { "directory": "{folder_album}", "album": "Sorted Oldest First", "filename": "{album?{folder_album_seq.1}_,}{original_name}", "result": "3_IMG_3092.heic", }, "3DD2C897-F19E-4CA6-8C22-B027D5A71907": { "directory": "{album}", "album": "Sorted Oldest First", "filename": "{album?{album_seq}_,}{original_name}", "result": "0_IMG_4547.jpg", }, } UUID_EMPTY_TITLE = "7783E8E6-9CAC-40F3-BE22-81FB7051C266" # IMG_3092.heic FILENAME_EMPTY_TITLE = "IMG_3092.heic" DESCRIPTION_TEMPLATE_EMPTY_TITLE = "{title,No Title} and {descr,No Descr}" DESCRIPTION_VALUE_EMPTY_TITLE = "No Title and No Descr" DESCRIPTION_TEMPLATE_TITLE_CONDITIONAL = "{title?true,false}" DESCRIPTION_VALUE_TITLE_CONDITIONAL = "false" UUID_UNICODE_TITLE = [ "B13F4485-94E0-41CD-AF71-913095D62E31", # Frítest.jpg "1793FAAB-DE75-4E25-886C-2BD66C780D6A", # Frítest.jpg "A8266C97-9BAF-4AF4-99F3-0013832869B8", # Frítest.jpg "D1D4040D-D141-44E8-93EA-E403D9F63E07", # Frítest.jpg ] EXPORT_UNICODE_TITLE_FILENAMES = [ "Frítest.jpg", "Frítest (1).jpg", "Frítest (2).jpg", "Frítest (3).jpg", ] # data for --report UUID_REPORT = [ { "uuid": "4D521201-92AC-43E5-8F7C-59BC41C37A96", "filenames": ["IMG_1997.JPG", "IMG_1997.cr2"], }, { "uuid": "7783E8E6-9CAC-40F3-BE22-81FB7051C266", "filenames": ["IMG_3092.heic", "IMG_3092_edited.jpeg"], }, ] # data for --exif QUERY_EXIF_DATA = [("EXIF:Make", "FUJIFILM", ["6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"])] QUERY_EXIF_DATA_CASE_INSENSITIVE = [ ("Make", "Fujifilm", ["6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"]) ] EXPORT_EXIF_DATA = [("EXIF:Make", "FUJIFILM", ["Tulips.jpg", "Tulips_edited.jpeg"])] UUID_LIVE_EDITED = "136A78FA-1B90-46CC-88A7-CCA3331F0353" # IMG_4813.HEIC CLI_EXPORT_LIVE_EDITED = [ "IMG_4813.HEIC", "IMG_4813.mov", "IMG_4813_edited.jpeg", "IMG_4813_edited.mov", ] UUID_FAVORITE = "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51" FILE_FAVORITE = "wedding.jpg" UUID_NOT_FAVORITE = "1EB2B765-0765-43BA-A90C-0D0580E6172C" FILE_NOT_FAVORITE = "Pumpkins3.jpg" # number of photos in test library with Make=Canon EXIF_MAKE_CANON = 7 @pytest.fixture(scope="module") def local_photosdb(): """Return a PhotosDB object for the local Photos library""" if "OSXPHOTOS_TEST_EXPORT_V2" not in os.environ: pytest.skip("OSXPHOTOS_TEST_EXPORT_V2 not set") return osxphotos.PhotosDB(dbfile=LOCAL_PHOTOSDB) def modify_file(filename): """appends data to a file to modify it""" with open(filename, "ab") as fd: fd.write(b"foo") @pytest.fixture(autouse=True) def reset_globals(): """reset globals in cli that tests may have changed""" yield osxphotos.cli.VERBOSE = False # determine if exiftool installed so exiftool tests can be skipped try: exiftool = get_exiftool_path() except: exiftool = None def touch_all_photos_in_db(dbpath): """touch date on all photos in a library helper function for --touch-file tests Args: dbpath: path to photos library to touch """ ts = int(time.time()) for photo in osxphotos.PhotosDB(dbpath).photos(): if photo.path is not None: os.utime(photo.path, (ts, ts)) if photo.path_edited is not None: os.utime(photo.path_edited, (ts, ts)) if photo.path_raw is not None: os.utime(photo.path_raw, (ts, ts)) if photo.path_live_photo is not None: os.utime(photo.path_live_photo, (ts, ts)) def setup_touch_tests(): """perform setup needed for --touch-file tests""" # touch all photos so they do not match PhotoInfo.date touch_all_photos_in_db(PHOTOS_DB_TOUCH) # adjust a couple of the photos so they're file times *are* correct photos = osxphotos.PhotosDB(PHOTOS_DB_TOUCH).photos_by_uuid( CLI_EXPORT_BY_DATE_TOUCH_UUID ) for photo in photos: ts = int(photo.date.timestamp()) if photo.path is not None: os.utime(photo.path, (ts, ts)) if photo.path_edited is not None: os.utime(photo.path_edited, (ts, ts)) if photo.path_raw is not None: os.utime(photo.path_raw, (ts, ts)) if photo.path_live_photo is not None: os.utime(photo.path_live_photo, (ts, ts)) def test_osxphotos(): runner = CliRunner() result = runner.invoke(cli_main, []) assert result.exit_code == 0 assert "Print information about osxphotos" in result.output def test_osxphotos_help_1(): # test help command no topic runner = CliRunner() result = runner.invoke(cli_main, ["help"]) assert result.exit_code == 0 assert "Print information about osxphotos" in result.output def test_osxphotos_help_2(): # test help command valid topic runner = CliRunner() result = runner.invoke(cli_main, ["help", "persons"]) assert result.exit_code == 0 assert "Print out persons (faces) found in the Photos library." in result.output def test_osxphotos_help_3(): # test help command invalid topic runner = CliRunner() result = runner.invoke(cli_main, ["help", "foo"]) assert result.exit_code == 0 assert "Invalid command: foo" in result.output def test_about(): """Test about""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke(about, []) assert result.exit_code == 0 assert "MIT License" in result.output def test_query_uuid(): runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--uuid", "D79B8D77-BFFC-460B-9312-034F2877D35B", ], ) assert result.exit_code == 0 json_expected = json.loads(CLI_OUTPUT_QUERY_UUID)[0] json_got = json.loads(result.output)[0] assert list(json_expected.keys()).sort() == list(json_got.keys()).sort() # check values expected vs got # path needs special handling as path is set to full path which will differ system to system for key_ in json_expected: assert key_ in json_got if key_ != "path": if isinstance(json_expected[key_], list): assert sorted(json_expected[key_]) == sorted(json_got[key_]) else: assert json_expected[key_] == json_got[key_] else: assert json_expected[key_] in json_got[key_] def test_query_uuid_from_file_1(): """Test query with --uuid-from-file""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--uuid-from-file", UUID_FILE, ], ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(UUID_EXPECTED_FROM_FILE) == sorted(uuid_got) def test_query_uuid_from_file_stdin(): """Test query with --uuid-from-file reading from stdin""" runner = CliRunner() cwd = os.getcwd() input_text = open(UUID_FILE, "r").read() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--uuid-from-file", "-"], input=input_text, ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(UUID_EXPECTED_FROM_FILE) == sorted(uuid_got) def test_query_has_comment(): """Test query with --has-comment""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, COMMENTS_PHOTOS_DB), "--has-comment"], ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(uuid_got) == sorted(UUID_HAS_COMMENTS) def test_query_no_comment(): """Test query with --no-comment""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, COMMENTS_PHOTOS_DB), "--no-comment"] ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] for uuid in UUID_NO_COMMENTS: assert uuid in uuid_got for uuid in uuid_got: assert uuid not in UUID_HAS_COMMENTS def test_query_has_likes(): """Test query with --has-likes""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, COMMENTS_PHOTOS_DB), "--has-likes"] ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(uuid_got) == sorted(UUID_HAS_LIKES) def test_query_no_likes(): """Test query with --no-likes""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, COMMENTS_PHOTOS_DB), "--no-likes"] ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] for uuid in UUID_NO_LIKES: assert uuid in uuid_got for uuid in uuid_got: assert uuid not in UUID_HAS_LIKES def test_query_is_reference(): """Test query with --is-reference""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--is-reference"] ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(uuid_got) == sorted(UUID_IS_REFERENCE) def test_query_edited(): """Test query with --edited""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--edited"] ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(uuid_got) == sorted(UUID_EDITED) def test_query_not_edited(): """Test query with --not-edited""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--not-edited"] ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(uuid_got) == sorted(UUID_NOT_EDITED) def test_query_in_album(): """Test query with --in-album""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--in-album"] ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(uuid_got) == sorted(UUID_IN_ALBUM) def test_query_not_in_album(): """Test query with --not-in-album""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--not-in-album"] ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(uuid_got) == sorted(UUID_NOT_IN_ALBUM) def test_query_duplicate(): """Test query with --duplicate""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--duplicate"], ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(uuid_got) == sorted(UUID_DUPLICATES) def test_query_location(): """Test query with --location""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--location"], ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert UUID_LOCATION in uuid_got assert UUID_NO_LOCATION not in uuid_got def test_query_no_location(): """Test query with --no-location""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--no-location"], ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert UUID_NO_LOCATION in uuid_got assert UUID_LOCATION not in uuid_got @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") @pytest.mark.parametrize("exiftag,exifvalue,uuid_expected", QUERY_EXIF_DATA) def test_query_exif(exiftag, exifvalue, uuid_expected): """Test query with --exif""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--exif", exiftag, exifvalue, ], ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(uuid_got) == sorted(uuid_expected) @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") @pytest.mark.parametrize( "exiftag,exifvalue,uuid_expected", QUERY_EXIF_DATA_CASE_INSENSITIVE ) def test_query_exif_case_insensitive(exiftag, exifvalue, uuid_expected): """Test query with --exif -i""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--exif", exiftag, exifvalue, "-i", ], ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) uuid_got = [photo["uuid"] for photo in json_got] assert sorted(uuid_got) == sorted(uuid_expected) @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_query_exif_multiple(): """Test query with multiple --exif options, #873""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--exif", "Make", "Canon", "--exif", "Model", "Canon PowerShot G10", ], ) assert result.exit_code == 0 # build list of uuids we got from the output JSON json_got = json.loads(result.output) assert len(json_got) == EXIF_MAKE_CANON def test_export(): """test basic export""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) def test_export_alt_copy(): """test basic export with --alt-copy""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--alt-copy", "-V"] ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) def test_export_tmpdir(): """test basic export with --tmpdir""" runner = CliRunner() cwd = os.getcwd() tmpdir = TemporaryDirectory() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--tmpdir", tmpdir.name], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) def test_export_uuid_from_file(): """Test export with --uuid-from-file""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid-from-file", os.path.join(cwd, UUID_FILE), ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_UUID_FROM_FILE_FILENAMES) def test_export_skip_uuid_from_file(): """Test export with --skip-uuid-from-file""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--skip-uuid-from-file", os.path.join(cwd, SKIP_UUID_FILE), ], ) assert result.exit_code == 0 files = glob.glob("*") for skipped_file in CLI_EXPORT_SKIP_UUID_FILENAMES: assert skipped_file not in files def test_export_skip_uuid(): """Test export with --skip-uuid""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager uuid_option = [] for uuid in CLI_EXPORT_SKIP_UUID: uuid_option.append("--skip-uuid") uuid_option.append(uuid) with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", *uuid_option, ], ) assert result.exit_code == 0 files = glob.glob("*") for skipped_file in CLI_EXPORT_SKIP_UUID_FILENAMES: assert skipped_file not in files def test_export_year(): """test export with --year""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--year", "2017", "--skip-edited", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_YEAR_2017) def test_export_preview(): """test export with --preview""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--preview", "--uuid", CLI_EXPORT_UUID, ], ) assert result.exit_code == 0 files = glob.glob("*") assert CLI_EXPORT_UUID_FILENAME_PREVIEW in files def test_export_preview_file_exists(): """test export with --preview when preview images already exist, issue #516""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--preview", "--uuid", CLI_EXPORT_UUID_MISSING, ], ) assert result.exit_code == 0 # export again result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--preview", "--uuid", CLI_EXPORT_UUID_MISSING, ], ) assert result.exit_code == 0 assert "Error exporting photo" not in result.output def test_export_preview_suffix(): """test export with --preview and --preview-suffix""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--preview", "--preview-suffix", CLI_EXPORT_PREVIEW_SUFFIX, "--uuid", CLI_EXPORT_UUID, ], ) assert result.exit_code == 0 files = glob.glob("*") assert CLI_EXPORT_UUID_FILENAME_PREVIEW_TEMPLATE in files def test_export_preview_if_missing(): """test export with --preview_if_missing""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): uuid_options = [] for uuid in UUID_DICT_MISSING: uuid_options.extend(["--uuid", uuid]) result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--preview-if-missing", "--preview-suffix", "", *uuid_options, ], ) assert result.exit_code == 0 files = glob.glob("*") expected_files = list(UUID_DICT_MISSING.values()) assert sorted(files) == sorted(expected_files) def test_export_preview_overwrite(): """test export with --preview and --overwrite (#526)""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--preview", "--uuid", CLI_EXPORT_UUID, ], ) assert result.exit_code == 0 # export again result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--preview", "--uuid", CLI_EXPORT_UUID, "--overwrite", ], ) assert result.exit_code == 0 files = glob.glob("*") assert len(files) == 2 # preview + original def test_export_preview_update(): """test export with --preview and --update (#526)""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--preview", "--uuid", CLI_EXPORT_UUID, ], ) assert result.exit_code == 0 # export again result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--preview", "--uuid", CLI_EXPORT_UUID, "--update", ], ) assert result.exit_code == 0 files = glob.glob("*") assert len(files) == 2 # preview + original def test_export_as_hardlink(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--export-as-hardlink", "-V"], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) def test_export_as_hardlink_samefile(): # test that --export-as-hardlink actually creates a hardlink # src and dest should be same file runner = CliRunner() cwd = os.getcwd() photosdb = osxphotos.PhotosDB(dbfile=CLI_PHOTOS_DB) photo = photosdb.photos(uuid=[CLI_EXPORT_UUID])[0] # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", f"--uuid={CLI_EXPORT_UUID}", "--export-as-hardlink", "-V", ], ) assert result.exit_code == 0 assert os.path.exists(CLI_EXPORT_UUID_FILENAME) assert os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path) def test_export_using_hardlinks_incompat_options(): # test that error shown if --export-as-hardlink used with --exiftool runner = CliRunner() cwd = os.getcwd() photosdb = osxphotos.PhotosDB(dbfile=CLI_PHOTOS_DB) photo = photosdb.photos(uuid=[CLI_EXPORT_UUID])[0] # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", f"--uuid={CLI_EXPORT_UUID}", "--export-as-hardlink", "--exiftool", "-V", ], ) assert result.exit_code == 1 assert "Incompatible export options" in result.output def test_export_current_name(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "--current-name", "-V"] ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_CURRENT) def test_export_skip_edited(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--skip-edited", "-V"] ) assert result.exit_code == 0 files = glob.glob("*") assert "St James Park_edited.jpeg" not in files def test_export_edited(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--edited", "-V"] ) assert result.exit_code == 0 files = glob.glob("*") # make sure edited versions did get exported assert "wedding_edited.jpeg" in files assert "Tulips_edited.jpeg" in files assert "St James Park_edited.jpeg" in files def test_export_not_edited(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--not-edited", "-V"] ) assert result.exit_code == 0 files = glob.glob("*") # make sure not edited files were exported assert "Pumkins1.jpg" in files assert "Pumkins2.jpg" in files assert "Jellyfish1.mp4" in files # make sure edited versions did not get exported assert "wedding_edited.jpeg" not in files assert "Tulips_edited.jpeg" not in files assert "St James Park_edited.jpeg" not in files def test_export_skip_original_if_edited(): """test export with --skip-original-if-edited""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "--skip-original-if-edited", "-V"], ) assert result.exit_code == 0 assert "Skipping original version of wedding.jpg" in result.output assert "Skipping original version of Tulips.jpg" in result.output assert "Skipping original version of St James Park.jpg" in result.output files = glob.glob("*") # make sure originals of edited version not exported assert "wedding.jpg" not in files assert "Tulips.jpg" not in files assert "St James Park.jpg" not in files # make sure edited versions did get exported assert "wedding_edited.jpeg" in files assert "Tulips_edited.jpeg" in files assert "St James Park_edited.jpeg" in files # make sure other originals did get exported assert "Pumkins2.jpg" in files @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]]) exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict() for key in CLI_EXIFTOOL[uuid]: if type(exif[key]) == list: assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key]) else: assert exif[key] == CLI_EXIFTOOL[uuid][key] @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_tmpdir(): """test --exiftool with --tmpdir""" runner = CliRunner() cwd = os.getcwd() tmpdir = TemporaryDirectory() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--uuid", f"{uuid}", "--tmpdir", tmpdir.name, ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]]) exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict() for key in CLI_EXIFTOOL[uuid]: if type(exif[key]) == list: assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key]) else: assert exif[key] == CLI_EXIFTOOL[uuid][key] @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_template_change(): """Test --exiftool when template changes with --update, #630""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL: # export with --exiftool result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 # export with --update, should be no change result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--update", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 assert "exported: 0" in result.output # export with --update and template change, should export result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--keyword-template", "FOO", "--update", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 assert "updated EXIF data: 1" in result.output # export with --update, nothing should export result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--keyword-template", "FOO", "--update", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 assert "exported: 0" in result.output assert "updated EXIF data: 0" in result.output @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_path(): """test --exiftool with --exiftool-path""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--uuid", f"{uuid}", "--exiftool-path", exiftool, ], ) assert result.exit_code == 0 assert f"exiftool path: {exiftool}" in result.output files = glob.glob("*") assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]]) exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict() for key in CLI_EXIFTOOL[uuid]: if type(exif[key]) == list: assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key]) else: assert exif[key] == CLI_EXIFTOOL[uuid][key] @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_path_render_template(): """test --exiftool-path with {exiftool:} template rendering""" exiftool_source = osxphotos.exiftool.get_exiftool_path() # monkey patch get_exiftool_path so it returns None get_exiftool_path = osxphotos.exiftool.get_exiftool_path osxphotos.exiftool.get_exiftool_path = noop runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--filename", "{original_name}_{exiftool:EXIF:Make}", "--uuid", f"{uuid}", "--exiftool-path", exiftool, ], ) assert result.exit_code == 0 assert re.search(r"Exported.*Canon", result.output) osxphotos.exiftool.get_exiftool_path = get_exiftool_path @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_ignore_date_modified(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--ignore-date-modified", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 exif = ExifTool( CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]["File:FileName"] ).asdict() for key in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]: if type(exif[key]) == list: assert sorted(exif[key]) == sorted( CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key] ) else: assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key] @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_quicktime(): """test --exiftol correctly writes QuickTime tags""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL_QUICKTIME: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted( [CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]] ) exif = ExifTool(CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]).asdict() for key in CLI_EXIFTOOL_QUICKTIME[uuid]: assert exif[key] == CLI_EXIFTOOL_QUICKTIME[uuid][key] # clean up exported files to avoid name conflicts for filename in files: os.unlink(filename) @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_duplicate_keywords(): """ensure duplicate keywords are removed""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL_DUPLICATE_KEYWORDS: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--uuid", f"{uuid}", ], ) exif = ExifTool(CLI_EXIFTOOL_DUPLICATE_KEYWORDS[uuid]) exifdict = exif.asdict() assert sorted(exifdict["IPTC:Keywords"]) == ["Maria", "wedding"] assert sorted(exifdict["XMP:Subject"]) == ["Maria", "wedding"] @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_error(): """ " test --exiftool catching error""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]]) exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict() for key in CLI_EXIFTOOL[uuid]: if type(exif[key]) == list: assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key]) else: assert exif[key] == CLI_EXIFTOOL[uuid][key] @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_option(): """test --exiftool-option""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # first export with --exiftool, one file produces a warning result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool"] ) assert result.exit_code == 0 assert "exiftool warning" in result.output # run again with exiftool-option = "-m" (ignore minor warnings) # shouldn't see the warning this time result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--exiftool-option", "-m", ], ) assert result.exit_code == 0 assert "exiftool warning" not in result.output @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_merge(): """test --exiftool-merge-keywords and --exiftool-merge-persons""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL_MERGE: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--uuid", f"{uuid}", "--exiftool-merge-keywords", "--exiftool-merge-persons", ], ) assert result.exit_code == 0 files = glob.glob("*") assert CLI_EXIFTOOL_MERGE[uuid]["File:FileName"] in files exif = ExifTool(CLI_EXIFTOOL_MERGE[uuid]["File:FileName"]).asdict() for key in CLI_EXIFTOOL_MERGE[uuid]: if type(exif[key]) == list: assert sorted(exif[key]) == sorted(CLI_EXIFTOOL_MERGE[uuid][key]) else: assert exif[key] == CLI_EXIFTOOL_MERGE[uuid][key] @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_merge_sidecar(): """test --exiftool-merge-keywords and --exiftool-merge-persons with --sidecar""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_EXIFTOOL_MERGE: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--sidecar", "json", "--uuid", f"{uuid}", "--exiftool-merge-keywords", "--exiftool-merge-persons", ], ) assert result.exit_code == 0 files = glob.glob("*") json_file = f"{CLI_EXIFTOOL_MERGE[uuid]['File:FileName']}.json" assert json_file in files with open(json_file, "r") as fp: exif = json.load(fp)[0] for key in CLI_EXIFTOOL_MERGE[uuid]: if key == "File:FileName": continue if type(exif[key]) == list: expected = ( CLI_EXIFTOOL_MERGE[uuid][key] if type(CLI_EXIFTOOL_MERGE[uuid][key]) == list else [CLI_EXIFTOOL_MERGE[uuid][key]] ) assert sorted(exif[key]) == sorted(expected) else: assert exif[key] == CLI_EXIFTOOL_MERGE[uuid][key] @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_favorite_rating(): """Test --exiftol --favorite-rating""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--uuid", UUID_FAVORITE, "--uuid", UUID_NOT_FAVORITE, "--favorite-rating", ], ) assert result.exit_code == 0 assert ExifTool(FILE_FAVORITE).asdict()["XMP:Rating"] == 5 assert ExifTool(FILE_NOT_FAVORITE).asdict()["XMP:Rating"] == 0 @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_update_errors(): """Test export with --update-errors, #872""" runner = CliRunner() cwd = os.getcwd() # first, normal export with --exiftool # some of the files will have errors / warnings from exiftool with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", ], ) # now run update; none of files should be updated result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--update", ], ) assert result.exit_code == 0 assert "updated: 0" in result.output # now run update-errors; only files with errors should be updated result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool", "--update", "--update-errors", ], ) assert result.exit_code == 0 assert "updated: 4" in result.output def test_export_edited_suffix(): """test export with --edited-suffix""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "--edited-suffix", CLI_EXPORT_EDITED_SUFFIX, "-V", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX) def test_export_edited_suffix_template(): """test export with --edited-suffix template""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "--edited-suffix", CLI_EXPORT_EDITED_SUFFIX_TEMPLATE, "-V", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE) def test_export_original_suffix(): """test export with --original-suffix""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "--original-suffix", CLI_EXPORT_ORIGINAL_SUFFIX, "-V", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX) def test_export_original_suffix_template(): """test export with --original-suffix template""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "--original-suffix", CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE, "-V", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE) @pytest.mark.skipif( "OSXPHOTOS_TEST_CONVERT" not in os.environ, reason="Skip if running in Github actions, no GPU.", ) def test_export_convert_to_jpeg(): """test --convert-to-jpeg""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--convert-to-jpeg"] ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG) large_file = pathlib.Path(CLI_EXPORT_CONVERT_TO_JPEG_LARGE_FILE) assert large_file.stat().st_size > 7000000 @pytest.mark.skipif( "OSXPHOTOS_TEST_CONVERT" not in os.environ, reason="Skip if running in Github actions, no GPU.", ) def test_export_convert_to_jpeg_quality(): """test --convert-to-jpeg --jpeg-quality""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--convert-to-jpeg", "--jpeg-quality", "0.2", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG) large_file = pathlib.Path(CLI_EXPORT_CONVERT_TO_JPEG_LARGE_FILE) assert large_file.stat().st_size < 1000000 @pytest.mark.skipif( "OSXPHOTOS_TEST_CONVERT" not in os.environ, reason="Skip if running in Github actions, no GPU.", ) def test_export_convert_to_jpeg_skip_raw(): """test --convert-to-jpeg""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--convert-to-jpeg", "--skip-raw", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_CONVERT_TO_JPEG_SKIP_RAW) def test_export_duplicate(): """Test export with --duplicate""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--duplicate", "--skip-raw"], ) assert result.exit_code == 0 files = glob.glob("*") assert len(files) == len(UUID_DUPLICATES) def test_export_duplicate_unicode_filenames(): # test issue #515 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager uuid = [] for u in UUID_UNICODE_TITLE: uuid.append("--uuid") uuid.append(u) with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "--convert-to-jpeg", "--edited-suffix", "", "--filename", "{title,{original_name}}", "--jpeg-ext", "jpg", "--person-keyword", "--skip-bursts", "--skip-live", "--skip-original-if-edited", "--touch-file", "--strip", *uuid, "-V", ], ) assert result.exit_code == 0 assert "exported: 4" in result.output files = glob.glob("*") assert sorted(files) == sorted(EXPORT_UNICODE_TITLE_FILENAMES) def test_query_date_1(): """Test --from-date and --to-date""" os.environ["TZ"] = "US/Pacific" time.tzset() runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--from-date=2018-09-28", "--to-date=2018-09-28T23:00:00", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 4 def test_query_date_2(): """Test --from-date and --to-date""" os.environ["TZ"] = "Asia/Jerusalem" time.tzset() runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--from-date=2018-09-28", "--to-date=2018-09-28T23:00:00", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 2 def test_query_date_timezone(): """Test --from-date, --to-date with ISO 8601 timezone""" os.environ["TZ"] = "US/Pacific" time.tzset() runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--from-date=2018-09-28T00:00:00-07:00", "--to-date=2018-09-28T23:00:00-07:00", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 4 def test_query_time(): """Test --from-time, --to-time""" os.environ["TZ"] = "US/Pacific" time.tzset() runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--from-time=16:00", "--to-time=17:00", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 3 def test_query_year_1(): """Test --year""" os.environ["TZ"] = "US/Pacific" time.tzset() runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--year", 2017, ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 def test_query_year_2(): """Test --year with multiple years""" os.environ["TZ"] = "US/Pacific" time.tzset() runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--year", 2017, "--year", 2018, ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 6 def test_query_year_3(): """Test --year with invalid year""" os.environ["TZ"] = "US/Pacific" time.tzset() runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--year", 3000, ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 0 def test_query_keyword_1(): """Test query --keyword""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--keyword", "Kids"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 4 def test_query_keyword_2(): """Test query --keyword with lower case keyword""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--keyword", "kids"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 0 def test_query_keyword_3(): """Test query --keyword with lower case keyword and --ignore-case""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--keyword", "kids", "--ignore-case", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 4 def test_query_keyword_4(): """Test query with more than one --keyword""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--keyword", "Kids", "--keyword", "wedding", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 6 def test_query_no_keyword(): """Test query --no-keyword""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--no-keyword", "--added-before", "2022-05-05", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 11 def test_query_person_1(): """Test query --person""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--person", "Katie"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 3 def test_query_person_2(): """Test query --person with lower case person""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--person", "katie"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 0 def test_query_person_3(): """Test query --person with lower case person and --ignore-case""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--person", "katie", "--ignore-case", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 3 def test_query_person_4(): """Test query with multiple --person""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--person", "Katie", "--person", "Maria", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 4 def test_query_album_1(): """Test query --album""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--album", "Pumpkin Farm", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 3 def test_query_album_2(): """Test query --album with lower case album""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--album", "pumpkin farm", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 0 def test_query_album_3(): """Test query --album with lower case album and --ignore-case""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--album", "pumpkin farm", "--ignore-case", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 3 def test_query_album_4(): """Test query with multipl --album""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--album", "Pumpkin Farm", "--album", "Raw", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 7 def test_query_label_1(): """Test query --label""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--label", "Bouquet"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 def test_query_label_2(): """Test query --label with lower case label""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--label", "bouquet"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 0 def test_query_label_3(): """Test query --label with lower case label and --ignore-case""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--label", "bouquet", "--ignore-case", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 def test_query_label_4(): """Test query with more than one --label""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--label", "Bouquet", "--label", "Plant", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 def test_query_deleted_deleted_only(): """Test query with --deleted and --deleted-only""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--deleted", "--deleted-only", ], ) assert "Incompatible query options" in result.output def test_query_deleted_1(): """Test query with --deleted""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--deleted"] ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == PHOTOS_NOT_IN_TRASH_LEN_15_7 + PHOTOS_IN_TRASH_LEN_15_7 def test_query_deleted_2(): """Test query with --deleted""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--deleted"] ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == PHOTOS_NOT_IN_TRASH_LEN_15_7 + PHOTOS_IN_TRASH_LEN_15_7 def test_query_deleted_3(): """Test query with --deleted-only""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--deleted-only"] ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == PHOTOS_IN_TRASH_LEN_15_7 assert json_got[0]["intrash"] def test_query_deleted_4(): """Test query with --deleted-only""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--deleted-only"] ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == PHOTOS_IN_TRASH_LEN_15_7 assert json_got[0]["intrash"] def test_export_sidecar(): """test --sidecar""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=xmp", f"--uuid={CLI_EXPORT_UUID}", "-V", ], ) assert result.exit_code == 0 files = glob.glob("*.*") assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES) def test_export_sidecar_favorite_rating(): """test --sidecar --favorite-rating""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=xmp", f"--uuid={UUID_FAVORITE}", f"--uuid={UUID_NOT_FAVORITE}", "--favorite-rating", "-V", ], ) assert result.exit_code == 0 with open(f"{FILE_FAVORITE}.json") as fp: json_sidecar = json.load(fp) assert json_sidecar[0]["XMP:Rating"] == 5 with open(f"{FILE_NOT_FAVORITE}.json") as fp: json_sidecar = json.load(fp) assert json_sidecar[0]["XMP:Rating"] == 0 results = subprocess.run( ["grep", "xmp:Rating", f"{FILE_FAVORITE}.xmp"], capture_output=True ) results_stdout = results.stdout.decode("utf-8") assert "5" in results_stdout results = subprocess.run( ["grep", "xmp:Rating", f"{FILE_NOT_FAVORITE}.xmp"], capture_output=True ) results_stdout = results.stdout.decode("utf-8") assert "0" in results_stdout def test_export_sidecar_drop_ext(): """test --sidecar with --sidecar-drop-ext option""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=xmp", "--sidecar-drop-ext", f"--uuid={CLI_EXPORT_UUID}", "-V", ], ) assert result.exit_code == 0 files = glob.glob("*.*") assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_DROP_EXT_FILENAMES) def test_export_sidecar_exiftool(): """test --sidecar exiftool""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=exiftool", "--sidecar=xmp", f"--uuid={CLI_EXPORT_UUID}", "-V", ], ) assert result.exit_code == 0 assert "Writing exiftool sidecar" in result.output files = glob.glob("*.*") assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES) def test_export_sidecar_templates(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--sidecar=json", f"--uuid={CLI_UUID_DICT_15_7['template']}", "-V", "--keyword-template", "{person}", "--description-template", "{descr} {person} {keyword} {album}", ], ) assert result.exit_code == 0 assert os.path.isfile(CLI_TEMPLATE_SIDECAR_FILENAME) with open(CLI_TEMPLATE_SIDECAR_FILENAME, "r") as jsonfile: exifdata = json.load(jsonfile) assert ( exifdata[0]["XMP:Description"] == "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Sorted Manual, Sorted Newest First, Sorted Oldest First, Sorted Title, Test Album" ) assert ( exifdata[0]["EXIF:ImageDescription"] == "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Sorted Manual, Sorted Newest First, Sorted Oldest First, Sorted Title, Test Album" ) def test_export_sidecar_templates_exiftool(): """test --sidecar exiftool with templates""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--sidecar=exiftool", f"--uuid={CLI_UUID_DICT_15_7['template']}", "-V", "--keyword-template", "{person}", "--description-template", "{descr} {person} {keyword} {album}", ], ) assert result.exit_code == 0 assert os.path.isfile(CLI_TEMPLATE_SIDECAR_FILENAME) with open(CLI_TEMPLATE_SIDECAR_FILENAME, "r") as jsonfile: exifdata = json.load(jsonfile) assert ( exifdata[0]["Description"] == "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Sorted Manual, Sorted Newest First, Sorted Oldest First, Sorted Title, Test Album" ) assert ( exifdata[0]["ImageDescription"] == "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Sorted Manual, Sorted Newest First, Sorted Oldest First, Sorted Title, Test Album" ) def test_export_sidecar_update(): """test sidecar don't update if not changed and do update if changed""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=xmp", f"--uuid={CLI_EXPORT_UUID}", "-V", ], ) assert result.exit_code == 0 assert "Writing XMP sidecar" in result.output assert "Writing JSON sidecar" in result.output # delete a sidecar file and run update fileutil = FileUtil() fileutil.unlink(CLI_EXPORT_SIDECAR_FILENAMES[1]) result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=xmp", f"--uuid={CLI_EXPORT_UUID}", "-V", "--update", ], ) assert result.exit_code == 0 assert "Skipped up to date XMP sidecar" in result.output assert "Writing JSON sidecar" in result.output # run update again, no sidecar files should update result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=xmp", f"--uuid={CLI_EXPORT_UUID}", "-V", "--update", ], ) assert result.exit_code == 0 assert "Skipped up to date XMP sidecar" in result.output assert "Skipped up to date JSON sidecar" in result.output # touch a file and export again ts = datetime.datetime.now().timestamp() + 1000 fileutil.utime(CLI_EXPORT_SIDECAR_FILENAMES[2], (ts, ts)) result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=xmp", f"--uuid={CLI_EXPORT_UUID}", "-V", "--update", ], ) assert result.exit_code == 0 assert "Writing XMP sidecar" in result.output assert "Skipped up to date JSON sidecar" in result.output # run update again, no sidecar files should update result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=xmp", f"--uuid={CLI_EXPORT_UUID}", "-V", "--update", ], ) assert result.exit_code == 0 assert "Skipped up to date XMP sidecar" in result.output assert "Skipped up to date JSON sidecar" in result.output # run update again with updated metadata, forcing update result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=xmp", f"--uuid={CLI_EXPORT_UUID}", "-V", "--update", "--keyword-template", "foo", ], ) assert result.exit_code == 0 assert "Writing XMP sidecar" in result.output assert "Writing JSON sidecar" in result.output def test_export_sidecar_invalid(): """test invalid combination of sidecars""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=exiftool", f"--uuid={CLI_EXPORT_UUID}", "-V", ], ) assert result.exit_code != 0 assert "cannot use --sidecar json with --sidecar exiftool" in result.output def test_export_live(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, LIVE_PHOTOS_DB), ".", "--live", "-V"] ) files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_LIVE_ORIGINAL) def test_export_skip_live(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, LIVE_PHOTOS_DB), ".", "--skip-live", "-V"] ) files = glob.glob("*") assert "img_0728.mov" not in [f.lower() for f in files] def test_export_raw(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, RAW_PHOTOS_DB), ".", "--current-name", "--skip-edited", "-V", ], ) files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_RAW) # TODO: Update this once RAW db is added # def test_skip_raw(): # runner = CliRunner() # cwd = os.getcwd() # # pylint: disable=not-context-manager # with runner.isolated_filesystem(): # result = runner.invoke( # export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "--skip-raw", "-V"] # ) # files = glob.glob("*") # for rawname in CLI_EXPORT_RAW: # assert rawname.lower() not in [f.lower() for f in files] def test_export_raw_original(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "--skip-edited", "-V"] ) files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_RAW_ORIGINAL) def test_export_raw_edited(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "--current-name", "-V"] ) files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_RAW_EDITED) def test_export_raw_edited_original(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke(export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "-V"]) files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_RAW_EDITED_ORIGINAL) def test_export_directory_template_1(): # test export using directory template locale.setlocale(locale.LC_ALL, "en_US") runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--directory", "{created.year}/{created.month}", ], ) assert result.exit_code == 0 workdir = os.getcwd() for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1: assert os.path.isfile(os.path.join(workdir, filepath)) def test_export_directory_template_2(): # test export using directory template with missing substitution value runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--directory", "{place.name}", ], ) assert result.exit_code == 0 workdir = os.getcwd() for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES2: assert os.path.isfile(os.path.join(workdir, filepath)) def test_export_directory_template_3(): # test export using directory template with unmatched substitution value runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--directory", "{created.year}/{foo}", ], ) assert result.exit_code != 0 assert "Invalid value" in result.output def test_export_directory_template_album_1(): # test export using directory template with multiple albums runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--directory", "{album}"], ) assert result.exit_code == 0 workdir = os.getcwd() for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1: assert os.path.isfile(os.path.join(workdir, filepath)) def test_export_directory_template_album_2(): # test export using directory template with multiple albums # specify default value runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--directory", "{album,NOALBUM}", ], ) assert result.exit_code == 0 workdir = os.getcwd() for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM2: assert os.path.isfile(os.path.join(workdir, filepath)) @pytest.mark.skipif( "OSXPHOTOS_TEST_LOCALE" not in os.environ, reason="Skip if running in Github actions", ) def test_export_directory_template_locale(): # test export using directory template in user locale non-US runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # set locale environment os.environ["LC_ALL"] = "de_DE.UTF-8" locale.setlocale(locale.LC_ALL, "") result = runner.invoke( export, [ os.path.join(cwd, PLACES_PHOTOS_DB), ".", "-V", "--directory", "{created.year}/{created.month}", ], ) assert result.exit_code == 0 workdir = os.getcwd() for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_LOCALE: assert os.path.isfile(os.path.join(workdir, filepath)) def test_export_filename_template_1(): """export photos using filename template""" locale.setlocale(locale.LC_ALL, "en_US") runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--filename", "{created.year}-{original_name}", ], ) assert result.exit_code == 0 files = glob.glob("*.*") for file in CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1: assert file in files def test_export_filename_template_2(): """export photos using filename template with folder_album and path_sep""" locale.setlocale(locale.LC_ALL, "en_US") runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--filename", "{folder_album,None}-{original_name}", ], ) assert result.exit_code == 0 files = glob.glob("*.*") for file in CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2: assert file in files def test_export_filename_template_strip(): """export photos using filename template with --strip""" locale.setlocale(locale.LC_ALL, "en_US") runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--filename", "{searchinfo.venue,} {created.year}-{original_name}", "--strip", ], ) assert result.exit_code == 0 files = glob.glob("*.*") for file in CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1: assert file in files def test_export_filename_template_pathsep_in_name_1(): """export photos using filename template with folder_album and "/" in album name""" locale.setlocale(locale.LC_ALL, "en_US") runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--directory", "{folder_album,None}", "--uuid", CLI_EXPORT_UUID_STATUE, ], ) assert result.exit_code == 0 for fname in CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_PATHSEP: # assert fname in result.output assert pathlib.Path(fname).is_file() def test_export_filename_template_pathsep_in_name_2(): """export photos using filename template with keyword and "/" in keyword""" locale.setlocale(locale.LC_ALL, "en_US") runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--directory", "{keyword}", "--filename", "{keyword}_{original_name}", "--uuid", CLI_EXPORT_UUID_KEYWORD_PATHSEP, ], ) assert result.exit_code == 0 for fname in CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES_KEYWORD_PATHSEP: assert pathlib.Path(fname).is_file() def test_export_filename_template_long_description(): """export photos using filename template with description that exceeds max length""" locale.setlocale(locale.LC_ALL, "en_US") runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--filename", "{descr}", "--uuid", CLI_EXPORT_UUID_LONG_DESCRIPTION, ], ) assert result.exit_code == 0 for fname in CLI_EXPORTED_FILENAME_TEMPLATE_LONG_DESCRIPTION: assert pathlib.Path(fname).is_file() def test_export_filename_template_3(): """test --filename with invalid template""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--directory", "{foo}-{original_filename}", ], ) assert result.exit_code != 0 assert "Invalid value" in result.output def test_export_album(): """Test export of an album""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "--album", "Pumpkin Farm", "-V"], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ALBUM) def test_export_album_unicode_name(): """Test export of an album with non-English characters in name""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "--album", "2018-10 - Sponsion, Museum, Frühstück, Römermuseum", "-V", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ALBUM_UNICODE) def test_export_album_deleted_twin(): """Test export of an album where album of same name has been deleted""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "--album", "I have a deleted twin", "-V", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_DELETED_TWIN) def test_export_deleted_1(): """Test export with --deleted""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "--deleted", *skip] ) assert result.exit_code == 0 files = glob.glob("*") assert ( len(files) == PHOTOS_NOT_IN_TRASH_LEN_15_7 + PHOTOS_IN_TRASH_LEN_15_7 - PHOTOS_MISSING_15_7 ) def test_export_deleted_2(): """Test export with --deleted""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_14_6), ".", "--deleted", *skip] ) assert result.exit_code == 0 files = glob.glob("*") assert ( len(files) == PHOTOS_NOT_IN_TRASH_LEN_14_6 + PHOTOS_IN_TRASH_LEN_14_6 - PHOTOS_MISSING_14_6 ) def test_export_not_deleted_1(): """Test export does not find intrash files without --deleted flag""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] result = runner.invoke(export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", *skip]) assert result.exit_code == 0 files = glob.glob("*") assert len(files) == PHOTOS_NOT_IN_TRASH_LEN_15_7 - PHOTOS_MISSING_15_7 def test_export_not_deleted_2(): """Test export does not find intrash files without --deleted flag""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] result = runner.invoke(export, [os.path.join(cwd, PHOTOS_DB_14_6), ".", *skip]) assert result.exit_code == 0 files = glob.glob("*") assert len(files) == PHOTOS_NOT_IN_TRASH_LEN_14_6 - PHOTOS_MISSING_14_6 def test_export_deleted_only_1(): """Test export with --deleted-only""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "--deleted-only", *skip] ) assert result.exit_code == 0 files = glob.glob("*") assert len(files) == PHOTOS_IN_TRASH_LEN_15_7 def test_export_deleted_only_2(): """Test export with --deleted-only""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_14_6), ".", "--deleted-only", *skip] ) assert result.exit_code == 0 files = glob.glob("*") assert len(files) == PHOTOS_IN_TRASH_LEN_14_6 def test_export_error(monkeypatch): """Test that export catches errors thrown by export""" # Note: I often comment out the try/except block in cli.py::export_photo_with_template when # debugging to see exactly where the error is # this test verifies I've re-enabled that code runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager def throw_error(*args, **kwargs): raise ValueError("Argh!") monkeypatch.setattr(osxphotos.PhotoExporter, "export", throw_error) with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid", CLI_EXPORT_UUID], ) assert result.exit_code == 0 assert "Error exporting" in result.output @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") @pytest.mark.parametrize("exiftag,exifvalue,files_expected", EXPORT_EXIF_DATA) def test_export_exif(exiftag, exifvalue, files_expected): """Test export --exif query""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--exif", exiftag, exifvalue, "-V"], ) files = glob.glob("*") assert sorted(files) == sorted(files_expected) def test_places(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): 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) def test_place_13(): # test --place on 10.13 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( query, [ "--db", os.path.join(cwd, PLACES_PHOTOS_DB_13), "--json", "--place", "Adelaide", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 # single element assert json_got[0]["uuid"] == "2L6X2hv3ROWRSCU3WRRAGQ" def test_no_place_13(): # test --no-place on 10.13 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( query, ["--db", os.path.join(cwd, PLACES_PHOTOS_DB_13), "--json", "--no-place"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 # single element assert json_got[0]["uuid"] == "pERZk5T1Sb+XcKDFRCsGpA" def test_place_15_1(): # test --place on 10.15 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( query, [ "--db", os.path.join(cwd, PLACES_PHOTOS_DB), "--json", "--place", "Washington", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 # single element assert json_got[0]["uuid"] == "128FB4C6-0B16-4E7D-9108-FB2E90DA1546" def test_place_15_2(): # test --place on 10.15 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( query, [ "--db", os.path.join(cwd, PLACES_PHOTOS_DB), "--json", "--place", "United States", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 2 # single element uuid = [json_got[x]["uuid"] for x in (0, 1)] assert "128FB4C6-0B16-4E7D-9108-FB2E90DA1546" in uuid assert "FF7AFE2C-49B0-4C9B-B0D7-7E1F8B8F2F0C" in uuid def test_no_place_15(): # test --no-place on 10.15 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( query, ["--db", os.path.join(cwd, PLACES_PHOTOS_DB), "--json", "--no-place"] ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 # single element assert json_got[0]["uuid"] == "A9B73E13-A6F2-4915-8D67-7213B39BAE9F" def test_no_folder_1_15(): # test --folder on 10.15 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( query, [ "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json", "--folder", "Folder1", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 2 # single element for item in json_got: assert item["uuid"] in [ "3DD2C897-F19E-4CA6-8C22-B027D5A71907", "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", ] for album in item["albums"]: assert album in [ "2019-10/11 Paris Clermont", "2018-10 - Sponsion, Museum, Frühstück, Römermuseum", "AlbumInFolder", "I have a deleted twin", "Multi Keyword", "Sorted Manual", "Sorted Newest First", "Sorted Oldest First", "Sorted Title", ] def test_no_folder_2_15(): # test --folder with --uuid on 10.15 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( query, [ "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json", "--folder", "Folder1", "--uuid", "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 # single element for item in json_got: assert item["uuid"] == "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51" assert sorted(item["albums"]) == sorted( ["AlbumInFolder", "I have a deleted twin", "Multi Keyword"] ) def test_no_folder_1_14(): # test --folder on 10.14 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( query, [ "--db", os.path.join(cwd, PHOTOS_DB_14_6), "--json", "--folder", "Folder1", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 # single element assert json_got[0]["uuid"] == "15uNd7%8RguTEgNPKHfTWw" def test_export_sidecar_keyword_template(): runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, CLI_PHOTOS_DB), ".", "--sidecar=json", "--sidecar=xmp", "--keyword-template", "{folder_album}", f"--uuid={CLI_EXPORT_UUID}", "-V", ], ) assert result.exit_code == 0 files = glob.glob("*.*") assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES) json_expected = json.loads( """ [{ "SourceFile": "Pumkins2.jpg", "ExifTool:ExifToolVersion": "12.00", "File:FileName": "Pumkins2.jpg", "EXIF:ImageDescription": "Girl holding pumpkin", "XMP:Description": "Girl holding pumpkin", "IPTC:Caption-Abstract": "Girl holding pumpkin", "XMP:Title": "I found one!", "IPTC:ObjectName": "I found one!", "IPTC:Keywords": [ "Kids", "Multi Keyword", "Pumpkin Farm", "Test Album" ], "XMP:Subject": [ "Kids", "Multi Keyword", "Pumpkin Farm", "Test Album" ], "XMP:TagsList": [ "Kids", "Multi Keyword", "Pumpkin Farm", "Test Album" ], "XMP:PersonInImage": [ "Katie" ], "XMP:RegionAppliedToDimensionsW": 1365, "XMP:RegionAppliedToDimensionsH": 2048, "XMP:RegionAppliedToDimensionsUnit": "pixel", "XMP:RegionName": [ "Katie" ], "XMP:RegionType": [ "Face" ], "XMP:RegionAreaX": [ 0.5898191407322884 ], "XMP:RegionAreaY": [ 0.28164292871952057 ], "XMP:RegionAreaW": [ 0.22411711513996124 ], "XMP:RegionAreaH": [ 0.14937493269826518 ], "XMP:RegionAreaUnit": [ "normalized" ], "XMP:RegionPersonDisplayName": [ "Katie" ], "EXIF:GPSLatitude": 41.256566, "EXIF:GPSLongitude": -95.940257, "EXIF:GPSLatitudeRef": "N", "EXIF:GPSLongitudeRef": "W", "EXIF:DateTimeOriginal": "2018:09:28 16:07:07", "EXIF:CreateDate": "2018:09:28 16:07:07", "EXIF:OffsetTimeOriginal": "-04:00", "IPTC:DateCreated": "2018:09:28", "IPTC:TimeCreated": "16:07:07-04:00", "EXIF:ModifyDate": "2018:09:28 16:07:07" }]""" )[0] with open("Pumkins2.jpg.json", "r") as json_file: json_got = json.load(json_file)[0] # some gymnastics to account for different sort order in different pythons for k, v in json_got.items(): if type(v) in (list, tuple): assert sorted(json_expected[k]) == sorted(v) else: assert json_expected[k] == v for k, v in json_expected.items(): if type(v) in (list, tuple): assert sorted(json_got[k]) == sorted(v) else: assert json_got[k] == v def test_export_update_basic(): """test export then update""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) assert os.path.isfile(OSXPHOTOS_EXPORT_DB) # update result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update"] ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0" in result.output ) def test_export_force_update(): """test export with --force-update""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) assert os.path.isfile(OSXPHOTOS_EXPORT_DB) src = os.path.join(cwd, CLI_PHOTOS_DB) dest = os.path.join(os.getcwd(), "export_force_update.photoslibrary") photos_db_path = copy_photos_library_to_path(src, dest) # update result = runner.invoke( export, [os.path.join(cwd, photos_db_path), ".", "--update"] ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0" in result.output ) # force update must be run once to set the metadata digest info # in practice, this means that first time user uses --force-update, most files will likely be re-exported result = runner.invoke( export, [os.path.join(cwd, photos_db_path), ".", "--force-update"] ) assert result.exit_code == 0 # update a file dbpath = os.path.join(photos_db_path, "database/Photos.sqlite") try: conn = sqlite3.connect(dbpath) c = conn.cursor() except sqlite3.Error as e: pytest.exit(f"An error occurred opening sqlite file") # photo is IMG_4547.jpg c.execute( "UPDATE ZADDITIONALASSETATTRIBUTES SET Z_OPT=9, ZTITLE='My Updated Title' WHERE Z_PK=8;" ) conn.commit() # run --force-update to see if updated metadata forced update result = runner.invoke( export, [os.path.join(cwd, photos_db_path), ".", "--force-update"] ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 1, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-1}, updated EXIF data: 0, missing: 3, error: 0" in result.output ) # update, nothing should export result = runner.invoke( export, [os.path.join(cwd, photos_db_path), ".", "--update"] ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0" in result.output ) # run --force-update, nothing should export result = runner.invoke( export, [os.path.join(cwd, photos_db_path), ".", "--force-update"] ) assert result.exit_code == 0 print(result.output) assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0" in result.output ) @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_update_complex(): """test complex --update scenario, #630""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) assert os.path.isfile(OSXPHOTOS_EXPORT_DB) src = os.path.join(cwd, CLI_PHOTOS_DB) dest = os.path.join(os.getcwd(), "export_complex_update.photoslibrary") photos_db_path = copy_photos_library_to_path(src, dest) tempdir = TemporaryDirectory() options = [ "--verbose", "--update", "--cleanup", "--directory", "{created.year}/{created.month}", "--description-template", "Album:{album,}{newline}Description:{descr,}", "--exiftool", "--exiftool-merge-keywords", "--exiftool-merge-persons", "--keyword-template", "{keyword}", "--not-hidden", "--retry", "2", "--skip-original-if-edited", "--timestamp", "--strip", "--skip-uuid", CLI_NOT_REALLY_A_JPEG, ] # update result = runner.invoke( export, [os.path.join(cwd, photos_db_path), tempdir.name, *options] ) assert result.exit_code == 0 assert ( f"exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7-1}, updated: 0, skipped: 0, updated EXIF data: {PHOTOS_NOT_IN_TRASH_LEN_15_7-1}" in result.output ) result = runner.invoke( export, [os.path.join(cwd, photos_db_path), tempdir.name, *options] ) assert result.exit_code == 0 assert "exported: 0" in result.output # update a file dbpath = os.path.join(photos_db_path, "database/Photos.sqlite") try: conn = sqlite3.connect(dbpath) c = conn.cursor() except sqlite3.Error as e: pytest.exit(f"An error occurred opening sqlite file") # photo is IMG_4547.jpg c.execute( "UPDATE ZADDITIONALASSETATTRIBUTES SET Z_OPT=9, ZTITLE='My Updated Title' WHERE Z_PK=8;" ) conn.commit() # run --update to see if updated metadata forced update result = runner.invoke( export, [os.path.join(cwd, photos_db_path), tempdir.name, *options] ) assert result.exit_code == 0 assert ( f"exported: 0, updated: 1, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7-2}, updated EXIF data: 1" in result.output ) # update, nothing should export result = runner.invoke( export, [os.path.join(cwd, photos_db_path), tempdir.name, *options] ) assert result.exit_code == 0 assert ( f"exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7-1}, updated EXIF data: 0" in result.output ) # change the template and run again options.extend(["--keyword-template", "FOO"]) # run update and all photos should be updated result = runner.invoke( export, [os.path.join(cwd, photos_db_path), tempdir.name, *options] ) assert result.exit_code == 0 assert ( f"exported: 0, updated: {PHOTOS_NOT_IN_TRASH_LEN_15_7-1}, skipped: 0, updated EXIF data: {PHOTOS_NOT_IN_TRASH_LEN_15_7-1}" in result.output ) @pytest.mark.skipif( "OSXPHOTOS_TEST_EXPORT" not in os.environ, reason="Skip if not running on author's personal library.", ) def test_export_live_edited(): """test export of edited live image #576""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke( export, [ os.path.join(cwd, LOCAL_PHOTOSDB), ".", "-V", "--uuid", UUID_LIVE_EDITED, "--download-missing", ], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_LIVE_EDITED) def test_export_update_child_folder(): """test export then update into a child folder of previous export""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 os.mkdir("foo") # update into foo result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), "foo", "--update"], input="N\n" ) assert result.exit_code != 0 assert "WARNING: found other export database files" in result.output def test_export_update_parent_folder(): """test export then update into a parent folder of previous export""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export os.mkdir("foo") result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), "foo", "-V"]) assert result.exit_code == 0 # update into "." result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update"], input="N\n" ) assert result.exit_code != 0 assert "WARNING: found other export database files" in result.output @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_update_exiftool(): """test export then update with exiftool""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) # update with exiftool result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update", "--exiftool"] ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, skipped: 0, updated EXIF data: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 3, error: 1" in result.output ) # update with exiftool again, should be no changes result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update", "--exiftool"] ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: 0, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated EXIF data: 0, missing: 3, error: 0" in result.output ) def test_export_update_hardlink(): """test export with hardlink then update""" photosdb = osxphotos.PhotosDB(dbfile=CLI_PHOTOS_DB) photo = photosdb.photos(uuid=[CLI_EXPORT_UUID])[0] runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--export-as-hardlink"], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) assert os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path) # update, should replace the hardlink files with new copies result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update"] ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, skipped: 0, updated EXIF data: 0, missing: 3, error: 0" in result.output ) assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path) @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_update_hardlink_exiftool(): """test export with hardlink then update with exiftool""" photosdb = osxphotos.PhotosDB(dbfile=CLI_PHOTOS_DB) photo = photosdb.photos(uuid=[CLI_EXPORT_UUID])[0] runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--export-as-hardlink"], ) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) assert os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path) # update, should replace the hardlink files with new copies result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update", "--exiftool"] ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 0, updated: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, skipped: 0, updated EXIF data: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 3, error: 1" in result.output ) assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path) def test_export_update_edits(): """test export then update after removing and editing files""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--export-by-date"] ) assert result.exit_code == 0 # change a couple of destination photos os.unlink(CLI_EXPORT_BY_DATE[1]) shutil.copyfile(CLI_EXPORT_BY_DATE[0], CLI_EXPORT_BY_DATE[1]) os.unlink(CLI_EXPORT_BY_DATE[0]) # update result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update", "--export-by-date"], ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 1, updated: 1, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-2}, updated EXIF data: 0, missing: 3, error: 0" in result.output ) def test_export_update_only_new(): """test --update --only-new""" os.environ["TZ"] = "US/Pacific" time.tzset() runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--to-date", "2020-12-20T18:33:41.766684-08:00", ], ) assert result.exit_code == 0 # --update with --only-new --dry-run result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--dry-run", "--update", "--only-new", ], ) assert result.exit_code == 0 assert "exported: 7" in result.output # --update with --only-new result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update", "--only-new"], ) assert result.exit_code == 0 assert "exported: 7" in result.output # --update with --only-new, should export nothing result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update", "--only-new"], ) assert result.exit_code == 0 assert "exported: 0" in result.output def test_export_update_no_db(): """test export then update after db has been deleted""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) assert os.path.isfile(OSXPHOTOS_EXPORT_DB) os.unlink(OSXPHOTOS_EXPORT_DB) # update, will re-export all files with different names result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update"] ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, updated: 0" in result.output ) assert os.path.isfile(OSXPHOTOS_EXPORT_DB) def test_export_then_hardlink(): """test export then hardlink""" photosdb = osxphotos.PhotosDB(dbfile=CLI_PHOTOS_DB) photo = photosdb.photos(uuid=[CLI_EXPORT_UUID])[0] runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 files = glob.glob("*") assert sorted(files) == sorted(CLI_EXPORT_FILENAMES) assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path) result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "--export-as-hardlink", "--overwrite", ], ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 3, error: 0" in result.output ) assert os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path) def test_export_dry_run(): """test export with --dry-run flag""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--dry-run"] ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 3, error: 0" in result.output ) for filepath in CLI_EXPORT_FILENAMES_DRY_RUN: assert re.search(r"Exported.*" + f"{re.escape(filepath)}", result.output) assert not os.path.isfile(normalize_fs_path(filepath)) def test_export_dry_run_alt_copy(): """test export with --dry-run flag and --alt-copy""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--alt-copy", "--dry-run"], ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 3, error: 0" in result.output ) for filepath in CLI_EXPORT_FILENAMES_DRY_RUN: assert re.search(r"Exported.*" + f"{re.escape(filepath)}", result.output) assert not os.path.isfile(normalize_fs_path(filepath)) def test_export_update_edits_dry_run(): """test export then update after removing and editing files with dry-run flag""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--export-by-date"] ) assert result.exit_code == 0 # change a couple of destination photos os.unlink(CLI_EXPORT_BY_DATE[1]) shutil.copyfile(CLI_EXPORT_BY_DATE[0], CLI_EXPORT_BY_DATE[1]) os.unlink(CLI_EXPORT_BY_DATE[0]) # update dry-run result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update", "--export-by-date", "--dry-run", ], ) assert result.exit_code == 0 assert ( f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: 1, updated: 1, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-2}, updated EXIF data: 0, missing: 3, error: 0" in result.output ) # make sure file didn't really get copied assert not os.path.isfile(CLI_EXPORT_BY_DATE[0]) def test_export_directory_template_1_dry_run(): """test export using directory template with dry-run flag""" locale.setlocale(locale.LC_ALL, "en_US") runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--directory", "{created.year}/{created.month}", "--dry-run", ], ) assert result.exit_code == 0 assert ( f"exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}" in result.output ) workdir = os.getcwd() for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1: assert re.search(r"Exported.*" + f"{filepath}", result.output) assert not os.path.isfile(os.path.join(workdir, filepath)) def test_export_touch_files(): """test export with --touch-files""" os.environ["TZ"] = "US/Pacific" time.tzset() setup_touch_tests() runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "-V", "--touch-file", "--export-by-date", ], ) assert result.exit_code == 0 assert ( f"exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}" in result.output ) assert ( f"touched date: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-2}" in result.output ) for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): st = os.stat(fname) assert int(st.st_mtime) == int(mtime) def test_export_touch_files_update(): """test complex export scenario with --update and --touch-files""" os.environ["TZ"] = "US/Pacific" time.tzset() setup_touch_tests() runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export with dry-run result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--dry-run"], ) assert result.exit_code == 0 assert ( f"exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}" in result.output ) assert not pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file() # without dry-run result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date"] ) assert result.exit_code == 0 assert ( f"exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}" in result.output ) assert pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file() # --update result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update"], ) assert result.exit_code == 0 assert ( f"skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}" in result.output ) # --update --touch-file --dry-run result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update", "--touch-file", "--dry-run", ], ) assert result.exit_code == 0 assert ( f"skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}" in result.output ) assert ( f"touched date: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-2}" in result.output ) for fname, mtime in zip( CLI_EXPORT_BY_DATE_NEED_TOUCH, CLI_EXPORT_BY_DATE_NEED_TOUCH_TIMES ): st = os.stat(fname) assert int(st.st_mtime) != int(mtime) # --update --touch-file result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update", "--touch-file", ], ) assert result.exit_code == 0 assert ( f"skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}" in result.output ) assert ( f"touched date: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-2}" in result.output ) for fname, mtime in zip( CLI_EXPORT_BY_DATE_NEED_TOUCH, CLI_EXPORT_BY_DATE_NEED_TOUCH_TIMES ): st = os.stat(fname) assert int(st.st_mtime) == int(mtime) # touch one file and run update again ts = time.time() os.utime(CLI_EXPORT_BY_DATE_NEED_TOUCH[1], (ts, ts)) result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update", "--touch-file", ], ) assert result.exit_code == 0 assert ( f"updated: 1, skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7-1}" in result.output ) assert "touched date: 1" in result.output for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): st = os.stat(fname) assert int(st.st_mtime) == int(mtime) # run update without --touch-file result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update"], ) assert result.exit_code == 0 assert ( f"skipped: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}" in result.output ) @pytest.mark.skip("TODO: This fails on some machines but not all") # @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_touch_files_exiftool_update(): """test complex export scenario with --update, --exiftool, and --touch-files""" os.environ["TZ"] = "US/Pacific" time.tzset() setup_touch_tests() runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # basic export with dry-run result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--dry-run"], ) assert result.exit_code == 0 assert "exported: 18" in result.output assert not pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file() # without dry-run result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date"] ) assert result.exit_code == 0 assert "exported: 18" in result.output assert pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file() # --update result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update"], ) assert result.exit_code == 0 assert "skipped: 19" in result.output # --update --exiftool --dry-run result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update", "--exiftool", "--dry-run", ], ) assert result.exit_code == 0 assert "updated: 18" in result.output assert "updated EXIF data: 18" in result.output # --update --exiftool result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update", "--exiftool", ], ) assert result.exit_code == 0 assert "updated: 18" in result.output assert "updated EXIF data: 18" in result.output # --update --touch-file --exiftool --dry-run result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update", "--exiftool", "--touch-file", "--dry-run", ], ) assert result.exit_code == 0 assert "skipped: 19" in result.output assert "touched date: 18" in result.output # --update --touch-file --exiftool result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update", "--exiftool", "--touch-file", ], ) assert result.exit_code == 0 assert "skipped: 19" in result.output assert "touched date: 18" in result.output for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): st = os.stat(fname) assert int(st.st_mtime) == int(mtime) # touch one file and run update again ts = time.time() os.utime(CLI_EXPORT_BY_DATE[0], (ts, ts)) result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update", "--exiftool", "--touch-file", ], ) assert result.exit_code == 0 assert "updated: 1" in result.output assert "skipped: 17" in result.output assert "updated EXIF data: 1" in result.output assert "touched date: 1" in result.output for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): st = os.stat(fname) assert int(st.st_mtime) == int(mtime) # run --update --exiftool --touch-file again result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--update", "--exiftool", "--touch-file", ], ) assert result.exit_code == 0 assert "exported: 0" in result.output assert "skipped: 19" in result.output # run update without --touch-file result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_TOUCH), ".", "--export-by-date", "--exiftool", "--update", ], ) assert result.exit_code == 0 assert "exported: 0" in result.output assert "skipped: 19" in result.output def test_export_ignore_signature(): """test export with --ignore-signature""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # first, export some files result = runner.invoke(export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V"]) assert result.exit_code == 0 # modify a couple of files for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES: modify_file(f"./{filename}") # export with --update and --ignore-signature # which should ignore the two modified files result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update", "--ignore-signature", ], ) assert result.exit_code == 0 assert "exported: 0, updated: 0" in result.output # export with --update and not --ignore-signature # which should updated the two modified files result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update"] ) assert result.exit_code == 0 assert "updated: 2" in result.output # run --update again, should be 0 files exported result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update"] ) assert result.exit_code == 0 assert "exported: 0, updated: 0" in result.output def test_export_ignore_signature_sidecar(): """test export with --ignore-signature and --sidecar""" """ Test the following use cases: If the metadata (in Photos) that went into the sidecar did not change, the sidecar will not be updated If the metadata (in Photos) that went into the sidecar did change, a new sidecar is written but a new image file is not If a sidecar does not exist for the photo, a sidecar will be written whether or not the photo file was written """ runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # first, export some files result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--sidecar", "XMP"] ) assert result.exit_code == 0 # export with --update and --ignore-signature result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update", "--sidecar", "XMP", "--ignore-signature", ], ) assert result.exit_code == 0 assert "exported: 0, updated: 0" in result.output assert "Writing XMP sidecar" not in result.output # modify a couple of files for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES: modify_file(f"./{filename}") # export with --update and --ignore-signature # which should ignore the two modified files # sidecar files should not be re-written result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update", "--sidecar", "XMP", "--ignore-signature", ], ) assert result.exit_code == 0 assert "exported: 0" in result.output assert "Writing XMP sidecar" not in result.output # change the sidecar data in export DB # should result in a new sidecar being exported but not the image itself exportdb = osxphotos.export_db.ExportDB("./.osxphotos_export.db", ".") for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES: record = exportdb.get_file_record(filename) sidecar_record = exportdb.create_or_get_file_record( f"{filename}.xmp", record.uuid ) sidecar_record.dest_sig = (0, 1, 2) sidecar_record.digest = "FOO" result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update", "--ignore-signature", "--sidecar", "XMP", ], ) assert result.exit_code == 0 assert "exported: 0, updated: 0" in result.output assert result.output.count("Writing XMP sidecar") == len( CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES ) # run --update again, should be 0 files exported result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update", "--ignore-signature", "--sidecar", "XMP", ], ) assert result.exit_code == 0 assert "exported: 0, updated: 0" in result.output assert "Writing XMP sidecar" not in result.output # remove XMP files and run again to verify the files get written for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES: os.unlink(f"./{filename}.xmp") result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update", "--ignore-signature", "--sidecar", "XMP", ], ) assert result.exit_code == 0 assert "exported: 0, updated: 0" in result.output assert result.output.count("Writing XMP sidecar") == len( CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES ) def test_labels(): """Test osxphotos labels""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( labels, ["--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json"] ) assert result.exit_code == 0 json_got = json.loads(result.output) assert json_got == LABELS_JSON def test_keywords(): """Test osxphotos keywords""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( keywords, ["--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json"] ) assert result.exit_code == 0 json_got = json.loads(result.output) assert json_got == KEYWORDS_JSON def test_albums_json(): """Test osxphotos albums json output""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( albums, ["--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json"] ) assert result.exit_code == 0 json_got = json.loads(result.output) assert json_got == ALBUMS_JSON def test_persons(): """Test osxphotos persons""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( persons, ["--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json"] ) assert result.exit_code == 0 json_got = json.loads(result.output) assert json_got == PERSONS_JSON def test_export_report(): """test export with --report option""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # test report creation result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--uuid", UUID_REPORT[0]["uuid"], "--report", "report.csv", ], ) assert result.exit_code == 0 assert "Wrote export report" in result.output assert os.path.exists("report.csv") with open("report.csv", "r") as f: reader = csv.DictReader(f) rows = list(reader) filenames = [str(pathlib.Path(row["filename"]).name) for row in rows] assert sorted(filenames) == sorted(UUID_REPORT[0]["filenames"]) # test report gets overwritten result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--uuid", UUID_REPORT[1]["uuid"], "--report", "report.csv", ], ) assert result.exit_code == 0 with open("report.csv", "r") as f: reader = csv.DictReader(f) rows = list(reader) filenames = [str(pathlib.Path(row["filename"]).name) for row in rows] assert sorted(filenames) == sorted(UUID_REPORT[1]["filenames"]) # test report with --append result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--uuid", UUID_REPORT[0]["uuid"], "--report", "report.csv", "--overwrite", "--append", ], ) assert result.exit_code == 0 with open("report.csv", "r") as f: reader = csv.DictReader(f) rows = list(reader) filenames = [str(pathlib.Path(row["filename"]).name) for row in rows] assert sorted(filenames) == sorted( UUID_REPORT[0]["filenames"] + UUID_REPORT[1]["filenames"] ) def test_export_report_json(): """test export with --report option for JSON report""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # test report creation result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--uuid", UUID_REPORT[0]["uuid"], "--report", "report.json", ], ) assert result.exit_code == 0 assert "Wrote export report" in result.output assert os.path.exists("report.json") with open("report.json", "r") as f: rows = json.load(f) filenames = [str(pathlib.Path(row["filename"]).name) for row in rows] assert sorted(filenames) == sorted(UUID_REPORT[0]["filenames"]) # test report gets overwritten result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--uuid", UUID_REPORT[1]["uuid"], "--report", "report.json", ], ) assert result.exit_code == 0 with open("report.json", "r") as f: rows = json.load(f) filenames = [str(pathlib.Path(row["filename"]).name) for row in rows] assert sorted(filenames) == sorted(UUID_REPORT[1]["filenames"]) # test report with --append result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--uuid", UUID_REPORT[0]["uuid"], "--report", "report.json", "--overwrite", "--append", ], ) assert result.exit_code == 0 with open("report.json", "r") as f: rows = json.load(f) filenames = [str(pathlib.Path(row["filename"]).name) for row in rows] assert sorted(filenames) == sorted( UUID_REPORT[0]["filenames"] + UUID_REPORT[1]["filenames"] ) @pytest.mark.parametrize("report_file", ["report.db", "report.sqlite"]) def test_export_report_sqlite(report_file): """test export with --report option with sqlite report""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # test report creation result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--uuid", UUID_REPORT[0]["uuid"], "--report", report_file, ], ) assert result.exit_code == 0 assert "Wrote export report" in result.output assert os.path.exists(report_file) conn = sqlite3.connect(report_file) c = conn.cursor() c.execute("SELECT filename FROM report") filenames = [str(pathlib.Path(row[0]).name) for row in c.fetchall()] assert sorted(filenames) == sorted(UUID_REPORT[0]["filenames"]) # test report gets overwritten result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--uuid", UUID_REPORT[1]["uuid"], "--report", report_file, ], ) assert result.exit_code == 0 conn = sqlite3.connect(report_file) c = conn.cursor() c.execute("SELECT filename FROM report") filenames = [str(pathlib.Path(row[0]).name) for row in c.fetchall()] assert sorted(filenames) == sorted(UUID_REPORT[1]["filenames"]) # test report with --append result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--uuid", UUID_REPORT[0]["uuid"], "--report", report_file, "--overwrite", "--append", ], ) assert result.exit_code == 0 conn = sqlite3.connect(report_file) c = conn.cursor() c.execute("SELECT filename FROM report") filenames = [str(pathlib.Path(row[0]).name) for row in c.fetchall()] assert sorted(filenames) == sorted( UUID_REPORT[0]["filenames"] + UUID_REPORT[1]["filenames"] ) def test_export_report_template(): """test export with --report option with a template for report name""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "report_{osxphotos_version}.csv", ], ) assert result.exit_code == 0 assert "Wrote export report" in result.output assert os.path.exists(f"report_{__version__}.csv") def test_export_report_not_a_file(): """test export with --report option and bad report value""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "."] ) assert result.exit_code != 0 assert "is a directory, must be file name" in result.output def test_export_as_hardlink_download_missing(): """test export with incompatible export options""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--download-missing", "--export-as-hardlink", ".", ], ) assert result.exit_code != 0 assert "Incompatible export options" in result.output def test_export_missing(): """test export with --missing""" # note this won't actually export the missing images since they are not in the test db # but it will test the code path by attempting to do the export runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--missing", "--dry-run", "--download-missing", ".", ], ) assert result.exit_code == 0 assert f"Exporting {PHOTOS_MISSING_15_7} photos" in result.output def test_export_missing_not_download_missing(): """test export with incompatible export options""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--missing", "--dry-run", ".", ], ) assert result.exit_code != 0 assert "Incompatible export options" in result.output def test_export_not_missing(): """test export with --not-missing""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--not-missing", "--dry-run"], ) assert result.exit_code == 0 assert f"Exporting {PHOTOS_NOT_MISSING_15_7} photos" in result.output def test_export_cleanup(): """test export with --cleanup flag""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 # create 2 files and a directory with open("delete_me.txt", "w") as fd: fd.write("delete me!") os.mkdir("./foo") with open("foo/delete_me_too.txt", "w") as fd: fd.write("delete me too!") assert pathlib.Path("./delete_me.txt").is_file() # run cleanup with dry-run result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup", "--dry-run", ], ) assert result.exit_code == 0 assert "Deleted: 2 files, 0 directories" in result.output assert pathlib.Path("./delete_me.txt").is_file() assert pathlib.Path("./foo/delete_me_too.txt").is_file() # run cleanup without dry-run result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup"], ) assert "Deleted: 2 files, 1 directory" in result.output assert not pathlib.Path("./delete_me.txt").is_file() assert not pathlib.Path("./foo/delete_me_too.txt").is_file() def test_export_cleanup_report(): """test export with --cleanup flag with --report in the export dir (#739)""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 tmpdir = os.getcwd() # run cleanup without dry-run result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup", "--report", f"{tmpdir}/report.db", ], ) assert "Deleted: 0 files, 0 directories" in result.output assert pathlib.Path("./report.db").is_file() def test_export_cleanup_empty_album(): """test export with --cleanup flag with an empty album (#481)""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 # run cleanup with dry-run with tempfile.TemporaryDirectory() as tempdir: result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), tempdir, "-V", "--uuid", UUID_LOCATION, ], ) # run cleanup with an empty folder result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), tempdir, "-V", "--update", "--cleanup", "--album", "EmptyAlbum", ], ) assert "Did not find any photos to export" in result.output assert "Deleted: 1 file" in result.output def test_export_cleanup_accented_album_name(): """test export with --cleanup flag and photos in album with accented unicode characters (#561, #618)""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with tempfile.TemporaryDirectory() as tempdir: result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), tempdir, "-V", "--update", "--cleanup", "--directory", "{folder_album}", ], ) assert "Deleted: 0 files, 0 directories" in result.output # do it again result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), tempdir, "-V", "--update", "--cleanup", "--directory", "{folder_album}", "--update", ], ) assert "exported: 0, updated: 0" in result.output assert "Deleted: 0 files, 0 directories" in result.output @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_cleanup_exiftool_accented_album_name_same_filenames(): """test export with --cleanup flag and photos in album with accented unicode characters (#561, #618)""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with tempfile.TemporaryDirectory() as report_dir: # keep report file out of of expor dir for --cleanup report_file = os.path.join(report_dir, "test.csv") with tempfile.TemporaryDirectory() as tempdir: result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), tempdir, "-V", "--cleanup", "--directory", "{album[/,.|:,.]}", "--exiftool", "--exiftool-merge-keywords", "--exiftool-merge-persons", "--keyword-template", "{keyword}", "--report", report_file, "--skip-original-if-edited", "--update", "--touch-file", "--not-hidden", ], ) assert "Deleted: 0 files, 0 directories" in result.output # do it again result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), tempdir, "-V", "--cleanup", "--directory", "{album[/,.|:,.]}", "--exiftool", "--exiftool-merge-keywords", "--exiftool-merge-persons", "--keyword-template", "{keyword}", "--report", report_file, "--skip-original-if-edited", "--update", "--touch-file", "--not-hidden", ], ) assert "exported: 0, updated: 0" in result.output assert "updated EXIF data: 0" in result.output assert "Deleted: 0 files, 0 directories" in result.output def test_export_cleanup_keep(): """test export with --cleanup --keep options""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): tmpdir = os.getcwd() result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 # create file and a directory that should be deleted os.mkdir("./empty_dir") os.mkdir("./delete_me_dir") with open("./delete_me.txt", "w") as fd: fd.write("delete me!") with open("./delete_me_dir/delete_me.txt", "w") as fd: fd.write("delete me!") # create files and directories that should be kept os.mkdir("./keep_me") os.mkdir("./keep_me/keep_me_2") with open("./keep_me.txt", "w") as fd: fd.write("keep me!") with open("./report.db", "w") as fd: fd.write("keep me!") with open("./keep_me/keep_me.txt", "w") as fd: fd.write("keep me") # run cleanup with dry-run result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup", "--keep", f"{tmpdir}/keep_me", "--keep", f"{tmpdir}/keep_me.txt", "--keep", f"{tmpdir}/*.db", "--dry-run", ], ) assert "Deleted: 2 files, 1 directory" in result.output assert pathlib.Path("./delete_me.txt").is_file() assert pathlib.Path("./delete_me_dir/delete_me.txt").is_file() assert pathlib.Path("./empty_dir").is_dir() # run cleanup without dry-run result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup", "--keep", f"{tmpdir}/keep_me", "--keep", f"{tmpdir}/keep_me.txt", "--keep", f"{tmpdir}/*.db", ], ) assert "Deleted: 2 files, 2 directories" in result.output assert not pathlib.Path("./delete_me.txt").is_file() assert not pathlib.Path("./delete_me_dir/delete_me_too.txt").is_file() assert not pathlib.Path("./empty_dir").is_dir() assert pathlib.Path("./keep_me.txt").is_file() assert pathlib.Path("./keep_me").is_dir() assert pathlib.Path("./keep_me/keep_me.txt").is_file() assert pathlib.Path("./keep_me/keep_me_2").is_dir() assert pathlib.Path("./report.db").is_file() def test_export_cleanup_keep_relative_path(): """test export with --cleanup --keep options with relative paths""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 # create file and a directory that should be deleted os.mkdir("./empty_dir") os.mkdir("./delete_me_dir") with open("./delete_me.txt", "w") as fd: fd.write("delete me!") with open("./delete_me_dir/delete_me.txt", "w") as fd: fd.write("delete me!") # create files and directories that should be kept os.mkdir("./keep_me") os.mkdir("./keep_me/keep_me_2") with open("./keep_me.txt", "w") as fd: fd.write("keep me!") with open("./report.db", "w") as fd: fd.write("keep me!") with open("./keep_me/keep_me.txt", "w") as fd: fd.write("keep me") # run cleanup with dry-run result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup", "--keep", "keep_me", "--keep", "keep_me.txt", "--keep", "*.db", "--dry-run", ], ) assert "Deleted: 2 files, 1 directory" in result.output assert pathlib.Path("./delete_me.txt").is_file() assert pathlib.Path("./delete_me_dir/delete_me.txt").is_file() assert pathlib.Path("./empty_dir").is_dir() # run cleanup without dry-run result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup", "--keep", "keep_me", "--keep", "keep_me.txt", "--keep", "*.db", ], ) assert "Deleted: 2 files, 2 directories" in result.output assert not pathlib.Path("./delete_me.txt").is_file() assert not pathlib.Path("./delete_me_dir/delete_me_too.txt").is_file() assert not pathlib.Path("./empty_dir").is_dir() assert pathlib.Path("./keep_me.txt").is_file() assert pathlib.Path("./keep_me").is_dir() assert pathlib.Path("./keep_me/keep_me.txt").is_file() assert pathlib.Path("./keep_me/keep_me_2").is_dir() assert pathlib.Path("./report.db").is_file() def test_export_cleanup_exportdb_report(): """test export with --cleanup flag results show in exportdb --report""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 # create 2 files and a directory with open("delete_me.txt", "w") as fd: fd.write("delete me!") os.mkdir("./foo") with open("foo/delete_me_too.txt", "w") as fd: fd.write("delete me too!") assert pathlib.Path("./delete_me.txt").is_file() results = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup"], ) assert "Deleted: 2 files, 1 directory" in results.output assert not pathlib.Path("./delete_me.txt").is_file() assert not pathlib.Path("./foo/delete_me_too.txt").is_file() results = runner.invoke( exportdb, [".", "--report", "report.json", "0"], ) assert results.exit_code == 0 with open("report.json", "r") as fd: report = json.load(fd) deleted_dirs = [x for x in report if x["cleanup_deleted_directory"]] deleted_files = [x for x in report if x["cleanup_deleted_file"]] assert len(deleted_dirs) == 1 assert len(deleted_files) == 2 def test_save_load_config(): """test --save-config, --load-config""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # test save config file result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--sidecar", "XMP", "--touch-file", "--update", "--save-config", "config.toml", ], ) assert result.exit_code == 0 assert "Saving options to config file" in result.output files = glob.glob("*") assert "config.toml" in files # test load config file result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--load-config", "config.toml", ], ) assert result.exit_code == 0 assert "Loaded options from file" in result.output assert "Skipped up to date XMP sidecar" in result.output # test overwrite existing config file result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--sidecar", "XMP", "--touch-file", "--not-live", "--update", "--save-config", "config.toml", ], ) assert result.exit_code == 0 assert "Saving options to config file" in result.output files = glob.glob("*") assert "config.toml" in files # test load config file with incompat command line option result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--load-config", "config.toml", "--live", ], ) assert result.exit_code != 0 assert "Incompatible export options" in result.output # test load config file with command line override result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--load-config", "config.toml", "--sidecar", "json", ], ) assert result.exit_code == 0 assert "Writing JSON sidecar" in result.output assert "Writing XMP sidecar" not in result.output def test_config_only(): """test --save-config, --config-only""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): # test save config file result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--sidecar", "XMP", "--touch-file", "--update", "--save-config", "config.toml", "--config-only", ], ) assert result.exit_code == 0 assert "Saved config file" in result.output assert "Processed:" not in result.output files = glob.glob("*") 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""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ "--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) files = glob.glob("*") assert "export.db" in files result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "export.db", "--update", ], ) assert result.exit_code == 0 assert re.search(r"Using export database.*export\.db", result.output) # export again w/o --exportdb result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"]) assert result.exit_code == 0 assert re.search( r"Created export database.*\.osxphotos_export\.db", result.output ) files = glob.glob(".*") assert ".osxphotos_export.db" in files # now try again with --exportdb, should generate warning result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "export.db"], ) assert result.exit_code == 0 assert ( "Warning: export database is 'export.db' but found '.osxphotos_export.db'" in result.output ) def test_export_exportdb_ramdb(): """test --exportdb --ramdb""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "export.db", "--ramdb", ], ) assert result.exit_code == 0 assert re.search(r"Created export database.*export\.db", result.output) files = glob.glob("*") assert "export.db" in files result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "export.db", "--update", "--ramdb", ], ) assert result.exit_code == 0 assert re.search(r"Using export database.*export\.db", result.output) assert "exported: 0" in result.output def test_export_ramdb(): """test --ramdb""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--ramdb"], ) assert result.exit_code == 0 # run again, update should update no files if db written back to disk result = runner.invoke( export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--ramdb"], ) assert result.exit_code == 0 assert "exported: 0" in result.output # run again without --ramdb, update should update no files if db written back to disk result = runner.invoke( export, [ os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", ], ) assert result.exit_code == 0 assert "exported: 0" in result.output def test_export_finder_tag_keywords_dry_run(): """test --finder-tag-keywords with --dry-run, #958""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_FINDER_TAGS: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--finder-tag-keywords", "--uuid", f"{uuid}", "--dry-run", ], ) assert result.exit_code == 0 def test_export_finder_tag_keywords(): """test --finder-tag-keywords""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_FINDER_TAGS: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--finder-tag-keywords", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"] keywords = [keywords] if type(keywords) != list else keywords expected = [Tag(x, 0) for x in keywords] assert sorted(md.tags) == sorted(expected) # run again with --update, should skip writing extended attributes result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--finder-tag-keywords", "--uuid", f"{uuid}", "--update", ], ) assert result.exit_code == 0 assert "Skipping Finder tags" in result.output md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"] keywords = [keywords] if type(keywords) != list else keywords expected = [Tag(x, 0) for x in keywords] assert sorted(md.tags) == sorted(expected) # clear tags and run again, should update extended attributes md.tags = None result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--finder-tag-keywords", "--uuid", f"{uuid}", "--update", ], ) assert result.exit_code == 0 assert "Writing Finder tags" in result.output md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"] keywords = [keywords] if type(keywords) != list else keywords expected = [Tag(x, 0) for x in keywords] assert sorted(md.tags) == sorted(expected) def test_export_finder_tag_template(): """test --finder-tag-template""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_FINDER_TAGS: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--finder-tag-template", "{person}", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"] keywords = [keywords] if type(keywords) != list else keywords expected = [Tag(x, 0) for x in keywords] assert sorted(md.tags) == sorted(expected) # run again with --update, should skip writing extended attributes result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--finder-tag-template", "{person}", "--uuid", f"{uuid}", "--update", ], ) assert result.exit_code == 0 assert "Skipping Finder tags" in result.output md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"] keywords = [keywords] if type(keywords) != list else keywords expected = [Tag(x, 0) for x in keywords] assert sorted(md.tags) == sorted(expected) # clear tags and run again, should update extended attributes md.tags = None result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--finder-tag-template", "{person}", "--uuid", f"{uuid}", "--update", ], ) assert result.exit_code == 0 assert "Writing Finder tags" in result.output md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"] keywords = [keywords] if type(keywords) != list else keywords expected = [Tag(x, 0) for x in keywords] assert sorted(md.tags) == sorted(expected) def test_export_finder_tag_template_multiple(): """test --finder-tag-template used more than once""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_FINDER_TAGS: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--finder-tag-template", "{keyword}", "--finder-tag-template", "{person}", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"] keywords = [keywords] if type(keywords) != list else keywords persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"] persons = [persons] if type(persons) != list else persons expected = [Tag(x, 0) for x in set(keywords + persons)] assert sorted(md.tags) == sorted(expected) def test_export_finder_tag_template_keywords(): """test --finder-tag-template with --finder-tag-keywords""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_FINDER_TAGS: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--finder-tag-keywords", "--finder-tag-template", "{person}", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"] keywords = [keywords] if type(keywords) != list else keywords persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"] persons = [persons] if type(persons) != list else persons expected = [Tag(x, 0) for x in set(keywords + persons)] assert sorted(md.tags) == sorted(expected) def test_export_finder_tag_template_multi_field(): """test --finder-tag-template with multiple fields (issue #422)""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in CLI_FINDER_TAGS: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--finder-tag-template", "{title};{descr}", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) title = CLI_FINDER_TAGS[uuid]["XMP:Title"] or "" descr = CLI_FINDER_TAGS[uuid]["XMP:Description"] or "" expected = [Tag(f"{title};{descr}", 0)] assert sorted(md.tags) == sorted(expected) def test_export_xattr_template_dry_run(): """test --xattr template with --dry-run, #958""" # Note: this test does not actually test that the metadata attributes get correctly # written by osxmetadata as osxmetadata doesn't work reliably when run by pytest # (but does appear to work correctly in practice) # Reference: https://github.com/RhetTbull/osxmetadata/issues/68 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): test_dir = os.getcwd() for uuid in CLI_FINDER_TAGS: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--xattr-template", "copyright", "osxphotos 2022", "--xattr-template", "comment", "{title};{descr}", "--uuid", f"{uuid}", "--dry-run", ], ) assert result.exit_code == 0 assert "Writing extended attribute" in result.output def test_export_xattr_template(): """test --xattr template""" # Note: this test does not actually test that the metadata attributes get correctly # written by osxmetadata as osxmetadata doesn't work reliably when run by pytest # (but does appear to work correctly in practice) # Reference: https://github.com/RhetTbull/osxmetadata/issues/68 runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): test_dir = os.getcwd() for uuid in CLI_FINDER_TAGS: result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--xattr-template", "copyright", "osxphotos 2022", "--xattr-template", "comment", "{title};{descr}", "--uuid", f"{uuid}", ], ) assert result.exit_code == 0 assert "Writing extended attribute copyright" in result.output assert "Writing extended attribute comment" in result.output # run again with --update, should skip writing extended attributes result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--xattr-template", "copyright", "osxphotos 2022", "--xattr-template", "comment", "{title};{descr}", "--uuid", f"{uuid}", "--update", ], ) assert result.exit_code == 0 # clear tags and run again, should update extended attributes md = OSXMetaData( os.path.join(test_dir, CLI_FINDER_TAGS[uuid]["File:FileName"]) ) md.copyright = None md.comment = None result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--xattr-template", "copyright", "osxphotos 2022", "--xattr-template", "comment", "{title}", "--uuid", f"{uuid}", "--update", ], ) assert result.exit_code == 0 def test_export_jpeg_ext(): """test --jpeg-ext""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid, fileinfo in UUID_JPEGS_DICT.items(): result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid", uuid] ) assert result.exit_code == 0 files = glob.glob("*") filename, ext = fileinfo assert f"{filename}.{ext}" in files for jpeg_ext in ["jpg", "JPG", "jpeg", "JPEG"]: with runner.isolated_filesystem(): for uuid, fileinfo in UUID_JPEGS_DICT.items(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid", uuid, "--jpeg-ext", jpeg_ext, ], ) assert result.exit_code == 0 files = glob.glob("*") filename, ext = fileinfo assert f"{filename}.{jpeg_ext}" in files def test_export_jpeg_ext_not_jpeg(): """test --jpeg-ext with non-jpeg files""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid, fileinfo in UUID_JPEGS_DICT.items(): result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid", uuid] ) assert result.exit_code == 0 files = glob.glob("*") filename, ext = fileinfo assert f"{filename}.{ext}" in files for jpeg_ext in ["jpg", "JPG", "jpeg", "JPEG"]: with runner.isolated_filesystem(): for uuid, fileinfo in UUID_JPEGS_DICT_NOT_JPEG.items(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid", uuid, "--jpeg-ext", jpeg_ext, ], ) assert result.exit_code == 0 files = glob.glob("*") filename, ext = fileinfo assert f"{filename}.{ext}" in files def test_export_jpeg_ext_edited_movie(): """test --jpeg-ext doesn't change extension on edited movie (issue #366)""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid, fileinfo in UUID_MOVIES_NOT_JPEGS_DICT.items(): result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_MOVIES), ".", "-V", "--uuid", uuid] ) assert result.exit_code == 0 files = glob.glob("*") files = [f.lower() for f in files] filename, ext = fileinfo assert f"{filename}_edited.{ext}".lower() in files for jpeg_ext in ["jpg", "JPG", "jpeg", "JPEG"]: with runner.isolated_filesystem(): for uuid, fileinfo in UUID_MOVIES_NOT_JPEGS_DICT.items(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_MOVIES), ".", "-V", "--uuid", uuid, "--jpeg-ext", jpeg_ext, ], ) assert result.exit_code == 0 files = glob.glob("*") files = [f.lower() for f in files] filename, ext = fileinfo assert f"{filename}_edited.{jpeg_ext}".lower() not in files assert f"{filename}_edited.{ext}".lower() in files @pytest.mark.skipif( "OSXPHOTOS_TEST_CONVERT" not in os.environ, reason="Skip if running in Github actions, no GPU.", ) def test_export_jpeg_ext_convert_to_jpeg(): """test --jpeg-ext with --convert-to-jpeg""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid, filename in UUID_HEIC.items(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid", uuid, "--convert-to-jpeg", "--jpeg-ext", "jpg", ], ) assert result.exit_code == 0 files = glob.glob("*") assert f"{filename}.jpg" in files @pytest.mark.skipif( "OSXPHOTOS_TEST_CONVERT" not in os.environ, reason="Skip if running in Github actions, no GPU.", ) def test_export_jpeg_ext_convert_to_jpeg_movie(): """test --jpeg-ext with --convert-to-jpeg and a movie, shouldn't convert or change extensions, #366""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid, fileinfo in UUID_MOVIES_NOT_JPEGS_DICT.items(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_MOVIES), ".", "-V", "--uuid", uuid, "--convert-to-jpeg", "--jpeg-ext", "jpg", ], ) assert result.exit_code == 0 files = glob.glob("*") files = [f.lower() for f in files] filename, ext = fileinfo assert f"{filename}.jpg".lower() not in files assert f"{filename}.{ext}".lower() in files assert f"{filename}_edited.{ext}".lower() in files @pytest.mark.skipif( "OSXPHOTOS_TEST_EXPORT_V2" not in os.environ, reason="Skip if not running on author's personal library.", ) def test_export_burst_folder_album(local_photosdb): """test non-selected burst photos are exported with the album their key photo is in, issue #401""" runner = CliRunner() cwd = os.getcwd() photos = local_photosdb.query( osxphotos.QueryOptions(description=["osxphotos:test_export_burst_folder_album"]) ) for photo in photos: with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, LOCAL_PHOTOSDB), ".", "-V", "--directory", "{folder_album}", "--uuid", photo.uuid, "--download-missing", "--use-photokit", ], ) assert result.exit_code == 0 files = [str(p) for p in pathlib.Path(".").glob("**/*.JPG")] expected = [] for p in [photo, *photo.burst_photos]: paths, _ = p.render_template("{folder_album}/{photo.original_filename}") expected.extend(paths) assert sorted(files) == sorted(expected) @pytest.mark.skipif( "OSXPHOTOS_TEST_EXPORT_V2" not in os.environ, reason="Skip if not running on author's personal library.", ) def test_export_burst_uuid(local_photosdb: osxphotos.PhotosDB): """test non-selected burst photos are exported when image is specified by --uuid, #640""" runner = CliRunner() cwd = os.getcwd() photos = local_photosdb.query( osxphotos.QueryOptions(description=["osxphotos:test_export_burst_uuid"]) ) for photo in photos: with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, LOCAL_PHOTOSDB), ".", "-V", "--uuid", photo.uuid, ], ) assert result.exit_code == 0 expected = len(photo.burst_photos) + 1 assert f"exported: {expected}" in result.output # export again with --skip-bursts result = runner.invoke( export, [ os.path.join(cwd, LOCAL_PHOTOSDB), ".", "-V", "--uuid", photo.uuid, "--skip-bursts", ], ) assert result.exit_code == 0 assert f"exported: 1" in result.output @pytest.mark.skipif( "OSXPHOTOS_TEST_EXPORT" not in os.environ, reason="Skip if not running on author's personal library.", ) def test_export_download_missing_file_exists(): """test --download-missing with file exists and --update, issue #456""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, LOCAL_PHOTOSDB), ".", "-V", "--uuid", UUID_DOWNLOAD_MISSING, "--download-missing", "--use-photos-export", ], ) assert result.exit_code == 0 # export again with --update result = runner.invoke( export, [ os.path.join(cwd, LOCAL_PHOTOSDB), ".", "-V", "--uuid", UUID_DOWNLOAD_MISSING, "--download-missing", "--use-photos-export", "--update", ], ) assert result.exit_code == 0 assert "skipped: 1" in result.output @pytest.mark.skipif( "OSXPHOTOS_TEST_EXPORT" not in os.environ, reason="Skip if not running on author's personal library.", ) def test_export_download_missing_preview(): """test --download-missing --preview, #564""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, LOCAL_PHOTOSDB), ".", "-V", "--uuid", UUID_DOWNLOAD_MISSING, "--download-missing", "--use-photos-export", "--use-photokit", "--preview", ], ) assert result.exit_code == 0 assert "exported: 2" in result.output @pytest.mark.skipif( "OSXPHOTOS_TEST_EXPORT" not in os.environ, reason="Skip if not running on author's personal library.", ) def test_export_download_missing_preview_applescript(): """test --download-missing --preview and applescript download, #564""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, LOCAL_PHOTOSDB), ".", "-V", "--uuid", UUID_DOWNLOAD_MISSING, "--download-missing", "--use-photos-export", "--preview", ], ) assert result.exit_code == 0 assert "exported: 2" in result.output @pytest.mark.skipif( "OSXPHOTOS_TEST_EXPORT" not in os.environ, reason="Skip if not running on author's personal library.", ) def test_export_skip_live_photokit(): """test that --skip-live works with --use-photokit (issue #537)""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager for uuid in UUID_SKIP_LIVE_PHOTOKIT: with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, LOCAL_PHOTOSDB), ".", "-V", "--uuid", uuid, "--use-photos-export", "--use-photokit", "--skip-live", "--skip-original-if-edited", "--convert-to-jpeg", ], ) assert result.exit_code == 0 files = [str(p) for p in pathlib.Path(".").glob("IMG*")] assert sorted(files) == sorted(UUID_SKIP_LIVE_PHOTOKIT[uuid]) def test_query_name(): """test query --name""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--name", "DSC03584"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 assert json_got[0]["original_filename"] == "DSC03584.dng" def test_query_name_unicode(): """test query --name with a unicode name""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--name", "Frítest"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 4 assert normalize_unicode(json_got[0]["original_filename"]).startswith( normalize_unicode("Frítest.jpg") ) def test_query_name_i(): """test query --name -i""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--name", "dsc03584", "-i", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 assert json_got[0]["original_filename"] == "DSC03584.dng" def test_query_name_original_filename(): """test query --name only searches original filename on Photos 5+""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--name", "AA"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 4 def test_query_name_original_filename_i(): """test query --name only searches original filename on Photos 5+ with -i""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--name", "aa", "-i"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 4 def test_export_name(): """test export --name""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--name", "DSC03584"] ) assert result.exit_code == 0 files = glob.glob("*") assert len(files) == 1 def test_query_eval(): """test export --query-eval""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--query-eval", "'DSC03584' in photo.original_filename", ], ) assert result.exit_code == 0 files = glob.glob("*") assert len(files) == 1 def test_bad_query_eval(): """test export --query-eval with bad input""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( export, [ os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--query-eval", "'DSC03584' in photo.originalfilename", ], ) assert result.exit_code != 0 assert "Invalid query-eval CRITERIA" in result.output def test_query_min_size_1(): """test query --min-size""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--min-size", "10MB"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 4 def test_query_min_size_2(): """test query --min-size""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--min-size", "10_000_000", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 4 def test_query_max_size_1(): """test query --max-size""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--max-size", "500 kB"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 3 def test_query_max_size_2(): """test query --max-size""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--max-size", "500_000"], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 3 def test_query_min_max_size(): """test query --max-size with --min-size""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--min-size", "48MB", "--max-size", "49MB", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 def test_query_min_size_error(): """test query --max-size with invalid size""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--min-size", "500 foo"], ) assert result.exit_code != 0 def test_query_regex_1(): """test query --regex against title""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--regex", "I found", "{title}", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 def test_query_regex_2(): """test query --regex with no match""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--regex", "{title}", "i Found", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 0 def test_query_regex_3(): """test query --regex with --ignore-case""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--regex", "i Found", "{title}", "--ignore-case", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 def test_query_regex_4(): """test query --regex against album""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--regex", "^Test", "{album}", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 2 def test_query_regex_multiple(): """test query multiple --regex values (#525)""" runner = CliRunner() cwd = os.getcwd() result = runner.invoke( query, [ "--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--regex", "I found", "{title}", "--regex", "carry", "{title}", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 2 def test_query_function(): """test query --query-function""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): with open("query1.py", "w") as f: f.writelines( [ "def query(photos):\n", " return [p for p in photos if 'DSC03584' in p.original_filename]", ] ) tmpdir = os.getcwd() result = runner.invoke( query, [ "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--query-function", f"{tmpdir}/query1.py::query", "--json", ], ) assert result.exit_code == 0 json_got = json.loads(result.output) assert len(json_got) == 1 assert json_got[0]["original_filename"] == "DSC03584.dng" def test_query_added_after(): """test query --added-after""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager results = runner.invoke( query, [ "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json", "--added-after", "2022-02-03", ], ) assert results.exit_code == 0 json_got = json.loads(results.output) assert len(json_got) == 4 def test_query_added_before(): """test query --added-before""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager results = runner.invoke( query, [ "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--json", "--added-before", "2019-07-28", ], ) assert results.exit_code == 0 json_got = json.loads(results.output) assert len(json_got) == 7 def test_query_added_in_last(): """test query --added-in-last""" # Note: ideally, I'd test this with freezegun to verify the time deltas worked but # freezegun causes osxphotos tests to crash so we just test that the --added-in-last runs without error runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager results = runner.invoke( query, [ os.path.join(cwd, PHOTOS_DB_15_7), "--json", "--added-in-last", "10 years", ], ) assert results.exit_code == 0 def test_export_export_dir_template(): """Test {export_dir} template""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): isolated_cwd = os.getcwd() result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--sidecar=json", f"--uuid={CLI_UUID_DICT_15_7['template']}", "-V", "--keyword-template", "{person}", "--description-template", "{export_dir}", ], ) assert result.exit_code == 0 assert os.path.isfile(CLI_TEMPLATE_SIDECAR_FILENAME) with open(CLI_TEMPLATE_SIDECAR_FILENAME, "r") as jsonfile: exifdata = json.load(jsonfile) assert exifdata[0]["XMP:Description"] == isolated_cwd def test_export_filepath_template(): """Test {filepath} template""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): isolated_cwd = os.getcwd() result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--sidecar=json", f"--uuid={CLI_UUID_DICT_15_7['template']}", "-V", "--keyword-template", "{person}", "--description-template", "{filepath}", ], ) assert result.exit_code == 0 assert os.path.isfile(CLI_TEMPLATE_SIDECAR_FILENAME) with open(CLI_TEMPLATE_SIDECAR_FILENAME, "r") as jsonfile: exifdata = json.load(jsonfile) assert exifdata[0]["XMP:Description"] == os.path.join( isolated_cwd, CLI_TEMPLATE_FILENAME ) def test_export_post_command(): """Test --post-command""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--post-command", "exported", "echo {filepath.name|shell_quote} >> {export_dir}/exported.txt", "--name", "Park", "--skip-original-if-edited", ], ) assert result.exit_code == 0 with open("exported.txt") as f: lines = [line.strip() for line in f] assert lines[0] == "St James Park_edited.jpeg" # run again with --update to test skipped result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--post-command", "skipped", "echo {filepath.name|shell_quote} >> {export_dir}/skipped.txt", "--name", "Park", "--skip-original-if-edited", "--update", ], ) assert result.exit_code == 0 with open("skipped.txt") as f: lines = [line.strip() for line in f] assert lines[0] == "St James Park_edited.jpeg" def test_export_post_command_bad_command(): """Test --post-command with bad command""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--post-command", "exported", "foobar {filepath.name|shell_quote} >> {export_dir}/exported.txt", "--name", "Park", "--skip-original-if-edited", ], ) assert result.exit_code == 0 assert 'Error running command "foobar' in result.output def test_export_post_command_bad_option_1(): """Test --post-command with bad options""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--post-command", "export", # should be "exported" "foobar {filepath.name|shell_quote} >> {export_dir}/exported.txt", "--name", "Park", "--skip-original-if-edited", ], ) assert result.exit_code != 0 assert "Invalid value" in result.output def test_export_post_command_bad_option_2(): """Test --post-command with bad options""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--post-command", "exported", # error in template for command (missing closing curly brace) "foobar {filepath.name|shell_quote >> {export_dir}/exported.txt", "--name", "Park", "--skip-original-if-edited", ], ) assert result.exit_code != 0 assert "Invalid value" in result.output def test_export_post_function(): """Test --post-function""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): with open("foo1.py", "w") as f: f.writelines( ["def foo(photo, results, verbose):\n", " verbose('FOO BAR')\n"] ) tempdir = os.getcwd() result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--post-function", f"{tempdir}/foo1.py::foo", "--name", "Park", "--skip-original-if-edited", "-V", ], ) assert result.exit_code == 0 assert "FOO BAR" in result.output def test_export_post_function_exception(): """Test --post-function that generates an exception""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): with open("bar1.py", "w") as f: f.writelines( [ "def bar(photo, results, verbose):\n", " raise ValueError('Argh!')\n", ] ) tempdir = os.getcwd() result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--post-function", f"{tempdir}/bar1.py::bar", "--name", "Park", "--skip-original-if-edited", "-V", ], ) assert result.exit_code == 0 assert "Error running post-function" in result.output def test_export_post_function_bad_value(): """Test --post-function option validation""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): with open("foo2.py", "w") as f: f.writelines( [ "def foo(photo, results, verbose):\n", " raise ValueError('Argh!')\n", ] ) tempdir = os.getcwd() result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--post-function", f"{tempdir}/foo2.py::bar", "--name", "Park", "--skip-original-if-edited", "-V", ], ) assert result.exit_code != 0 assert "Could not load function" in result.output def test_export_directory_template_function(): """Test --directory with template function""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): with open("foo3.py", "w") as f: f.writelines(["def foo(photo, **kwargs):\n", " return 'foo/bar'"]) tempdir = os.getcwd() result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid", CLI_EXPORT_UUID, "--directory", "{function:" + f"{tempdir}" + "/foo3.py::foo}", ], ) assert result.exit_code == 0 assert pathlib.Path(f"foo/bar/{CLI_EXPORT_UUID_FILENAME}").is_file() def test_export_query_function(): """Test --query-function""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): with open("query2.py", "w") as f: f.writelines( [ "def query(photos):\n", " return [p for p in photos if p.title and 'Tulips' in p.title]\n", ] ) tempdir = os.getcwd() result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--query-function", f"{tempdir}/query2.py::query", "-V", "--skip-edited", ], ) assert result.exit_code == 0 assert "exported: 1" in result.output def test_export_album_seq(): """Test {album_seq} template""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): for uuid in UUID_DICT_FOLDER_ALBUM_SEQ: result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--album", UUID_DICT_FOLDER_ALBUM_SEQ[uuid]["album"], "--directory", UUID_DICT_FOLDER_ALBUM_SEQ[uuid]["directory"], "--filename", UUID_DICT_FOLDER_ALBUM_SEQ[uuid]["filename"], "--uuid", uuid, ], ) assert result.exit_code == 0 files = glob.glob(f"{UUID_DICT_FOLDER_ALBUM_SEQ[uuid]['album']}/*") assert ( f"{UUID_DICT_FOLDER_ALBUM_SEQ[uuid]['album']}/{UUID_DICT_FOLDER_ALBUM_SEQ[uuid]['result']}" in files ) @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_description_template(): """Test for issue #506""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--sidecar=json", f"--uuid={UUID_EMPTY_TITLE}", "-V", "--description-template", DESCRIPTION_TEMPLATE_EMPTY_TITLE, "--exiftool", ], ) assert result.exit_code == 0 exif = ExifTool(FILENAME_EMPTY_TITLE).asdict() assert exif["EXIF:ImageDescription"] == DESCRIPTION_VALUE_EMPTY_TITLE def test_export_description_template_conditional(): """Test for issue #506""" runner = CliRunner() cwd = os.getcwd() # pylint: disable=not-context-manager with runner.isolated_filesystem(): result = runner.invoke( cli_main, [ "export", "--db", os.path.join(cwd, PHOTOS_DB_15_7), ".", "--sidecar=json", f"--uuid={UUID_EMPTY_TITLE}", "-V", "--description-template", DESCRIPTION_TEMPLATE_TITLE_CONDITIONAL, "--sidecar", "JSON", ], ) assert result.exit_code == 0 with open(f"{FILENAME_EMPTY_TITLE}.json", "r") as fp: json_got = json.load(fp)[0] assert ( json_got["EXIF:ImageDescription"] == DESCRIPTION_VALUE_TITLE_CONDITIONAL ) def test_export_min_size_1(): """test export --min-size""" runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( export, [".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--min-size", "10MB"], ) assert result.exit_code == 0 assert "Exporting 4 photos" in result.output def test_export_validate_template_1(): """ "Test CLI validation of template arguments""" runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( export, [ ".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--filename", "{original_names}", ], ) assert result.exit_code != 0 assert "Invalid value" in result.output def test_export_validate_template_2(): """ "Test CLI validation of template arguments""" runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( export, [ ".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--filename", "{original_name", ], ) assert result.exit_code != 0 assert "Invalid value" in result.output def test_theme_list(): """Test theme --list command""" runner = CliRunner() temp_file = tempfile.TemporaryFile() with runner.isolated_filesystem(): result = runner.invoke(cli_main, ["theme", "--list"]) assert result.exit_code == 0 assert "Dark" in result.output def test_export_added_after(): """test export --added-after""" runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( export, [ ".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--added-after", "2022-02-03", ], ) assert result.exit_code == 0 assert "Exporting 4 photos" in result.output def test_export_added_before(): """test export --added-before""" runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( export, [ ".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--added-before", "2019-07-28", ], ) assert result.exit_code == 0 assert "Exporting 7 photos" in result.output def test_export_added_in_last(): """test export --added-in-last""" # can't use freezegun as it causes osxphotos tests to crash so # just run export with --added-in-last and verify no errors runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( export, [ ".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--added-in-last", "10 years", ], ) assert result.exit_code == 0 assert "Exporting" in result.output def test_export_limit(): """test export --limit""" # Use --added-before so test doesn't break if photos added in the future runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( export, [ ".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--update", "--limit", "20", "--added-before", "2022-05-07", ], ) assert result.exit_code == 0 assert "limit: 20/20 exported" in result.output result = runner.invoke( export, [ ".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--update", "--limit", "20", "--added-before", "2022-05-07", ], ) assert result.exit_code == 0 assert "limit: 5/20 exported" in result.output result = runner.invoke( export, [ ".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--update", "--limit", "20", "--added-before", "2022-05-07", ], ) assert result.exit_code == 0 assert "limit: 0/20 exported" in result.output def test_export_no_keyword(): """test export --no-keyword""" runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( export, [ ".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--no-keyword", "--added-before", "2022-05-05", ], ) assert result.exit_code == 0 assert "Exporting 11" in result.output def test_export_print(): """test export --print""" runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( export, [ ".", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--print", "uuid: {uuid}", "--uuid", UUID_FAVORITE, ], ) assert result.exit_code == 0 assert f"uuid: {UUID_FAVORITE}" in result.output def test_query_print_quiet(): """test query --print""" runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( query, [ "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--print", "uuid: {uuid}", "--uuid", UUID_FAVORITE, "--quiet", ], ) assert result.exit_code == 0 assert result.output.strip() == f"uuid: {UUID_FAVORITE}" def test_query_field(): """test query --field""" runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( query, [ "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--field", "uuid", "{uuid}", "--field", "name", "{photo.original_filename}", "--uuid", UUID_FAVORITE, ], ) assert result.exit_code == 0 assert result.output.strip() == f"uuid,name\n{UUID_FAVORITE},{FILE_FAVORITE}" def test_query_field_json(): """test query --field --json""" runner = CliRunner() cwd = os.getcwd() with runner.isolated_filesystem(): result = runner.invoke( query, [ "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--field", "uuid", "{uuid}", "--field", "name", "{photo.original_filename}", "--uuid", UUID_FAVORITE, "--json", ], ) assert result.exit_code == 0 json_results = json.loads(result.output) assert json_results[0]["uuid"] == UUID_FAVORITE assert json_results[0]["name"] == FILE_FAVORITE