From 91804d53eaafddb7bff83b065014099827bb9d43 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sun, 25 Apr 2021 18:44:48 -0700 Subject: [PATCH] Added read-only ExifToolCaching class, to implement #325 --- osxphotos/_version.py | 2 +- osxphotos/exiftool.py | 68 +++++++ osxphotos/phototemplate.py | 4 +- tests/test_exiftool_caching.py | 328 +++++++++++++++++++++++++++++++++ 4 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 tests/test_exiftool_caching.py diff --git a/osxphotos/_version.py b/osxphotos/_version.py index ce98282a..ab6c25e9 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.42.12" +__version__ = "0.42.13" diff --git a/osxphotos/exiftool.py b/osxphotos/exiftool.py index ea55821c..53b4f885 100644 --- a/osxphotos/exiftool.py +++ b/osxphotos/exiftool.py @@ -13,6 +13,7 @@ import re import shutil import subprocess from functools import lru_cache # pylint: disable=syntax-error +from abc import ABC, abstractmethod # exiftool -stay_open commands outputs this EOF marker after command is run EXIFTOOL_STAYOPEN_EOF = "{ready}" @@ -367,5 +368,72 @@ class ExifTool: self.run_commands(*self._commands) +class ExifToolCaching(ExifTool): + """ Basic exiftool interface for reading and writing EXIF tags, with caching. + Use this only when you know the file's EXIF data will not be changed by any external process. + + Creates a singleton cached ExifTool instance """ + + _singletons = {} + + def __new__(cls, filepath, exiftool=None): + """ create new object or return instance of already created singleton """ + if filepath not in cls._singletons: + cls._singletons[filepath] = _ExifToolCaching(filepath, exiftool=exiftool) + return cls._singletons[filepath] +class _ExifToolCaching(ExifTool): + def __init__(self, filepath, exiftool=None): + """Create read-only ExifTool object that caches values + + Args: + file: path to image file + exiftool: path to exiftool, if not specified will look in path + + Returns: + ExifTool instance + """ + self._json_cache = None + self._asdict_cache = {} + super().__init__(filepath, exiftool=exiftool, overwrite=False, flags=None) + + def run_commands(self, *commands, no_file=False): + if commands[0] not in ["-json", "-ver"]: + raise NotImplementedError(f"{self.__class__} is read-only") + return super().run_commands(*commands, no_file=no_file) + + def setvalue(self, tag, value): + raise NotImplementedError(f"{self.__class__} is read-only") + + def addvalues(self, tag, *values): + raise NotImplementedError(f"{self.__class__} is read-only") + + def json(self): + if not self._json_cache: + self._json_cache = super().json() + return self._json_cache + + def asdict(self, tag_groups=True, normalized=False): + """return dictionary of all EXIF tags and values from exiftool + returns empty dict if no tags + + Args: + tag_groups: if True (default), dict keys have tag groups, e.g. "IPTC:Keywords"; if False, drops groups from keys, e.g. "Keywords" + normalized: if True, dict keys are all normalized to lower case (default is False) + """ + try: + return self._asdict_cache[tag_groups][normalized] + except KeyError: + if tag_groups not in self._asdict_cache: + self._asdict_cache[tag_groups] = {} + self._asdict_cache[tag_groups][normalized] = super().asdict( + tag_groups=tag_groups, normalized=normalized + ) + return self._asdict_cache[tag_groups][normalized] + + def flush_cache(self): + """ Clear cached data so that calls to json or asdict return fresh data """ + self._json_cache = None + self._asdict_cache = {} + diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index 11b89d07..9721fb68 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -9,7 +9,7 @@ from textx import TextXSyntaxError, metamodel_from_file from ._constants import _UNKNOWN_PERSON from .datetime_formatter import DateTimeFormatter -from .exiftool import ExifTool +from .exiftool import ExifToolCaching from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart from .utils import load_function @@ -1081,7 +1081,7 @@ class PhotoTemplate: if not self.photo.path: return [] - exif = ExifTool(self.photo.path, exiftool=self.exiftool_path) + exif = ExifToolCaching(self.photo.path, exiftool=self.exiftool_path) exifdict = exif.asdict(normalized=True) subfield = subfield.lower() if subfield in exifdict: diff --git a/tests/test_exiftool_caching.py b/tests/test_exiftool_caching.py new file mode 100644 index 00000000..26c5d7ed --- /dev/null +++ b/tests/test_exiftool_caching.py @@ -0,0 +1,328 @@ +import pytest +from osxphotos.exiftool import get_exiftool_path + +TEST_FILE_ONE_KEYWORD = "tests/test-images/wedding.jpg" +TEST_FILE_BAD_IMAGE = "tests/test-images/badimage.jpeg" +TEST_FILE_WARNING = "tests/test-images/exiftool_warning.heic" +TEST_FILE_MULTI_KEYWORD = "tests/test-images/Tulips.jpg" +TEST_MULTI_KEYWORDS = [ + "Top Shot", + "flowers", + "flower", + "design", + "Stock Photography", + "vibrant", + "plastic", + "Digital Nomad", + "close up", + "stock photo", + "outdoor", + "wedding", + "Reiseblogger", + "fake", + "colorful", + "Indoor", + "display", + "photography", +] + +PHOTOS_DB = "tests/Test-10.15.4.photoslibrary" +EXIF_UUID = { + "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4": { + "EXIF:DateTimeOriginal": "2019:07:04 16:24:01", + "EXIF:LensModel": "XF18-55mmF2.8-4 R LM OIS", + "IPTC:Keywords": [ + "Digital Nomad", + "Indoor", + "Reiseblogger", + "Stock Photography", + "Top Shot", + "close up", + "colorful", + "design", + "display", + "fake", + "flower", + "outdoor", + "photography", + "plastic", + "stock photo", + "vibrant", + ], + "IPTC:DocumentNotes": "https://flickr.com/e/l7FkSm4f2lQkSV3CG6xlv8Sde5uF3gVu4Hf0Qk11AnU%3D", + }, + "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": { + "EXIF:Make": "NIKON CORPORATION", + "EXIF:Model": "NIKON D810", + "IPTC:DateCreated": "2019:04:15", + }, +} +EXIF_UUID_NO_GROUPS = { + "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4": { + "DateTimeOriginal": "2019:07:04 16:24:01", + "LensModel": "XF18-55mmF2.8-4 R LM OIS", + "Keywords": [ + "Digital Nomad", + "Indoor", + "Reiseblogger", + "Stock Photography", + "Top Shot", + "close up", + "colorful", + "design", + "display", + "fake", + "flower", + "outdoor", + "photography", + "plastic", + "stock photo", + "vibrant", + ], + "DocumentNotes": "https://flickr.com/e/l7FkSm4f2lQkSV3CG6xlv8Sde5uF3gVu4Hf0Qk11AnU%3D", + }, + "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": { + "Make": "NIKON CORPORATION", + "Model": "NIKON D810", + "DateCreated": "2019:04:15", + }, +} +EXIF_UUID_NONE = ["A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C"] + +try: + exiftool = get_exiftool_path() +except: + exiftool = None + +if exiftool is None: + pytest.skip("could not find exiftool in path", allow_module_level=True) + + +def test_version(): + import osxphotos.exiftool + + exif = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + assert exif.version is not None + assert isinstance(exif.version, str) + + +def test_singleton(): + """ tests per-file singleton behavior """ + import osxphotos.exiftool + + exif1 = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + exif2 = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + assert exif1 is exif2 + + exif3 = osxphotos.exiftool.ExifToolCaching(TEST_FILE_MULTI_KEYWORD) + assert exif1 is not exif3 + + +def test_read(): + import osxphotos.exiftool + + exif = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + assert exif.data["File:MIMEType"] == "image/jpeg" + assert exif.data["EXIF:ISO"] == 160 + assert exif.data["IPTC:Keywords"] == "wedding" + + +def test_setvalue_1(): + # test setting a tag value + import os.path + import tempfile + import osxphotos.exiftool + from osxphotos.fileutil import FileUtil + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD)) + FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile) + + exif = osxphotos.exiftool.ExifToolCaching(tempfile) + with pytest.raises(NotImplementedError): + exif.setvalue("IPTC:Keywords", "test") + + +def test_setvalue_cache(): + # test setting a tag value doesn't affect cached value + import os.path + import tempfile + import osxphotos.exiftool + from osxphotos.fileutil import FileUtil + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD)) + FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile) + + exif = osxphotos.exiftool.ExifTool(tempfile) + exif.setvalue("IPTC:Keywords", "test") + assert exif.asdict()["IPTC:Keywords"] == "test" + + exifcache = osxphotos.exiftool.ExifToolCaching(tempfile) + assert exifcache.asdict()["IPTC:Keywords"] == "test" + + # now change the value + exif.setvalue("IPTC:Keywords", "foo") + assert exif.asdict()["IPTC:Keywords"] == "foo" + assert exifcache.asdict()["IPTC:Keywords"] == "test" + + exifcache.flush_cache() + assert exifcache.asdict()["IPTC:Keywords"] == "foo" + + +def test_setvalue_context_manager(): + # test setting a tag value as context manager + import os.path + import tempfile + import osxphotos.exiftool + from osxphotos.fileutil import FileUtil + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD)) + FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile) + + with pytest.raises(NotImplementedError): + with osxphotos.exiftool.ExifToolCaching(tempfile) as exif: + exif.setvalue("IPTC:Keywords", "test1") + + +def test_flags(): + # test that flags raise error + import os.path + import tempfile + import osxphotos.exiftool + from osxphotos.fileutil import FileUtil + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_WARNING)) + FileUtil.copy(TEST_FILE_WARNING, tempfile) + + with pytest.raises(TypeError): + # ExifToolCaching doesn't take flags arg + with osxphotos.exiftool.ExifToolCaching(tempfile, flags=["-m"]) as exif: + exif.setvalue("XMP:Subject", "foo/ba r") + + +def test_clear_value(): + # test clearing a tag value + import os.path + import tempfile + import osxphotos.exiftool + from osxphotos.fileutil import FileUtil + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD)) + FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile) + + exif = osxphotos.exiftool.ExifToolCaching(tempfile) + + with pytest.raises(NotImplementedError): + exif.setvalue("IPTC:Keywords", None) + + +def test_addvalues_1(): + # test setting a tag value + import os.path + import tempfile + import osxphotos.exiftool + from osxphotos.fileutil import FileUtil + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD)) + FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile) + + exif = osxphotos.exiftool.ExifToolCaching(tempfile) + with pytest.raises(NotImplementedError): + exif.addvalues("IPTC:Keywords", "test") + + +def test_exiftoolproc_process(): + import osxphotos.exiftool + + exif1 = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + assert exif1._exiftoolproc.process is not None + + +def test_exiftoolproc_exiftool(): + import osxphotos.exiftool + + exif1 = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + assert exif1._exiftoolproc.exiftool == osxphotos.exiftool.get_exiftool_path() + + +def test_as_dict(): + import osxphotos.exiftool + + exif1 = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + exifdata = exif1.asdict() + assert exifdata["XMP:TagsList"] == "wedding" + + +def test_as_dict_normalized(): + import osxphotos.exiftool + + exif1 = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + exifdata = exif1.asdict(normalized=True) + assert exifdata["xmp:tagslist"] == "wedding" + assert "XMP:TagsList" not in exifdata + + +def test_as_dict_no_tag_groups(): + import osxphotos.exiftool + + exif1 = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + exifdata = exif1.asdict(tag_groups=False) + assert exifdata["TagsList"] == "wedding" + + +def test_json(): + import osxphotos.exiftool + import json + + exif1 = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + exifdata = json.loads(exif1.json()) + assert exifdata[0]["XMP:TagsList"] == "wedding" + + +def test_str(): + import osxphotos.exiftool + + exif1 = osxphotos.exiftool.ExifToolCaching(TEST_FILE_ONE_KEYWORD) + assert "file: " in str(exif1) + assert "exiftool: " in str(exif1) + + +def test_photoinfo_exiftool(): + """ test PhotoInfo.exiftool which returns ExifTool object for photo """ + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + for uuid in EXIF_UUID: + photo = photosdb.photos(uuid=[uuid])[0] + exiftool = photo.exiftool + exif_dict = exiftool.asdict() + for key, val in EXIF_UUID[uuid].items(): + assert exif_dict[key] == val + + +def test_photoinfo_exiftool_no_groups(): + """ test PhotoInfo.exiftool which returns ExifTool object for photo without tag group names""" + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + for uuid in EXIF_UUID_NO_GROUPS: + photo = photosdb.photos(uuid=[uuid])[0] + exiftool = photo.exiftool + exif_dict = exiftool.asdict(tag_groups=False) + for key, val in EXIF_UUID_NO_GROUPS[uuid].items(): + assert exif_dict[key] == val + + +def test_photoinfo_exiftool_none(): + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + for uuid in EXIF_UUID_NONE: + photo = photosdb.photos(uuid=[uuid])[0] + exiftool = photo.exiftool + assert exiftool is None