Compare commits

..

5 Commits

Author SHA1 Message Date
Rhet Turnbull
846ea89012 Added PyCharm .idea/ 2021-04-25 18:53:31 -07:00
Rhet Turnbull
dc0bbd5fd6 Updated docs 2021-04-25 18:49:51 -07:00
Rhet Turnbull
91804d53ea Added read-only ExifToolCaching class, to implement #325 2021-04-25 18:44:48 -07:00
Rhet Turnbull
3d26206d91 Added normalized flag to ExifTool.asdict() 2021-04-25 08:31:08 -07:00
Rhet Turnbull
92d9dfaef2 Updated CHANGELOG.md, [skip ci] 2021-04-24 22:49:41 -07:00
18 changed files with 436 additions and 16 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ __pycache__
t.out
.vscode/
.tox/
.idea/
dist/
build/
working/

View File

@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.42.12](https://github.com/RhetTbull/osxphotos/compare/v0.42.11...v0.42.12)
> 25 April 2021
- Added {edited_version} template field, closes #420 [`#420`](https://github.com/RhetTbull/osxphotos/issues/420)
#### [v0.42.11](https://github.com/RhetTbull/osxphotos/compare/v0.42.9...v0.42.11)
> 25 April 2021

View File

@@ -1,4 +1,4 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 6ac453642da35cd6bd3feb4b51f3691d
config: 09f774bbf0a11a7f854d5b240879b3b4
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overview: module code &#8212; osxphotos 0.42.12 documentation</title>
<title>Overview: module code &#8212; osxphotos 0.42.13 documentation</title>
<link rel="stylesheet" href="../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../" src="../_static/documentation_options.js"></script>

View File

@@ -1,6 +1,6 @@
var DOCUMENTATION_OPTIONS = {
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
VERSION: '0.42.12',
VERSION: '0.42.13',
LANGUAGE: 'None',
COLLAPSE_INDEX: false,
BUILDER: 'html',

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.42.12 documentation</title>
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.42.13 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &#8212; osxphotos 0.42.12 documentation</title>
<title>Index &#8212; osxphotos 0.42.13 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.42.12 documentation</title>
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.42.13 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos &#8212; osxphotos 0.42.12 documentation</title>
<title>osxphotos &#8212; osxphotos 0.42.13 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

Binary file not shown.

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos package &#8212; osxphotos 0.42.12 documentation</title>
<title>osxphotos package &#8212; osxphotos 0.42.13 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; osxphotos 0.42.12 documentation</title>
<title>Search &#8212; osxphotos 0.42.13 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Export your photos &#8212; osxphotos 0.42.12 documentation</title>
<title>Export your photos &#8212; osxphotos 0.42.13 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.42.12"
__version__ = "0.42.13"

View File

@@ -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}"
@@ -308,12 +309,13 @@ class ExifTool:
ver, _, _ = self.run_commands("-ver", no_file=True)
return ver.decode("utf-8")
def asdict(self, tag_groups=True):
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)
"""
json_str, _, _ = self.run_commands("-json")
if not json_str:
@@ -334,6 +336,10 @@ class ExifTool:
k = re.sub(r".*:", "", k)
exif_new[k] = v
exifdict = exif_new
if normalized:
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
return exifdict
def json(self):
@@ -360,3 +366,74 @@ class ExifTool:
elif self._commands:
# run_commands sets self.warning and self.error as needed
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 = {}

View File

@@ -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,9 +1081,8 @@ class PhotoTemplate:
if not self.photo.path:
return []
exif = ExifTool(self.photo.path, exiftool=self.exiftool_path)
exifdict = exif.asdict()
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
exif = ExifToolCaching(self.photo.path, exiftool=self.exiftool_path)
exifdict = exif.asdict(normalized=True)
subfield = subfield.lower()
if subfield in exifdict:
values = exifdict[subfield]

View File

@@ -349,6 +349,15 @@ def test_as_dict():
assert exifdata["XMP:TagsList"] == "wedding"
def test_as_dict_normalized():
import osxphotos.exiftool
exif1 = osxphotos.exiftool.ExifTool(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

View File

@@ -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