Compare commits

...

30 Commits

Author SHA1 Message Date
Rhet Turnbull
3394c52768 Fixed --exiftool-path bug, issue #311, #313 2020-12-30 20:21:05 -08:00
Rhet Turnbull
27282af3b9 Updated CHANGELOG.md 2020-12-30 14:00:31 -08:00
Rhet Turnbull
b7b06b9fdb Merge pull request #310 from RhetTbull/finder_tags
Added Finder tags, partial implementation for issue #242
2020-12-30 13:52:37 -08:00
Rhet Turnbull
29e424575a Added tests for Finder tags 2020-12-30 13:37:15 -08:00
Rhet Turnbull
ea373c4197 Updated requirements.txt 2020-12-30 08:52:58 -08:00
Rhet Turnbull
f25a299309 Updated README for finder tags 2020-12-30 08:51:01 -08:00
Rhet Turnbull
5885b23d32 Initial implementation for Finder tags 2020-12-30 08:32:42 -08:00
Rhet Turnbull
5dccdf7750 Fixed --exiftool-path bug, issue #308 2020-12-30 07:31:07 -08:00
Rhet Turnbull
e9134f84df Updated CHANGELOG.md 2020-12-29 09:51:18 -08:00
Rhet Turnbull
3872e7ae64 Fixed --exiftool-path to work with --exiftool-merge-keywords/persons 2020-12-29 09:47:03 -08:00
Rhet Turnbull
b3e86dffc8 Updated CHANGELOG.md 2020-12-29 09:46:25 -08:00
Rhet Turnbull
4897fc4b05 Added --exiftool-path to CLI 2020-12-29 09:23:51 -08:00
Rhet Turnbull
1dbf22fdc9 Updated CHANGELOG.md 2020-12-29 08:03:21 -08:00
Rhet Turnbull
fa58af8b88 Added exiftool signature to JSON output, issue #303 2020-12-29 07:51:34 -08:00
Rhet Turnbull
9c9bcb08b3 Updated CHANGELOG.md 2020-12-28 15:42:09 -08:00
Rhet Turnbull
b1cb99f83f Added --exiftool-merge-keywords/persons, issue #299, #292 2020-12-28 15:31:31 -08:00
Rhet Turnbull
d3605f6303 Updated CHANGELOG.md 2020-12-28 11:58:00 -08:00
Rhet Turnbull
dce002cdfe Added --sidecar-drop-ext, issue #291 2020-12-28 11:54:02 -08:00
Rhet Turnbull
7bd189e9b2 Updated Template Substitution table 2020-12-28 09:26:38 -08:00
Rhet Turnbull
baa86c77f6 Updated CHANGELOG.md 2020-12-28 09:17:06 -08:00
Rhet Turnbull
0d086bf851 Added searchinfo templates, issue #302 2020-12-28 09:14:08 -08:00
Rhet Turnbull
ade98fc150 Refactored sidecar code 2020-12-28 08:23:23 -08:00
Rhet Turnbull
0d66759b1c Refactored export2 to use sidecar bit field 2020-12-27 22:45:47 -08:00
Rhet Turnbull
d833c14ef4 Added --sidecar exiftool, issue #303 2020-12-27 22:17:56 -08:00
Rhet Turnbull
34841f86c0 Updated CHANGELOG.md 2020-12-27 09:29:32 -08:00
Rhet Turnbull
4cc40d24cf Bug fix for --description-template, issue #304 2020-12-27 09:26:54 -08:00
Rhet Turnbull
1ccf03e158 Updated CHANGELOG.md 2020-12-27 08:45:49 -08:00
Rhet Turnbull
75888cd663 Set XMP:Subject to match Keywords, issue #302 2020-12-27 08:35:30 -08:00
Rhet Turnbull
a08d0725b9 Updated CHANGELOG.md 2020-12-26 08:36:17 -08:00
Rhet Turnbull
f9f699ba35 Fixed city/sub-locality for SearchInfo 2020-12-26 08:31:14 -08:00
22 changed files with 1569 additions and 477 deletions

View File

@@ -4,6 +4,95 @@ 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.39.0](https://github.com/RhetTbull/osxphotos/compare/v0.38.22...v0.39.0)
> 30 December 2020
- Added Finder tags, partial implementation for issue #242 [`#310`](https://github.com/RhetTbull/osxphotos/pull/310)
- Added tests for Finder tags [`29e4245`](https://github.com/RhetTbull/osxphotos/commit/29e424575a522ae03efe5a140be46bfd0a1346c5)
- Initial implementation for Finder tags [`5885b23`](https://github.com/RhetTbull/osxphotos/commit/5885b23d3249cf91953092a6b1ce967da2667e29)
- Updated README for finder tags [`f25a299`](https://github.com/RhetTbull/osxphotos/commit/f25a2993097ad7b2b8ab2d1c787db58c0d799a41)
- Updated requirements.txt [`ea373c4`](https://github.com/RhetTbull/osxphotos/commit/ea373c4197ce1cce00e89157fe560d1366f7e764)
#### [v0.38.22](https://github.com/RhetTbull/osxphotos/compare/v0.38.21...v0.38.22)
> 30 December 2020
- Fixed --exiftool-path bug, issue #308 [`5dccdf7`](https://github.com/RhetTbull/osxphotos/commit/5dccdf7750611c78de5356bb02f6023d4fc382c5)
#### [v0.38.21](https://github.com/RhetTbull/osxphotos/compare/v0.38.20...v0.38.21)
> 29 December 2020
- Fixed --exiftool-path to work with --exiftool-merge-keywords/persons [`3872e7a`](https://github.com/RhetTbull/osxphotos/commit/3872e7ae649f42d849de472a7dbf78a241d54407)
#### [v0.38.20](https://github.com/RhetTbull/osxphotos/compare/v0.38.19...v0.38.20)
> 29 December 2020
- Added --exiftool-path to CLI [`4897fc4`](https://github.com/RhetTbull/osxphotos/commit/4897fc4b05cc7a3bea314f9cce8a2163bf3922b2)
#### [v0.38.19](https://github.com/RhetTbull/osxphotos/compare/v0.38.18...v0.38.19)
> 29 December 2020
- Added exiftool signature to JSON output, issue #303 [`fa58af8`](https://github.com/RhetTbull/osxphotos/commit/fa58af8b883da11fdfa723d2da75a600d927d46e)
#### [v0.38.18](https://github.com/RhetTbull/osxphotos/compare/v0.38.17...v0.38.18)
> 28 December 2020
- Added --exiftool-merge-keywords/persons, issue #299, #292 [`b1cb99f`](https://github.com/RhetTbull/osxphotos/commit/b1cb99f83f55128a314d265d4588134cb79026c6)
#### [v0.38.17](https://github.com/RhetTbull/osxphotos/compare/v0.38.16...v0.38.17)
> 28 December 2020
- Added --sidecar-drop-ext, issue #291 [`dce002c`](https://github.com/RhetTbull/osxphotos/commit/dce002cdfe12fa5fa4ada4d5097828a5375c2ecd)
- Updated Template Substitution table [`7bd189e`](https://github.com/RhetTbull/osxphotos/commit/7bd189e9b22a2ad5a8a80deb7cb93c61be37c771)
#### [v0.38.16](https://github.com/RhetTbull/osxphotos/compare/v0.38.15...v0.38.16)
> 28 December 2020
- Added searchinfo templates, issue #302 [`0d086bf`](https://github.com/RhetTbull/osxphotos/commit/0d086bf85102ce78b3111c64bfa88673fbc19559)
#### [v0.38.15](https://github.com/RhetTbull/osxphotos/compare/v0.38.14...v0.38.15)
> 28 December 2020
- Added --sidecar exiftool, issue #303 [`d833c14`](https://github.com/RhetTbull/osxphotos/commit/d833c14ef4b3f9375a85034cf0fb0f85a68cabb4)
- Refactored sidecar code [`ade98fc`](https://github.com/RhetTbull/osxphotos/commit/ade98fc15051684bfb54d0199d9c370481b70dcc)
- Refactored export2 to use sidecar bit field [`0d66759`](https://github.com/RhetTbull/osxphotos/commit/0d66759b1c200f1ecda202e28c259f88fd3db599)
#### [v0.38.14](https://github.com/RhetTbull/osxphotos/compare/v0.38.13...v0.38.14)
> 27 December 2020
- Bug fix for --description-template, issue #304 [`4cc40d2`](https://github.com/RhetTbull/osxphotos/commit/4cc40d24cfb11ef8668c5d3c3bab40371fdd0436)
#### [v0.38.13](https://github.com/RhetTbull/osxphotos/compare/v0.38.12...v0.38.13)
> 27 December 2020
- Set XMP:Subject to match Keywords, issue #302 [`75888cd`](https://github.com/RhetTbull/osxphotos/commit/75888cd6633d3f0180d24fef4f6776986a136f0f)
#### [v0.38.12](https://github.com/RhetTbull/osxphotos/compare/v0.38.11...v0.38.12)
> 26 December 2020
- Fixed city/sub-locality for SearchInfo [`f9f699b`](https://github.com/RhetTbull/osxphotos/commit/f9f699ba3500d58494f955d4e5d8118e336e6a2c)
#### [v0.38.11](https://github.com/RhetTbull/osxphotos/compare/v0.38.9...v0.38.11)
> 26 December 2020
- Exposed SearchInfo, closes #121 [`#121`](https://github.com/RhetTbull/osxphotos/issues/121)
- Added version to --verbose, closes #297 [`#297`](https://github.com/RhetTbull/osxphotos/issues/297)
- Added --exportdb [`2a49255`](https://github.com/RhetTbull/osxphotos/commit/2a49255277d3c6bd3b0d5f8288afd7de7dab0320)
- Updated README.md [`f469ccc`](https://github.com/RhetTbull/osxphotos/commit/f469cccc4b4561db7611c3e9abf5aefc3ab0f648)
- Fixed help text [`f3b7134`](https://github.com/RhetTbull/osxphotos/commit/f3b7134af1e3d07fb956eaccccd9d60bd075d3bf)
#### [v0.38.9](https://github.com/RhetTbull/osxphotos/compare/v0.38.8...v0.38.9)
> 21 December 2020

145
README.md
View File

@@ -301,23 +301,45 @@ Options:
the primary photo will be exported--
associated burst images will be skipped.
--sidecar FORMAT Create sidecar for each photo exported;
valid FORMAT values: xmp, json; --sidecar
json: create JSON sidecar useable by
exiftool (https://exiftool.org/) The sidecar
file can be used to apply metadata to the
file with exiftool, for example: "exiftool
valid FORMAT values: xmp, json, exiftool;
--sidecar xmp: create XMP sidecar used by
Adobe Lightroom, etc. The sidecar file is
named in format photoname.ext.xmp The XMP
sidecar exports the following tags:
Description, Title, Keywords/Tags, Subject
(set to Keywords + PersonInImage),
PersonInImage, CreateDate, ModifyDate,
GPSLongitude.
--sidecar json: create JSON
sidecar useable by exiftool
(https://exiftool.org/) The sidecar file can
be used to apply metadata to the file with
exiftool, for example: "exiftool
-j=photoname.jpg.json photoname.jpg" The
sidecar file is named in format
photoname.ext.json --sidecar xmp: create
XMP sidecar used by Adobe Lightroom, etc.The
sidecar file is named in format
photoname.ext.xmpThe XMP sidecar exports the
following tags: Description, Title,
Keywords/Tags, Subject (set to Keywords +
PersonInImage), PersonInImage, CreateDate,
ModifyDate, GPSLongitude. For a list of tags
exported in the JSON sidecar, see
--exiftool.
photoname.ext.json; format includes tag
groups (equivalent to running 'exiftool -G
-j').
--sidecar exiftool: create JSON
sidecar compatible with output of 'exiftool
-j'. Unlike '--sidecar json', '--sidecar
exiftool' does not export tag groups.
Sidecar filename is in format
photoname.ext.json; For a list of tags
exported in the JSON and exiftool sidecar,
see '--exiftool'.
--sidecar-drop-ext Drop the photo's extension when naming
sidecar files. By default, sidecar files are
named in format
'photo_filename.photo_ext.sidecar_ext', e.g.
'IMG_1234.JPG.json'. Use '--sidecar-drop-
ext' to ignore the photo extension.
Resulting sidecar files will have name in
format 'IMG_1234.json'. Warning: this may
result in sidecar filename collisions if
there are files of different types but the
same name in the output directory, e.g.
'IMG_1234.JPG' and 'IMG_1234.MOV'.
--exiftool Use exiftool to write metadata directly to
exported photos. To use this option,
exiftool must be installed and in the path.
@@ -327,14 +349,12 @@ Options:
metadata: EXIF:ImageDescription,
XMP:Description (see also --description-
template); XMP:Title; XMP:TagsList,
IPTC:Keywords (see also --keyword-template,
--person-keyword, --album-keyword);
XMP:Subject (set to keywords + person in
image to mirror Photos' behavior);
XMP:PersonInImage; EXIF:GPSLatitudeRef;
EXIF:GPSLongitudeRef; EXIF:GPSLatitude;
EXIF:GPSLongitude; EXIF:GPSPosition;
EXIF:DateTimeOriginal;
IPTC:Keywords, XMP:Subject (see also
--keyword-template, --person-keyword,
--album-keyword); XMP:PersonInImage;
EXIF:GPSLatitudeRef; EXIF:GPSLongitudeRef;
EXIF:GPSLatitude; EXIF:GPSLongitude;
EXIF:GPSPosition; EXIF:DateTimeOriginal;
EXIF:OffsetTimeOriginal; EXIF:ModifyDate
(see --ignore-date-modified);
IPTC:DateCreated; IPTC:TimeCreated; (video
@@ -343,6 +363,8 @@ Options:
(see also --ignore-date-modified);
QuickTime:GPSCoordinates;
UserData:GPSCoordinates.
--exiftool-path EXIFTOOL_PATH Optionally specify path to exiftool; if not
provided, will look for exiftool in $PATH.
--exiftool-option OPTION Optional flag/option to pass to exiftool
when using --exiftool. For example,
--exiftool-option '-m' to ignore minor
@@ -353,6 +375,12 @@ Options:
may be specified by repeating the option,
e.g. --exiftool-option '-m' --exiftool-
option '-F'.
--exiftool-merge-keywords Merge any keywords found in the original
file with keywords used for '--exiftool' and
'--sidecar'.
--exiftool-merge-persons Merge any persons found in the original file
with persons used for '--exiftool' and '--
sidecar'.
--ignore-date-modified If used with --exiftool or --sidecar, will
ignore the photo modification date and set
EXIF:ModifyDate to EXIF:DateTimeOriginal;
@@ -384,6 +412,20 @@ Options:
could specify --description-template
"{descr} exported with osxphotos on
{today.date}" See Templating System below.
--finder-tag-template TEMPLATE Set Finder tags to TEMPLATE. These tags can
be searched in the Finder or Spotlight with
'tag:tagname' format. For example, '--
finder-tag-template "{label}"' to set Finder
tags to photo labels. You may specify
multiple TEMPLATE values by using '--finder-
tag-template' multiple times. See also '--
finder-tag-keywords and Extended Attributes
below.'.
--finder-tag-keywords Set Finder tags to keywords; any keywords
specified via '--keyword-template', '--
person-keyword', etc. will also be used as
Finder tags. See also '--finder-tag-template
and Extended Attributes below.'.
--directory DIRECTORY Optional template for specifying name of
output directory in the form
'{name,DEFAULT}'. See below for additional
@@ -486,6 +528,24 @@ option to re-export the entire library thus rebuilding the
'.osxphotos_export.db' database.
** Extended Attributes **
Some options (currently '--finder-tag-template' and '--finder-tag-keywords')
write additional metadata to extended attributes in the file. These options
will only work if the destination filesystem supports extended attributes
(most do). For example, --finder-tag-keyword writes all keywords (including
any specified by '--keyword-template' or other options) to Finder tags that
are searchable in Spotlight using the syntax: 'tag:tagname'. For example, if
you have images with keyword "Travel" then using '--finder-tag-keywords' you
could quickly find those images in the Finder by typing 'tag:Travel' in the
Spotlight search bar. Finder tags are written to the
'com.apple.metadata:_kMDItemUserTags' extended attribute. Unlike EXIF
metadata, extended attributes do not modify the actual file. Most cloud
storage services do not synch extended attributes. Dropbox does sync them and
any changes to a file's extended attributes will cause Dropbox to re-sync the
files.
** Templating System **
Several options, such as --directory, allow you to specify a template which
@@ -718,6 +778,10 @@ Substitution Description
'United States'
{place.address.country_code} ISO country code of the postal address, e.g.
'US'
{searchinfo.season} Season of the year associated with a photo,
e.g. 'Summer'; (Photos 5+ only, applied
automatically by Photos' image
categorization algorithms).
The following substitutions may result in multiple values. Thus if specified
for --directory these could result in multiple copies of a photo being being
@@ -734,10 +798,10 @@ Substitution Description
{keyword} Keyword(s) assigned to photo
{person} Person(s) / face(s) in a photo
{label} Image categorization label associated with a photo
(Photos 5 only)
{label_normalized} All lower case version of 'label' (Photos 5 only)
(Photos 5+ only)
{label_normalized} All lower case version of 'label' (Photos 5+ only)
{comment} Comment(s) on shared Photos; format is 'Person
name: comment text' (Photos 5 only)
name: comment text' (Photos 5+ only)
{exiftool:GROUP:TAGNAME} Use exiftool (https://exiftool.org) to extract
metadata, in form GROUP:TAGNAME, from image. E.g.
'{exiftool:EXIF:Make}' to get camera make, or
@@ -746,6 +810,20 @@ Substitution Description
tag names. You must specify group (e.g. EXIF,
IPTC, etc) as used in `exiftool -G`. exiftool must
be installed in the path to use this template.
{searchinfo.holiday} Holiday names associated with a photo, e.g.
'Christmas Day'; (Photos 5+ only, applied
automatically by Photos' image categorization
algorithms).
{searchinfo.activity} Activities associated with a photo, e.g. 'Sporting
Event'; (Photos 5+ only, applied automatically by
Photos' image categorization algorithms).
{searchinfo.venue} Venues associated with a photo, e.g. name of
restaurant; (Photos 5+ only, applied automatically
by Photos' image categorization algorithms).
{searchinfo.venue_type} Venue types associated with a photo, e.g.
'Restaurant'; (Photos 5+ only, applied
automatically by Photos' image categorization
algorithms).
```
Example: export all photos to ~/Desktop/export group in folders by date created
@@ -1556,7 +1634,7 @@ Returns a JSON representation of all photo info.
Returns a dictionary representation of all photo info.
#### `export()`
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised).
@@ -1567,6 +1645,8 @@ Export photo from the Photos library to another destination on disk.
- live_photo: boolean; if True (default=False), will also export the associted .mov for live photos; exported live photo will be named filename.mov
- increment: boolean; if True (default=True), will increment file name until a non-existent name is found
- sidecar_json: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name
- sidecar_json: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name; resulting json file will include tag group names (e.g. `exiftool -G -j`)
- sidecar_exiftool: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name; resulting json file will not include tag group names (e.g. `exiftool -j`)
- sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with metadata; sidecar filename will be dest/filename.xmp where filename is the stem of the photo name
- use_photos_export: boolean; (default=False), if True will attempt to export photo via applescript interaction with Photos; useful for forcing download of missing photos. This only works if the Photos library being used is the default library (last opened by Photos) as applescript will directly interact with whichever library Photos is currently using.
- timeout: (int, default=120) timeout in seconds used with use_photos_export
@@ -2216,14 +2296,19 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{place.address.postal_code}|Postal code part of the postal address, e.g. '20009'|
|{place.address.country}|Country name of the postal address, e.g. 'United States'|
|{place.address.country_code}|ISO country code of the postal address, e.g. 'US'|
|{searchinfo.season}|Season of the year associated with a photo, e.g. 'Summer'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{album}|Album(s) photo is contained in|
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
|{keyword}|Keyword(s) assigned to photo|
|{person}|Person(s) / face(s) in a photo|
|{label}|Image categorization label associated with a photo (Photos 5 only)|
|{label_normalized}|All lower case version of 'label' (Photos 5 only)|
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)|
|{label}|Image categorization label associated with a photo (Photos 5+ only)|
|{label_normalized}|All lower case version of 'label' (Photos 5+ only)|
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only)|
|{exiftool:GROUP:TAGNAME}|Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) as used in `exiftool -G`. exiftool must be installed in the path to use this template.|
|{searchinfo.holiday}|Holiday names associated with a photo, e.g. 'Christmas Day'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{searchinfo.activity}|Activities associated with a photo, e.g. 'Sporting Event'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{searchinfo.venue}|Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{searchinfo.venue_type}|Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
### Utility Functions

View File

@@ -13,17 +13,22 @@ import unicodedata
import click
import yaml
import osxmetadata
import osxphotos
from ._constants import (
_EXIF_TOOL_URL,
_PHOTOS_4_VERSION,
_UNKNOWN_PLACE,
_OSXPHOTOS_NONE_SENTINEL,
CLI_COLOR_ERROR,
CLI_COLOR_WARNING,
DEFAULT_JPEG_QUALITY,
DEFAULT_EDITED_SUFFIX,
DEFAULT_JPEG_QUALITY,
DEFAULT_ORIGINAL_SUFFIX,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
SIDECAR_XMP,
UNICODE_FORMAT,
)
from ._version import __version__
@@ -176,6 +181,24 @@ class ExportCommand(click.Command):
+ "You can always run export without the --update option to re-export the entire library thus "
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
)
formatter.write("\n\n")
formatter.write_text("** Extended Attributes **")
formatter.write("\n")
formatter.write_text(
"""
Some options (currently '--finder-tag-template' and '--finder-tag-keywords') write
additional metadata to extended attributes in the file. These options will only work
if the destination filesystem supports extended attributes (most do).
For example, --finder-tag-keyword writes all keywords (including any specified by '--keyword-template'
or other options) to Finder tags that are searchable in Spotlight using the syntax: 'tag:tagname'.
For example, if you have images with keyword "Travel" then using '--finder-tag-keywords' you could quickly
find those images in the Finder by typing 'tag:Travel' in the Spotlight search bar.
Finder tags are written to the 'com.apple.metadata:_kMDItemUserTags' extended attribute.
Unlike EXIF metadata, extended attributes do not modify the actual file. Most cloud storage services
do not synch extended attributes. Dropbox does sync them and any changes to a file's extended attributes
will cause Dropbox to re-sync the files.
"""
)
formatter.write("\n\n")
formatter.write_text("** Templating System **")
@@ -1154,8 +1177,9 @@ def query(
_list_libraries()
return
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
photos = _query(
db=db,
photosdb=photosdb,
keyword=keyword,
person=person,
album=album,
@@ -1337,18 +1361,32 @@ def query(
default=None,
multiple=True,
metavar="FORMAT",
type=click.Choice(["xmp", "json"], case_sensitive=False),
help="Create sidecar for each photo exported; valid FORMAT values: xmp, json; "
f"--sidecar json: create JSON sidecar useable by exiftool ({_EXIF_TOOL_URL}) "
"The sidecar file can be used to apply metadata to the file with exiftool, for example: "
'"exiftool -j=photoname.jpg.json photoname.jpg" '
"The sidecar file is named in format photoname.ext.json "
"--sidecar xmp: create XMP sidecar used by Adobe Lightroom, etc."
"The sidecar file is named in format photoname.ext.xmp"
type=click.Choice(["xmp", "json", "exiftool"], case_sensitive=False),
help="Create sidecar for each photo exported; valid FORMAT values: xmp, json, exiftool; "
"--sidecar xmp: create XMP sidecar used by Adobe Lightroom, etc. "
"The sidecar file is named in format photoname.ext.xmp "
"The XMP sidecar exports the following tags: Description, Title, Keywords/Tags, "
"Subject (set to Keywords + PersonInImage), PersonInImage, CreateDate, ModifyDate, "
"GPSLongitude. "
"For a list of tags exported in the JSON sidecar, see --exiftool.",
f"\n--sidecar json: create JSON sidecar useable by exiftool ({_EXIF_TOOL_URL}) "
"The sidecar file can be used to apply metadata to the file with exiftool, for example: "
'"exiftool -j=photoname.jpg.json photoname.jpg" '
"The sidecar file is named in format photoname.ext.json; "
"format includes tag groups (equivalent to running 'exiftool -G -j'). "
"\n--sidecar exiftool: create JSON sidecar compatible with output of 'exiftool -j'. "
"Unlike '--sidecar json', '--sidecar exiftool' does not export tag groups. "
"Sidecar filename is in format photoname.ext.json; "
"For a list of tags exported in the JSON and exiftool sidecar, see '--exiftool'.",
)
@click.option(
"--sidecar-drop-ext",
is_flag=True,
help="Drop the photo's extension when naming sidecar files. "
"By default, sidecar files are named in format 'photo_filename.photo_ext.sidecar_ext', "
"e.g. 'IMG_1234.JPG.json'. Use '--sidecar-drop-ext' to ignore the photo extension. "
"Resulting sidecar files will have name in format 'IMG_1234.json'. "
"Warning: this may result in sidecar filename collisions if there are files of different "
"types but the same name in the output directory, e.g. 'IMG_1234.JPG' and 'IMG_1234.MOV'.",
)
@click.option(
"--exiftool",
@@ -1358,14 +1396,20 @@ def query(
"exiftool may be installed from https://exiftool.org/. "
"Cannot be used with --export-as-hardlink. Writes the following metadata: "
"EXIF:ImageDescription, XMP:Description (see also --description-template); "
"XMP:Title; XMP:TagsList, IPTC:Keywords (see also --keyword-template, --person-keyword, --album-keyword); "
"XMP:Subject (set to keywords + person in image to mirror Photos' behavior); "
"XMP:Title; XMP:TagsList, IPTC:Keywords, XMP:Subject "
"(see also --keyword-template, --person-keyword, --album-keyword); "
"XMP:PersonInImage; EXIF:GPSLatitudeRef; EXIF:GPSLongitudeRef; EXIF:GPSLatitude; EXIF:GPSLongitude; "
"EXIF:GPSPosition; EXIF:DateTimeOriginal; EXIF:OffsetTimeOriginal; "
"EXIF:ModifyDate (see --ignore-date-modified); IPTC:DateCreated; IPTC:TimeCreated; "
"(video files only): QuickTime:CreationDate; QuickTime:CreateDate; QuickTime:ModifyDate (see also --ignore-date-modified); "
"QuickTime:GPSCoordinates; UserData:GPSCoordinates.",
)
@click.option(
"--exiftool-path",
metavar="EXIFTOOL_PATH",
type=click.Path(exists=True),
help="Optionally specify path to exiftool; if not provided, will look for exiftool in $PATH.",
)
@click.option(
"--exiftool-option",
multiple=True,
@@ -1377,6 +1421,16 @@ def query(
"More than one option may be specified by repeating the option, e.g. "
"--exiftool-option '-m' --exiftool-option '-F'. ",
)
@click.option(
"--exiftool-merge-keywords",
is_flag=True,
help="Merge any keywords found in the original file with keywords used for '--exiftool' and '--sidecar'.",
)
@click.option(
"--exiftool-merge-persons",
is_flag=True,
help="Merge any persons found in the original file with persons used for '--exiftool' and '--sidecar'.",
)
@click.option(
"--ignore-date-modified",
is_flag=True,
@@ -1420,6 +1474,22 @@ def query(
'--description-template "{descr} exported with osxphotos on {today.date}" '
"See Templating System below.",
)
@click.option(
"--finder-tag-template",
metavar="TEMPLATE",
multiple=True,
default=None,
help="Set MacOS Finder tags to TEMPLATE. These tags can be searched in the Finder or Spotlight with "
"'tag:tagname' format. For example, '--finder-tag-template \"{label}\"' to set Finder tags to photo labels. "
"You may specify multiple TEMPLATE values by using '--finder-tag-template' multiple times. "
"See also '--finder-tag-keywords and Extended Attributes below.'.",
)
@click.option(
"--finder-tag-keywords",
is_flag=True,
help="Set MacOS Finder tags to keywords; any keywords specified via '--keyword-template', '--person-keyword', etc. "
"will also be used as Finder tags. See also '--finder-tag-template and Extended Attributes below.'.",
)
@click.option(
"--directory",
metavar="DIRECTORY",
@@ -1560,10 +1630,13 @@ def export(
album_keyword,
keyword_template,
description_template,
finder_tag_template,
finder_tag_keywords,
current_name,
convert_to_jpeg,
jpeg_quality,
sidecar,
sidecar_drop_ext,
only_photos,
only_movies,
burst,
@@ -1573,7 +1646,10 @@ def export(
download_missing,
dest,
exiftool,
exiftool_path,
exiftool_option,
exiftool_merge_keywords,
exiftool_merge_persons,
ignore_date_modified,
portrait,
not_portrait,
@@ -1648,7 +1724,7 @@ def export(
)
raise click.Abort()
# re-set the local function vars to the corresponding config value
# re-set the local vars to the corresponding config value
# this isn't elegant but avoids having to rewrite this function to use cfg.varname for every parameter
db = cfg.db
photos_library = cfg.photos_library
@@ -1692,10 +1768,13 @@ def export(
album_keyword = cfg.album_keyword
keyword_template = cfg.keyword_template
description_template = cfg.description_template
finder_tag_template = cfg.finder_tag_template
finder_tag_keywords = cfg.finder_tag_keywords
current_name = cfg.current_name
convert_to_jpeg = cfg.convert_to_jpeg
jpeg_quality = cfg.jpeg_quality
sidecar = cfg.sidecar
sidecar_drop_ext = cfg.sidecar_drop_ext
only_photos = cfg.only_photos
only_movies = cfg.only_movies
burst = cfg.burst
@@ -1704,6 +1783,10 @@ def export(
not_live = cfg.not_live
download_missing = cfg.download_missing
exiftool = cfg.exiftool
exiftool_path = cfg.exiftool_path
exiftool_option = cfg.exiftool_option
exiftool_merge_keywords = cfg.exiftool_merge_keywords
exiftool_merge_persons = cfg.exiftool_merge_persons
ignore_date_modified = cfg.ignore_date_modified
portrait = cfg.portrait
not_portrait = cfg.not_portrait
@@ -1745,6 +1828,7 @@ def export(
verbose_(f"osxphotos version {__version__}")
# validate options
exclusive_options = [
("favorite", "not_favorite"),
("hidden", "not_hidden"),
@@ -1776,6 +1860,8 @@ def export(
("jpeg_quality", ("convert_to_jpeg")),
("ignore_signature", ("update")),
("exiftool_option", ("exiftool")),
("exiftool_merge_keywords", ("exiftool", "sidecar")),
("exiftool_merge_persons", ("exiftool", "sidecar")),
]
try:
cfg.validate(exclusive=exclusive_options, dependent=dependent_options, cli=True)
@@ -1788,6 +1874,16 @@ def export(
)
raise click.Abort()
if all(x in [s.lower() for s in sidecar] for x in ["json", "exiftool"]):
click.echo(
click.style(
"Cannot use --sidecar json with --sidecar exiftool due to name collisions",
fg=CLI_COLOR_ERROR,
),
err=True,
)
raise click.Abort()
if save_config:
verbose_(f"Saving options to file {save_config}")
cfg.write_to_file(save_config)
@@ -1834,10 +1930,15 @@ def export(
not x for x in [skip_edited, skip_bursts, skip_live, skip_raw]
]
# verify exiftool installed an in path
if exiftool:
# verify exiftool installed and in path if path not provided and exiftool will be used
# NOTE: this won't catch use of {exiftool:} in a template
# but those will raise error during template eval if exiftool path not set
if (
any([exiftool, exiftool_merge_keywords, exiftool_merge_persons])
and not exiftool_path
):
try:
_ = get_exiftool_path()
exiftool_path = get_exiftool_path()
except FileNotFoundError:
click.echo(
click.style(
@@ -1849,6 +1950,9 @@ def export(
)
ctx.exit(2)
if any([exiftool, exiftool_merge_keywords, exiftool_merge_persons]):
verbose_(f"exiftool path: {exiftool_path}")
isphoto = ismovie = True # default searches for everything
if only_movies:
isphoto = False
@@ -1930,8 +2034,9 @@ def export(
f"Upgraded export database {export_db_path} from version {upgraded[0]} to {upgraded[1]}"
)
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_, exiftool=exiftool_path)
photos = _query(
db=db,
photosdb=photosdb,
keyword=keyword,
person=person,
album=album,
@@ -2010,14 +2115,17 @@ def export(
original_name = not current_name
results = ExportResults()
if verbose:
for p in photos:
# send progress bar output to /dev/null if verbose to hide the progress bar
fp = open(os.devnull, "w") if verbose else None
with click.progressbar(photos, file=fp) as bar:
for p in bar:
export_results = export_photo(
photo=p,
dest=dest,
verbose=verbose,
export_by_date=export_by_date,
sidecar=sidecar,
sidecar_drop_ext=sidecar_drop_ext,
update=update,
ignore_signature=ignore_signature,
export_as_hardlink=export_as_hardlink,
@@ -2028,6 +2136,8 @@ def export(
export_live=export_live,
download_missing=download_missing,
exiftool=exiftool,
exiftool_merge_keywords=exiftool_merge_keywords,
exiftool_merge_persons=exiftool_merge_persons,
directory=directory,
filename_template=filename_template,
export_raw=export_raw,
@@ -2050,68 +2160,30 @@ def export(
)
results += export_results
# if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg":
# for photo_file in set(
# results.exported + results.updated + results.exif_updated
# ):
# verbose_(f"Converting {photo_file} to jpeg")
else:
# show progress bar
with click.progressbar(photos) as bar:
for p in bar:
export_results = export_photo(
photo=p,
dest=dest,
verbose=verbose,
export_by_date=export_by_date,
sidecar=sidecar,
update=update,
ignore_signature=ignore_signature,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
export_edited=export_edited,
skip_original_if_edited=skip_original_if_edited,
original_name=original_name,
export_live=export_live,
download_missing=download_missing,
exiftool=exiftool,
directory=directory,
filename_template=filename_template,
export_raw=export_raw,
if finder_tag_keywords or finder_tag_template:
files = set(
export_results.exported
+ export_results.new
+ export_results.updated
+ export_results.exif_updated
+ export_results.converted_to_jpeg
+ export_results.skipped
)
tags_written, tags_skipped = write_finder_tags(
p,
files,
keywords=finder_tag_keywords,
keyword_template=keyword_template,
album_keyword=album_keyword,
person_keyword=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
export_db=export_db,
fileutil=fileutil,
dry_run=dry_run,
touch_file=touch_file,
edited_suffix=edited_suffix,
original_suffix=original_suffix,
use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
exiftool_option=exiftool_option,
exiftool_merge_keywords=exiftool_merge_keywords,
finder_tag_template=finder_tag_template,
)
results += export_results
results.xattr_written.extend(tags_written)
results.xattr_skipped.extend(tags_skipped)
# print summary results
# print(f"results_exported: {results_exported}")
# print(f"results_new: {results_new}")
# print(f"results_updated: {results_updated}")
# print(f"results_skipped: {results_skipped}")
# print(f"results_exif_updated: {results_exif_updated}")
# print(f"results_touched: {results_touched}")
# print(f"results_converted: {results_converted}")
# print(f"results_sidecar_json_written: {results_sidecar_json_written}")
# print(f"results_sidecar_json_skipped: {results_sidecar_json_skipped}")
# print(f"results_sidecar_xmp_written: {results_sidecar_xmp_written}")
# print(f"results_sidecar_xmp_skipped: {results_sidecar_xmp_skipped}")
# print(f"results_missing: {results_missing}")
# print(f"results_error: {results_error}")
if fp is not None:
fp.close()
if cleanup:
all_files = (
@@ -2122,6 +2194,8 @@ def export(
+ results.converted_to_jpeg
+ results.sidecar_json_written
+ results.sidecar_json_skipped
+ results.sidecar_exiftool_written
+ results.sidecar_exiftool_skipped
+ results.sidecar_xmp_written
+ results.sidecar_xmp_skipped
# include missing so a file that was already in export directory
@@ -2287,7 +2361,7 @@ def print_photo_info(photos, json=False):
def _query(
db=None,
photosdb,
keyword=None,
person=None,
album=None,
@@ -2346,12 +2420,12 @@ def _query(
has_likes=False,
no_likes=False,
):
"""run a query against PhotosDB to extract the photos based on user supply criteria
used by query and export commands
arguments must be passed in same order as query and export
if either is modified, need to ensure all three functions are updated"""
"""Run a query against PhotosDB to extract the photos based on user supply criteria used by query and export commands
Args:
photosdb: PhotosDB object
"""
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
if deleted or deleted_only:
photos = photosdb.photos(
uuid=uuid,
@@ -2611,6 +2685,7 @@ def export_photo(
verbose=None,
export_by_date=None,
sidecar=None,
sidecar_drop_ext=False,
update=None,
ignore_signature=None,
export_as_hardlink=None,
@@ -2621,6 +2696,8 @@ def export_photo(
export_live=None,
download_missing=None,
exiftool=None,
exiftool_merge_keywords=False,
exiftool_merge_persons=False,
directory=None,
filename_template=None,
export_raw=None,
@@ -2649,6 +2726,7 @@ def export_photo(
verbose: boolean; print verbose output
export_by_date: boolean; create export folder in form dest/YYYY/MM/DD
sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export
sidecar_drop_ext: boolean; if True, drops photo extension from sidecar name
export_as_hardlink: boolean; hardlink files instead of copying them
overwrite: boolean; overwrite dest file if it already exists
original_name: boolean; use original filename instead of current filename
@@ -2674,6 +2752,8 @@ def export_photo(
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
exiftool_option: optional list flags (e.g. ["-m", "-F"]) to pass to exiftool
exiftool_merge_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
exiftool_merge_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
Returns:
list of path(s) of exported photo or None if photo was missing
@@ -2763,11 +2843,13 @@ def export_photo(
)
sidecar = [s.lower() for s in sidecar]
sidecar_json = sidecar_xmp = False
sidecar_flags = 0
if "json" in sidecar:
sidecar_json = True
sidecar_flags |= SIDECAR_JSON
if "xmp" in sidecar:
sidecar_xmp = True
sidecar_flags |= SIDECAR_XMP
if "exiftool" in sidecar:
sidecar_flags |= SIDECAR_EXIFTOOL
# if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos
@@ -2796,14 +2878,16 @@ def export_photo(
export_results = photo.export2(
dest_path,
original_filename,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
sidecar=sidecar_flags,
sidecar_drop_ext=sidecar_drop_ext,
live_photo=export_live,
raw_photo=export_raw,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
use_photos_export=use_photos_export,
exiftool=exiftool,
merge_exif_keywords=exiftool_merge_keywords,
merge_exif_persons=exiftool_merge_persons,
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
@@ -2901,13 +2985,15 @@ def export_photo(
export_results_edited = photo.export2(
dest_path,
edited_filename,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
sidecar=sidecar_flags,
sidecar_drop_ext=sidecar_drop_ext,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
edited=True,
use_photos_export=use_photos_export,
exiftool=exiftool,
merge_exif_keywords=exiftool_merge_keywords,
merge_exif_persons=exiftool_merge_persons,
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
@@ -3115,8 +3201,8 @@ def load_uuid_from_file(filename):
def write_export_report(report_file, results):
""" write CSV report with results from export
"""write CSV report with results from export
Args:
report_file: path to report file
results: ExportResults object
@@ -3136,10 +3222,13 @@ def write_export_report(report_file, results):
"converted_to_jpeg": 0,
"sidecar_xmp": 0,
"sidecar_json": 0,
"sidecar_exiftool": 0,
"missing": 0,
"error": 0,
"exiftool_warning": "",
"exiftool_error": "",
"extended_attributes_written": 0,
"extended_attributes_skipped": 0,
}
for result in results.all_files()
}
@@ -3181,6 +3270,14 @@ def write_export_report(report_file, results):
all_results[result]["sidecar_json"] = 1
all_results[result]["skipped"] = 1
for result in results.sidecar_exiftool_written:
all_results[result]["sidecar_exiftool"] = 1
all_results[result]["exported"] = 1
for result in results.sidecar_exiftool_skipped:
all_results[result]["sidecar_exiftool"] = 1
all_results[result]["skipped"] = 1
for result in results.missing:
all_results[result]["missing"] = 1
@@ -3193,6 +3290,12 @@ def write_export_report(report_file, results):
for result in results.exiftool_error:
all_results[result[0]]["exiftool_error"] = result[1]
for result in results.xattr_written:
all_results[result]["extended_attributes_written"] = 1
for result in results.xattr_skipped:
all_results[result]["extended_attributes_skipped"] = 1
report_columns = [
"filename",
"exported",
@@ -3204,10 +3307,13 @@ def write_export_report(report_file, results):
"converted_to_jpeg",
"sidecar_xmp",
"sidecar_json",
"sidecar_exiftool",
"missing",
"error",
"exiftool_warning",
"exiftool_error",
"extended_attributes_written",
"extended_attributes_skipped",
]
try:
@@ -3225,7 +3331,7 @@ def write_export_report(report_file, results):
def cleanup_files(dest_path, files_to_keep, fileutil):
""" cleanup dest_path by deleting and files and empty directories
"""cleanup dest_path by deleting and files and empty directories
not in files_to_keep
Args:
@@ -3259,5 +3365,84 @@ def cleanup_files(dest_path, files_to_keep, fileutil):
return (deleted_files, deleted_dirs)
def write_finder_tags(
photo,
files,
keywords=False,
keyword_template=None,
album_keyword=None,
person_keyword=None,
exiftool_merge_keywords=None,
finder_tag_template=None,
):
"""Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written
Args:
photo: a PhotoInfo object
files: list of file paths to write Finder tags to
keywords: if True, sets Finder tags to all keywords including any evaluated from keyword_template, album_keyword, person_keyword, exiftool_merge_keywords
keyword_template: list of keyword templates to evaluate for determining keywords
album_keyword: if True, use album names as keywords
person_keyword: if True, use person in image as keywords
exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords
finder_tag_template: list of templates to evaluate for determining Finder tags
Returns:
(list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating)
"""
tags = []
written = []
skipped = []
if keywords:
# match whatever keywords would've been used in --exiftool or --sidecar
exif = photo._exiftool_dict(
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
merge_exif_keywords=exiftool_merge_keywords,
)
try:
if exif["IPTC:Keywords"]:
tags.extend(exif["IPTC:Keywords"])
except KeyError:
pass
if finder_tag_template:
rendered_tags = []
for template_str in finder_tag_template:
rendered, unmatched = photo.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
if unmatched:
click.echo(
click.style(
f"Warning: unmatched template substitution for template: {template_str} {unmatched}",
fg=CLI_COLOR_WARNING,
),
err=True,
)
rendered_tags.extend(rendered)
# filter out any template values that didn't match by looking for sentinel
rendered_tags = [
tag for tag in rendered_tags if _OSXPHOTOS_NONE_SENTINEL not in tag
]
tags.extend(rendered_tags)
tags = [osxmetadata.Tag(tag) for tag in set(tags)]
for f in files:
md = osxmetadata.OSXMetaData(f)
if sorted(md.tags) != sorted(tags):
verbose_(f"Writing Finder tags to {f}")
md.tags = tags
written.append(f)
else:
verbose_(f"Skipping Finder tags for {f}: nothing to do")
skipped.append(f)
return (written, skipped)
if __name__ == "__main__":
cli() # pylint: disable=no-value-for-parameter

View File

@@ -106,15 +106,15 @@ SEARCH_CATEGORY_PLACE_NAME = 1
SEARCH_CATEGORY_STREET = 2
SEARCH_CATEGORY_NEIGHBORHOOD = 3
SEARCH_CATEGORY_LOCALITY_4 = 4
SEARCH_CATEGORY_CITY = 5
SEARCH_CATEGORY_SUB_LOCALITY = 6
SEARCH_CATEGORY_LOCALITY_7 = 7
SEARCH_CATEGORY_SUB_LOCALITY_5 = 5
SEARCH_CATEGORY_SUB_LOCALITY_6 = 6
SEARCH_CATEGORY_CITY = 7
SEARCH_CATEGORY_LOCALITY_8 = 8
SEARCH_CATEGORY_NAMED_AREA = 9
SEARCH_CATEGORY_ALL_LOCALITY = [
SEARCH_CATEGORY_LOCALITY_4,
SEARCH_CATEGORY_SUB_LOCALITY,
SEARCH_CATEGORY_LOCALITY_7,
SEARCH_CATEGORY_SUB_LOCALITY_5,
SEARCH_CATEGORY_SUB_LOCALITY_6,
SEARCH_CATEGORY_LOCALITY_8,
SEARCH_CATEGORY_NAMED_AREA,
]
@@ -178,3 +178,8 @@ DEFAULT_ORIGINAL_SUFFIX = ""
# Colors for print CLI messages
CLI_COLOR_ERROR = "red"
CLI_COLOR_WARNING = "yellow"
# Bit masks for --sidecar
SIDECAR_JSON = 0x1
SIDECAR_EXIFTOOL = 0x2
SIDECAR_XMP = 0x4

View File

@@ -1,5 +1,5 @@
""" version info """
__version__ = "0.38.11"
__version__ = "0.39.1"

View File

@@ -33,8 +33,8 @@ def get_exiftool_path():
class _ExifToolProc:
""" Runs exiftool in a subprocess via Popen
Creates a singleton object """
"""Runs exiftool in a subprocess via Popen
Creates a singleton object"""
def __new__(cls, *args, **kwargs):
""" create new object or return instance of already created singleton """
@@ -44,20 +44,20 @@ class _ExifToolProc:
return cls.instance
def __init__(self, exiftool=None):
""" construct _ExifToolProc singleton object or return instance of already created object
exiftool: optional path to exiftool binary (if not provided, will search path to find it) """
"""construct _ExifToolProc singleton object or return instance of already created object
exiftool: optional path to exiftool binary (if not provided, will search path to find it)"""
if hasattr(self, "_process_running") and self._process_running:
# already running
if exiftool is not None:
if exiftool is not None and exiftool != self._exiftool:
logging.warning(
f"exiftool subprocess already running, "
f"ignoring exiftool={exiftool}"
)
return
self._exiftool = exiftool or get_exiftool_path()
self._process_running = False
self._exiftool = exiftool or get_exiftool_path()
self._start_proc()
@property
@@ -106,8 +106,8 @@ class _ExifToolProc:
def _stop_proc(self):
""" stop the exiftool process if it's running, otherwise, do nothing """
if not self._process_running:
logging.warning("exiftool process is not running")
return
self._process.stdin.write(b"-stay_open\n")
@@ -133,7 +133,7 @@ class ExifTool:
""" Basic exiftool interface for reading and writing EXIF tags """
def __init__(self, filepath, exiftool=None, overwrite=True, flags=None):
""" Create ExifTool object
"""Create ExifTool object
Args:
file: path to image file
@@ -157,15 +157,15 @@ class ExifTool:
self._read_exif()
def setvalue(self, tag, value):
""" Set tag to value(s); if value is None, will delete tag
"""Set tag to value(s); if value is None, will delete tag
Args:
tag: str; name of tag to set
value: str; value to set tag to
Returns:
True if success otherwise False
If error generated by exiftool, returns False and sets self.error to error string
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
If called in context manager, returns True (execution is delayed until exiting context manager)
@@ -184,26 +184,26 @@ class ExifTool:
return error is None
def addvalues(self, tag, *values):
""" Add one or more value(s) to tag
"""Add one or more value(s) to tag
If more than one value is passed, each value will be added to the tag
Args:
tag: str; tag to set
*values: str; one or more values to set
Returns:
True if success otherwise False
If error generated by exiftool, returns False and sets self.error to error string
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
If called in context manager, returns True (execution is delayed until exiting context manager)
Notes: exiftool may add duplicate values for some tags so the caller must ensure
the values being added are not already in the EXIF data
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
It's up to the caller to know what exiftool will do for each tag
If setvalue called before addvalues, exiftool does not appear to add duplicates,
If setvalue called before addvalues, exiftool does not appear to add duplicates,
but if addvalues called without first calling setvalue, exiftool will add duplicate values
"""
if not values:
@@ -226,7 +226,7 @@ class ExifTool:
return error is None
def run_commands(self, *commands, no_file=False):
""" Run commands in the exiftool process and return result.
"""Run commands in the exiftool process and return result.
Args:
*commands: exiftool commands to run
@@ -266,7 +266,7 @@ class ExifTool:
+ b"\n"
+ b"-execute\n"
)
# send the command
self._process.stdin.write(command_str)
self._process.stdin.flush()
@@ -301,8 +301,8 @@ class ExifTool:
return ver.decode("utf-8")
def asdict(self):
""" return dictionary of all EXIF tags and values from exiftool
returns empty dict if no tags
"""return dictionary of all EXIF tags and values from exiftool
returns empty dict if no tags
"""
json_str, _, _ = self.run_commands("-json")
if json_str:

View File

@@ -18,12 +18,11 @@ def exiftool(self):
return self._exiftool
except AttributeError:
try:
exiftool_path = get_exiftool_path()
exiftool_path = self._db._exiftool_path or get_exiftool_path()
if self.path is not None and os.path.isfile(self.path):
exiftool = ExifTool(self.path)
exiftool = ExifTool(self.path, exiftool=exiftool_path)
else:
exiftool = None
logging.debug(f"exiftool: missing path {self.uuid}")
except FileNotFoundError:
# get_exiftool_path raises FileNotFoundError if exiftool not found
exiftool = None

View File

@@ -5,12 +5,15 @@
_export_photo
_write_exif_data
_exiftool_json_sidecar
_get_exif_keywords
_get_exif_persons
_exiftool_dict
_xmp_sidecar
_write_sidecar
"""
# TODO: should this be its own PhotoExporter class?
# TODO: the various sidecar_json, sidecar_xmp, etc args should all be collapsed to a sidecar param using a bit mask
import glob
import hashlib
@@ -32,6 +35,9 @@ from .._constants import (
_TEMPLATE_DIR,
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
SIDECAR_XMP,
)
from ..datetime_utils import datetime_tz_to_utc
from ..exiftool import ExifTool
@@ -40,8 +46,8 @@ from ..fileutil import FileUtil
from ..photokit import (
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PhotoLibrary,
PhotoKitFetchFailed,
PhotoLibrary,
)
from ..utils import dd_to_dms_str, findfiles, noop
@@ -60,12 +66,16 @@ class ExportResults:
converted_to_jpeg=None,
sidecar_json_written=None,
sidecar_json_skipped=None,
sidecar_exiftool_written=None,
sidecar_exiftool_skipped=None,
sidecar_xmp_written=None,
sidecar_xmp_skipped=None,
missing=None,
error=None,
exiftool_warning=None,
exiftool_error=None,
xattr_written=None,
xattr_skipped=None,
):
self.exported = exported or []
self.new = new or []
@@ -76,12 +86,16 @@ class ExportResults:
self.converted_to_jpeg = converted_to_jpeg or []
self.sidecar_json_written = sidecar_json_written or []
self.sidecar_json_skipped = sidecar_json_skipped or []
self.sidecar_exiftool_written = sidecar_exiftool_written or []
self.sidecar_exiftool_skipped = sidecar_exiftool_skipped or []
self.sidecar_xmp_written = sidecar_xmp_written or []
self.sidecar_xmp_skipped = sidecar_xmp_skipped or []
self.missing = missing or []
self.error = error or []
self.exiftool_warning = exiftool_warning or []
self.exiftool_error = exiftool_error or []
self.xattr_written = xattr_written or []
self.xattr_skipped = xattr_skipped or []
def all_files(self):
""" return all filenames contained in results """
@@ -95,6 +109,8 @@ class ExportResults:
+ self.converted_to_jpeg
+ self.sidecar_json_written
+ self.sidecar_json_skipped
+ self.sidecar_exiftool_written
+ self.sidecar_exiftool_skipped
+ self.sidecar_xmp_written
+ self.sidecar_xmp_skipped
+ self.missing
@@ -116,6 +132,8 @@ class ExportResults:
self.converted_to_jpeg += other.converted_to_jpeg
self.sidecar_json_written += other.sidecar_json_written
self.sidecar_json_skipped += other.sidecar_json_skipped
self.sidecar_exiftool_written += other.sidecar_exiftool_written
self.sidecar_exiftool_skipped += other.sidecar_exiftool_skipped
self.sidecar_xmp_written += other.sidecar_xmp_written
self.sidecar_xmp_skipped += other.sidecar_xmp_skipped
self.missing += other.missing
@@ -136,6 +154,8 @@ class ExportResults:
+ f",converted_to_jpeg={self.converted_to_jpeg}"
+ f",sidecar_json_written={self.sidecar_json_written}"
+ f",sidecar_json_skipped={self.sidecar_json_skipped}"
+ f",sidecar_exiftool_written={self.sidecar_exiftool_written}"
+ f",sidecar_exiftool_skipped={self.sidecar_exiftool_skipped}"
+ f",sidecar_xmp_written={self.sidecar_xmp_written}"
+ f",sidecar_xmp_skipped={self.sidecar_xmp_skipped}"
+ f",missing={self.missing}"
@@ -323,6 +343,7 @@ def export(
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_exiftool=False,
sidecar_xmp=False,
use_photos_export=False,
timeout=120,
@@ -352,10 +373,12 @@ def export(
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
sidecar filename will be dest/filename.json
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
sidecar_json: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
sidecar_xmp: if set will write an XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
@@ -372,6 +395,14 @@ def export(
# Implementation note: calls export2 to actually do the work
sidecar = 0
if sidecar_json:
sidecar |= SIDECAR_JSON
if sidecar_exiftool:
sidecar |= SIDECAR_EXIFTOOL
if sidecar_xmp:
sidecar |= SIDECAR_XMP
results = self.export2(
dest,
*filename,
@@ -381,8 +412,7 @@ def export(
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
increment=increment,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
sidecar=sidecar,
use_photos_export=use_photos_export,
timeout=timeout,
exiftool=exiftool,
@@ -405,8 +435,8 @@ def export2(
export_as_hardlink=False,
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_xmp=False,
sidecar=0,
sidecar_drop_ext=False,
use_photos_export=False,
timeout=120,
exiftool=False,
@@ -426,6 +456,8 @@ def export2(
use_photokit=False,
verbose=None,
exiftool_flags=None,
merge_exif_keywords=False,
merge_exif_persons=False,
):
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -445,10 +477,14 @@ def export2(
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
sidecar filename will be dest/filename.json
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
sidecar: bit field: set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
SIDECAR_JSON: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
SIDECAR_EXIFTOOL: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
SIDECAR_XMP: if set will write an XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
sidecar_drop_ext: (boolean, default=False); if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json')
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
@@ -471,6 +507,8 @@ def export2(
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
exiftool_flags: optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
Returns: ExportResults class
ExportResults has attributes:
@@ -483,6 +521,8 @@ def export2(
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_exiftool_written",
"sidecar_exiftool_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
"missing",
@@ -857,48 +897,62 @@ def export2(
)
# export metadata
sidecars = []
sidecar_json_files_skipped = []
sidecar_json_files_written = []
if sidecar_json:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json")
sidecar_exiftool_files_skipped = []
sidecar_exiftool_files_written = []
sidecar_xmp_files_skipped = []
sidecar_xmp_files_written = []
dest_suffix = "" if sidecar_drop_ext else dest.suffix
if sidecar & SIDECAR_JSON:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
sidecar_str = self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
filename=dest.name,
)
sidecar_digest = hexdigest(sidecar_str)
old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
sidecar_filename
)
write_sidecar = (
not update
or (update and not sidecar_filename.exists())
or (
update
and (sidecar_digest != old_sidecar_digest)
or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig)
sidecars.append(
(
sidecar_filename,
sidecar_str,
sidecar_json_files_written,
sidecar_json_files_skipped,
"JSON",
)
)
if write_sidecar:
verbose(f"Writing exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_written.append(str(sidecar_filename))
if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str)
export_db.set_sidecar_for_file(
sidecar_filename,
sidecar_digest,
fileutil.file_sig(sidecar_filename),
)
else:
verbose(f"Skipped up to date exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_skipped.append(str(sidecar_filename))
sidecar_xmp_files_skipped = []
sidecar_xmp_files_written = []
if sidecar_xmp:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp")
if sidecar & SIDECAR_EXIFTOOL:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
sidecar_str = self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
tag_groups=False,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
filename=dest.name,
)
sidecars.append(
(
sidecar_filename,
sidecar_str,
sidecar_exiftool_files_written,
sidecar_exiftool_files_skipped,
"exiftool",
)
)
if sidecar & SIDECAR_XMP:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.xmp")
sidecar_str = self._xmp_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
@@ -906,6 +960,23 @@ def export2(
description_template=description_template,
extension=dest.suffix[1:] if dest.suffix else None,
)
sidecars.append(
(
sidecar_filename,
sidecar_str,
sidecar_xmp_files_written,
sidecar_xmp_files_skipped,
"XMP",
)
)
for data in sidecars:
sidecar_filename = data[0]
sidecar_str = data[1]
files_written = data[2]
files_skipped = data[3]
sidecar_type = data[4]
sidecar_digest = hexdigest(sidecar_str)
old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
sidecar_filename
@@ -920,8 +991,8 @@ def export2(
)
)
if write_sidecar:
verbose(f"Writing XMP sidecar {sidecar_filename}")
sidecar_xmp_files_written.append(str(sidecar_filename))
verbose(f"Writing {sidecar_type} sidecar {sidecar_filename}")
files_written.append(str(sidecar_filename))
if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str)
export_db.set_sidecar_for_file(
@@ -930,8 +1001,8 @@ def export2(
fileutil.file_sig(sidecar_filename),
)
else:
verbose(f"Skipped up to date XMP sidecar {sidecar_filename}")
sidecar_xmp_files_skipped.append(str(sidecar_filename))
verbose(f"Skipped up to date {sidecar_type} sidecar {sidecar_filename}")
files_skipped.append(str(sidecar_filename))
# if exiftool, write the metadata
if update:
@@ -957,6 +1028,8 @@ def export2(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
)
)[0]
if old_data != current_data:
@@ -975,6 +1048,8 @@ def export2(
description_template=description_template,
ignore_date_modified=ignore_date_modified,
flags=exiftool_flags,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
)
if warning_:
exiftool_warning.append((exported_file, warning_))
@@ -990,6 +1065,8 @@ def export2(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
),
)
export_db.set_stat_exif_for_file(
@@ -1010,6 +1087,8 @@ def export2(
description_template=description_template,
ignore_date_modified=ignore_date_modified,
flags=exiftool_flags,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
)
if warning_:
exiftool_warning.append((exported_file, warning_))
@@ -1025,6 +1104,8 @@ def export2(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
),
)
export_db.set_stat_exif_for_file(
@@ -1051,6 +1132,8 @@ def export2(
converted_to_jpeg=converted_to_jpeg_files,
sidecar_json_written=sidecar_json_files_written,
sidecar_json_skipped=sidecar_json_files_skipped,
sidecar_exiftool_written=sidecar_exiftool_files_written,
sidecar_exiftool_skipped=sidecar_exiftool_files_skipped,
sidecar_xmp_written=sidecar_xmp_files_written,
sidecar_xmp_skipped=sidecar_xmp_files_skipped,
error=errors,
@@ -1233,6 +1316,8 @@ def _export_photo(
converted_to_jpeg=converted_to_jpeg_files,
sidecar_json_written=[],
sidecar_json_skipped=[],
sidecar_exiftool_written=[],
sidecar_exiftool_skipped=[],
sidecar_xmp_written=[],
sidecar_xmp_skipped=[],
missing=[],
@@ -1249,6 +1334,8 @@ def _write_exif_data(
description_template=None,
ignore_date_modified=False,
flags=None,
merge_exif_keywords=False,
merge_exif_persons=False,
):
"""write exif data to image file at filepath
@@ -1271,9 +1358,11 @@ def _write_exif_data(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
)
with ExifTool(filepath, flags=flags) as exiftool:
with ExifTool(filepath, flags=flags, exiftool=self._db._exiftool_path) as exiftool:
for exiftag, val in exif_info.items():
if type(val) == list:
for v in val:
@@ -1290,16 +1379,22 @@ def _exiftool_dict(
keyword_template=None,
description_template=None,
ignore_date_modified=False,
merge_exif_keywords=False,
merge_exif_persons=False,
filename=None,
):
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
Args:
filename: name of source image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
description_template: (list of strings); list of template strings to render for the description
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
merge_exif_keywords: merge keywords in the file's exif metadata (requires exiftool)
merge_exif_persons: merge persons in the file's exif metadata (requires exiftool)
Returns: dict with exiftool tags / values
@@ -1326,11 +1421,21 @@ def _exiftool_dict(
UserData:GPSCoordinates
"""
exif = {}
exif = (
{
"SourceFile": filename,
"ExifTool:ExifToolVersion": "12.00",
"File:FileName": filename,
}
if filename is not None
else {}
)
if description_template is not None:
description = self.render_template(
rendered = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
description = " ".join(rendered) if rendered else ""
exif["EXIF:ImageDescription"] = description
exif["XMP:Description"] = description
elif self.description:
@@ -1341,13 +1446,19 @@ def _exiftool_dict(
exif["XMP:Title"] = self.title
keyword_list = []
if merge_exif_keywords:
keyword_list.extend(self._get_exif_keywords())
if self.keywords:
keyword_list.extend(self.keywords)
person_list = []
if merge_exif_persons:
person_list.extend(self._get_exif_persons())
if self.persons:
# filter out _UNKNOWN_PERSON
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
person_list.extend([p for p in self.persons if p != _UNKNOWN_PERSON])
if use_persons_as_keywords and person_list:
keyword_list.extend(person_list)
@@ -1390,18 +1501,14 @@ def _exiftool_dict(
if keyword_list:
# remove duplicates
keyword_list = sorted(list(set(keyword_list)))
exif["XMP:TagsList"] = keyword_list.copy()
exif["IPTC:Keywords"] = keyword_list.copy()
exif["XMP:Subject"] = keyword_list.copy()
exif["XMP:TagsList"] = keyword_list.copy()
if person_list:
person_list = sorted(list(set(person_list)))
exif["XMP:PersonInImage"] = person_list.copy()
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
# only use Photos' keywords for subject (e.g. don't include template values)
exif["XMP:Subject"] = sorted(list(set(self.keywords + person_list)))
# if self.favorite():
# exif["Rating"] = 5
@@ -1478,6 +1585,39 @@ def _exiftool_dict(
return exif
def _get_exif_keywords(self):
""" returns list of keywords found in the file's exif metadata """
keywords = []
exif = self.exiftool
if exif:
exifdict = exif.asdict()
for field in ["IPTC:Keywords", "XMP:TagsList", "XMP:Subject"]:
try:
kw = exifdict[field]
if kw and type(kw) != list:
kw = [kw]
keywords.extend(kw)
except KeyError:
pass
return keywords
def _get_exif_persons(self):
""" returns list of persons found in the file's exif metadata """
persons = []
exif = self.exiftool
if exif:
exifdict = exif.asdict()
try:
p = exifdict["XMP:PersonInImage"]
if p and type(p) != list:
p = [p]
persons.extend(p)
except KeyError:
pass
return persons
def _exiftool_json_sidecar(
self,
use_albums_as_keywords=False,
@@ -1485,6 +1625,10 @@ def _exiftool_json_sidecar(
keyword_template=None,
description_template=None,
ignore_date_modified=False,
tag_groups=True,
merge_exif_keywords=False,
merge_exif_persons=False,
filename=None,
):
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
@@ -1495,6 +1639,10 @@ def _exiftool_json_sidecar(
keyword_template: (list of strings); list of template strings to render as keywords
description_template: (list of strings); list of template strings to render for the description
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
tag_groups: if True, tags are in form Group:TagName, e.g. IPTC:Keywords, otherwise group name is omitted, e.g. Keywords
merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
filename: filename of the destination image file for including in exiftool signature in JSON sidecar
Returns: dict with exiftool tags / values
@@ -1526,7 +1674,19 @@ def _exiftool_json_sidecar(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
filename=filename,
)
if not tag_groups:
# strip tag groups
exif_new = {}
for k, v in exif.items():
k = re.sub(r".*:", "", k)
exif_new[k] = v
exif = exif_new
return json.dumps([exif])
@@ -1537,12 +1697,17 @@ def _xmp_sidecar(
keyword_template=None,
description_template=None,
extension=None,
merge_exif_keywords=False,
merge_exif_persons=False,
):
"""returns string for XMP sidecar
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
description_template: string; optional template string that will be rendered for use as photo description"""
description_template: string; optional template string that will be rendered for use as photo description
merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
"""
xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME))
@@ -1551,13 +1716,17 @@ def _xmp_sidecar(
extension = extension.suffix[1:] if extension.suffix else None
if description_template is not None:
description = self.render_template(
rendered = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
description = " ".join(rendered) if rendered else ""
else:
description = self.description if self.description is not None else ""
keyword_list = []
if merge_exif_keywords:
keyword_list.extend(self._get_exif_keywords())
if self.keywords:
keyword_list.extend(self.keywords)
@@ -1565,9 +1734,12 @@ def _xmp_sidecar(
# good candidate for pulling out in a function
person_list = []
if merge_exif_persons:
person_list.extend(self._get_exif_persons())
if self.persons:
# filter out _UNKNOWN_PERSON
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
person_list.extend([p for p in self.persons if p != _UNKNOWN_PERSON])
if use_persons_as_keywords and person_list:
keyword_list.extend(person_list)
@@ -1607,20 +1779,15 @@ def _xmp_sidecar(
keyword_list.extend(rendered_keywords)
subject_list = []
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
subject_list = list(self.keywords) + person_list
# remove duplicates
# sorted mainly to make testing the XMP file easier
if keyword_list:
keyword_list = sorted(list(set(keyword_list)))
if subject_list:
subject_list = sorted(list(set(subject_list)))
if person_list:
person_list = sorted(list(set(person_list)))
subject_list = keyword_list
xmp_str = xmp_template.render(
photo=self,
description=description,
@@ -1631,7 +1798,7 @@ def _xmp_sidecar(
)
# remove extra lines that mako inserts from template
xmp_str = "\n".join([line for line in xmp_str.split("\n") if line.strip() != ""])
xmp_str = "\n".join(line for line in xmp_str.split("\n") if line.strip() != "")
return xmp_str

View File

@@ -21,11 +21,11 @@ from .._constants import (
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_VERSION,
_PHOTOS_5_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
_PHOTOS_5_SHARED_ALBUM_KIND,
_PHOTOS_5_SHARED_PHOTO_PATH,
_PHOTOS_5_VERSION,
)
from ..albuminfo import AlbumInfo, ImportInfo
from ..personinfo import FaceInfo, PersonInfo
@@ -56,6 +56,8 @@ class PhotoInfo:
_export_photo,
_exiftool_dict,
_exiftool_json_sidecar,
_get_exif_keywords,
_get_exif_persons,
_write_exif_data,
_write_sidecar,
_xmp_sidecar,
@@ -84,8 +86,8 @@ class PhotoInfo:
@property
def original_filename(self):
""" original filename of the picture
Photos 5 mangles filenames upon import """
"""original filename of the picture
Photos 5 mangles filenames upon import"""
if (
self._db._db_version <= _PHOTOS_4_VERSION
and self.has_raw
@@ -104,8 +106,8 @@ class PhotoInfo:
@property
def date_modified(self):
""" image modification date as timezone aware datetime object
or None if no modification date set """
"""image modification date as timezone aware datetime object
or None if no modification date set"""
# Photos <= 4 provides no way to get date of adjustment and will update
# lastmodifieddate anytime photo database record is updated (e.g. adding tags)
@@ -490,9 +492,9 @@ class PhotoInfo:
@property
def ismissing(self):
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
"""returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
do not immediately get written to disk. In particular, I've noticed that downloading
do not immediately get written to disk. In particular, I've noticed that downloading
an image from the cloud does not force the database to be updated until something else
e.g. an edit, keyword, etc. occurs forcing a database synch
The exact process / timing is a mystery to be but be aware that if some photos were recently
@@ -537,8 +539,8 @@ class PhotoInfo:
@property
def shared(self):
""" returns True if photos is in a shared iCloud album otherwise false
Only valid on Photos 5; returns None on older versions """
"""returns True if photos is in a shared iCloud album otherwise false
Only valid on Photos 5; returns None on older versions"""
if self._db._db_version > _PHOTOS_4_VERSION:
return self._info["shared"]
else:
@@ -546,8 +548,8 @@ class PhotoInfo:
@property
def uti(self):
""" Returns Uniform Type Identifier (UTI) for the image
for example: public.jpeg or com.apple.quicktime-movie
"""Returns Uniform Type Identifier (UTI) for the image
for example: public.jpeg or com.apple.quicktime-movie
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
if self.hasadjustments:
@@ -562,8 +564,8 @@ class PhotoInfo:
@property
def uti_original(self):
""" Returns Uniform Type Identifier (UTI) for the original image
for example: public.jpeg or com.apple.quicktime-movie
"""Returns Uniform Type Identifier (UTI) for the original image
for example: public.jpeg or com.apple.quicktime-movie
"""
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
return self._info["raw_pair_info"]["UTI"]
@@ -575,9 +577,9 @@ class PhotoInfo:
@property
def uti_edited(self):
""" Returns Uniform Type Identifier (UTI) for the edited image
if the photo has been edited, otherwise None;
for example: public.jpeg
"""Returns Uniform Type Identifier (UTI) for the edited image
if the photo has been edited, otherwise None;
for example: public.jpeg
"""
if self._db._db_version >= _PHOTOS_5_VERSION:
return self.uti if self.hasadjustments else None
@@ -586,36 +588,34 @@ class PhotoInfo:
@property
def uti_raw(self):
""" Returns Uniform Type Identifier (UTI) for the RAW image if there is one
for example: com.canon.cr2-raw-image
Returns None if no associated RAW image
"""Returns Uniform Type Identifier (UTI) for the RAW image if there is one
for example: com.canon.cr2-raw-image
Returns None if no associated RAW image
"""
return self._info["UTI_raw"]
@property
def ismovie(self):
""" Returns True if file is a movie, otherwise False
"""
"""Returns True if file is a movie, otherwise False"""
return True if self._info["type"] == _MOVIE_TYPE else False
@property
def isphoto(self):
""" Returns True if file is an image, otherwise False
"""
"""Returns True if file is an image, otherwise False"""
return True if self._info["type"] == _PHOTO_TYPE else False
@property
def incloud(self):
""" Returns True if photo is cloud asset and is synched to cloud
False if photo is cloud asset and not yet synched to cloud
None if photo is not cloud asset
"""Returns True if photo is cloud asset and is synched to cloud
False if photo is cloud asset and not yet synched to cloud
None if photo is not cloud asset
"""
return self._info["incloud"]
@property
def iscloudasset(self):
""" Returns True if photo is a cloud asset (in an iCloud library),
otherwise False
"""Returns True if photo is a cloud asset (in an iCloud library),
otherwise False
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return (
@@ -634,9 +634,9 @@ class PhotoInfo:
@property
def burst_photos(self):
""" If photo is a burst photo, returns list of PhotoInfo objects
that are part of the same burst photo set; otherwise returns empty list.
self is not included in the returned list """
"""If photo is a burst photo, returns list of PhotoInfo objects
that are part of the same burst photo set; otherwise returns empty list.
self is not included in the returned list"""
if self._info["burst"]:
burst_uuid = self._info["burstUUID"]
return [
@@ -654,9 +654,9 @@ class PhotoInfo:
@property
def path_live_photo(self):
""" Returns path to the associated video file for a live photo
If photo is not a live photo, returns None
If photo is missing, returns None """
"""Returns path to the associated video file for a live photo
If photo is not a live photo, returns None
If photo is missing, returns None"""
photopath = None
if self._db._db_version <= _PHOTOS_4_VERSION:
@@ -783,9 +783,9 @@ class PhotoInfo:
@property
def raw_original(self):
""" returns True if associated raw image and the raw image is selected in Photos
via "Use RAW as Original "
otherwise returns False """
"""returns True if associated raw image and the raw image is selected in Photos
via "Use RAW as Original "
otherwise returns False"""
return self._info["raw_is_original"]
@property
@@ -839,20 +839,20 @@ class PhotoInfo:
Args:
template_str: a template string with fields to render
none_str: a str to use if template field renders to None, default is "_".
path_sep: a single character str to use as path separator when joining
path_sep: a single character str to use as path separator when joining
fields like folder_album; if not provided, defaults to os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
dirname: if True, template output will be sanitized to produce valid directory name
replacement: str, value to replace any illegal file path characters with; default = ":"
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
template = PhotoTemplate(self)
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
return template.render(
template_str,
none_str=none_str,
@@ -875,11 +875,11 @@ class PhotoInfo:
return self._info["latitude"]
def _get_album_uuids(self):
""" Return list of album UUIDs this photo is found in
"""Return list of album UUIDs this photo is found in
Filters out albums in the trash and any special album types
Returns: list of album UUIDs
Returns: list of album UUIDs
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
version4 = True

View File

@@ -70,12 +70,13 @@ class PhotosDB:
from ._photosdb_process_scoreinfo import _process_scoreinfo
from ._photosdb_process_comments import _process_comments
def __init__(self, dbfile=None, verbose=None):
def __init__(self, dbfile=None, verbose=None, exiftool=None):
""" Create a new PhotosDB object.
Args:
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
Raises:
FileNotFoundError if dbfile is not a valid Photos library.
@@ -98,6 +99,8 @@ class PhotosDB:
raise TypeError("verbose must be callable")
self._verbose = verbose
self._exiftool_path = exiftool
# create a temporary directory
# tempfile.TemporaryDirectory gets cleaned up when the object does
self._tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")

View File

@@ -117,6 +117,7 @@ TEMPLATE_SUBSTITUTIONS = {
"{place.address.postal_code}": "Postal code part of the postal address, e.g. '20009'",
"{place.address.country}": "Country name of the postal address, e.g. 'United States'",
"{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
"{searchinfo.season}": "Season of the year associated with a photo, e.g. 'Summer'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
}
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
@@ -125,13 +126,17 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
"{keyword}": "Keyword(s) assigned to photo",
"{person}": "Person(s) / face(s) in a photo",
"{label}": "Image categorization label associated with a photo (Photos 5 only)",
"{label_normalized}": "All lower case version of 'label' (Photos 5 only)",
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)",
"{label}": "Image categorization label associated with a photo (Photos 5+ only)",
"{label_normalized}": "All lower case version of 'label' (Photos 5+ only)",
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only)",
"{exiftool:GROUP:TAGNAME}": "Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. "
"E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. "
"See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) "
"as used in `exiftool -G`. exiftool must be installed in the path to use this template.",
"{searchinfo.holiday}": "Holiday names associated with a photo, e.g. 'Christmas Day'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{searchinfo.activity}": "Activities associated with a photo, e.g. 'Sporting Event'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{searchinfo.venue}": "Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{searchinfo.venue_type}": "Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
}
# Just the multi-valued substitution names without the braces
@@ -144,13 +149,15 @@ MULTI_VALUE_SUBSTITUTIONS = [
class PhotoTemplate:
""" PhotoTemplate class to render a template string from a PhotoInfo object """
def __init__(self, photo):
""" Inits PhotoTemplate class with photo, non_str, and path_sep
def __init__(self, photo, exiftool_path=None):
""" Inits PhotoTemplate class with photo
Args:
photo: a PhotoInfo instance.
exiftool_path: optional path to exiftool for use with {exiftool:} template; if not provided, will look for exiftool in $PATH
"""
self.photo = photo
self.exiftool_path = exiftool_path
# holds value of current date/time for {today.x} fields
# gets initialized in get_template_value
@@ -512,7 +519,7 @@ class PhotoTemplate:
if not self.photo.path:
values = [None]
else:
exif = ExifTool(self.photo.path)
exif = ExifTool(self.photo.path, exiftool=self.exiftool_path)
exifdict = exif.asdict()
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
subfield = subfield.lower()
@@ -842,6 +849,8 @@ class PhotoTemplate:
if self.photo.place and self.photo.place.address.iso_country_code
else None
)
elif field == "searchinfo.season":
value = self.photo.search_info.season if self.photo.search_info else None
else:
# if here, didn't get a match
raise ValueError(f"Unhandled template value: {field}")
@@ -914,6 +923,16 @@ class PhotoTemplate:
values = [
f"{comment.user}: {comment.text}" for comment in self.photo.comments
]
elif field == "searchinfo.holiday":
values = self.photo.search_info.holidays if self.photo.search_info else []
elif field == "searchinfo.activity":
values = self.photo.search_info.activities if self.photo.search_info else []
elif field == "searchinfo.venue":
values = self.photo.search_info.venues if self.photo.search_info else []
elif field == "searchinfo.venue_type":
values = (
self.photo.search_info.venue_types if self.photo.search_info else []
)
elif not field.startswith("exiftool:"):
raise ValueError(f"Unhandled template value: {field}")

View File

@@ -26,7 +26,6 @@
<%def name="dc_subject(subject)">
% if subject:
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
% for subj in subject:

View File

@@ -42,6 +42,7 @@ mccabe==0.6.1
modulegraph==0.18
more-itertools==7.2.0
multidict==4.7.6
osxmetadata>=0.99.11
packaging==19.0
parso==0.6.2
pathspec==0.7.0

View File

@@ -81,6 +81,7 @@ setup(
"wurlitzer>=2.0.1",
"photoscript>=0.1.0",
"toml>=0.10.0",
"osxmetadata>=0.99.11",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 532 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
""" Test the command line interface (CLI) """
r""" Test the command line interface (CLI) """
import os
@@ -306,6 +306,11 @@ 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",
@@ -352,9 +357,10 @@ CLI_EXIFTOOL = {
"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", "Katie"],
"XMP:Subject": "Kids",
"EXIF:GPSLatitudeRef": "N",
"EXIF:GPSLongitudeRef": "W",
"EXIF:GPSLatitude": 41.256566,
@@ -362,6 +368,29 @@ CLI_EXIFTOOL = {
}
}
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",
@@ -395,7 +424,7 @@ CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = {
"XMP:TagsList": "wedding",
"IPTC:Keywords": "wedding",
"XMP:PersonInImage": "Maria",
"XMP:Subject": ["wedding", "Maria"],
"XMP:Subject": "wedding",
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
@@ -411,6 +440,19 @@ 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",
}
}
LABELS_JSON = {
"labels": {
"Plant": 7,
@@ -1027,6 +1069,100 @@ def test_export_exiftool():
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_path():
""" test --exiftool with --exiftool-path """
import glob
import os
import os.path
import shutil
import tempfile
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool, get_exiftool_path
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
tempdir = tempfile.TemporaryDirectory()
exiftool_source = get_exiftool_path()
exiftool_path = os.path.join(tempdir.name, "myexiftool")
shutil.copy2(exiftool_source, exiftool_path)
for uuid in CLI_EXIFTOOL:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"-V",
"--exiftool",
"--uuid",
f"{uuid}",
"--exiftool-path",
exiftool_path,
],
)
assert result.exit_code == 0
assert f"exiftool path: {exiftool_path}" 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 """
import glob
import os
import os.path
import re
import shutil
import sys
import tempfile
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool
from osxphotos.utils import noop
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():
tempdir = tempfile.TemporaryDirectory()
exiftool_path = os.path.join(tempdir.name, "myexiftool")
shutil.copy2(exiftool_source, exiftool_path)
for uuid in CLI_EXIFTOOL:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"-V",
"--filename",
"{original_name}_{exiftool:EXIF:Make}",
"--uuid",
f"{uuid}",
"--exiftool-path",
exiftool_path,
],
)
assert result.exit_code == 0
assert re.search(r"Exporting.*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():
import glob
@@ -1211,6 +1347,96 @@ def test_export_exiftool_option():
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 """
import glob
import os
import os.path
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool
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 """
import glob
import json
import os
import os.path
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool
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]
def test_export_edited_suffix():
""" test export with --edited-suffix """
import glob
@@ -1974,6 +2200,7 @@ def test_query_deleted_4():
def test_export_sidecar():
""" test --sidecar """
import glob
import os
import os.path
@@ -2003,6 +2230,70 @@ def test_export_sidecar():
assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES)
def test_export_sidecar_drop_ext():
""" test --sidecar with --sidecar-drop-ext option """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import cli
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
cli,
[
"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 """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import cli
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
cli,
[
"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():
import json
import os
@@ -2036,11 +2327,54 @@ def test_export_sidecar_templates():
with open(CLI_TEMPLATE_SIDECAR_FILENAME, "r") as jsonfile:
exifdata = json.load(jsonfile)
assert (
exifdata[0]["XMP:Description"][0]
exifdata[0]["XMP:Description"]
== "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Test Album"
)
assert (
exifdata[0]["EXIF:ImageDescription"][0]
exifdata[0]["EXIF:ImageDescription"]
== "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Test Album"
)
def test_export_sidecar_templates_exiftool():
""" test --sidecar exiftool with templates """
import json
import os
import os.path
import osxphotos
from osxphotos.__main__ import cli
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
".",
"--sidecar=exiftool",
f"--uuid={CLI_UUID_DICT_15_5['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, Test Album"
)
assert (
exifdata[0]["ImageDescription"]
== "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Test Album"
)
@@ -2075,7 +2409,7 @@ def test_export_sidecar_update():
)
assert result.exit_code == 0
assert "Writing XMP sidecar" in result.output
assert "Writing exiftool JSON sidecar" in result.output
assert "Writing JSON sidecar" in result.output
# delete a sidecar file and run update
fileutil = FileUtil()
@@ -2097,7 +2431,7 @@ def test_export_sidecar_update():
)
assert result.exit_code == 0
assert "Skipped up to date XMP sidecar" in result.output
assert "Writing exiftool JSON sidecar" in result.output
assert "Writing JSON sidecar" in result.output
# run update again, no sidecar files should update
result = runner.invoke(
@@ -2116,7 +2450,7 @@ def test_export_sidecar_update():
)
assert result.exit_code == 0
assert "Skipped up to date XMP sidecar" in result.output
assert "Skipped up to date exiftool JSON 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
@@ -2138,7 +2472,7 @@ def test_export_sidecar_update():
)
assert result.exit_code == 0
assert "Writing XMP sidecar" in result.output
assert "Skipped up to date exiftool JSON 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(
@@ -2157,7 +2491,7 @@ def test_export_sidecar_update():
)
assert result.exit_code == 0
assert "Skipped up to date XMP sidecar" in result.output
assert "Skipped up to date exiftool JSON 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(
@@ -2178,7 +2512,34 @@ def test_export_sidecar_update():
)
assert result.exit_code == 0
assert "Writing XMP sidecar" in result.output
assert "Writing exiftool JSON sidecar" in result.output
assert "Writing JSON sidecar" in result.output
def test_export_sidecar_invalid():
""" test invalid combination of sidecars """
import os
from osxphotos.__main__ import cli
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
cli,
[
"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():
@@ -3129,13 +3490,16 @@ def test_export_sidecar_keyword_template():
json_expected = json.loads(
"""
[{"EXIF:ImageDescription": "Girl holding pumpkin",
[{"SourceFile": "Pumkins2.jpg",
"ExifTool:ExifToolVersion": "12.00",
"File:FileName": "Pumkins2.jpg",
"EXIF:ImageDescription": "Girl holding pumpkin",
"XMP:Description": "Girl holding pumpkin",
"XMP:Title": "I found one!",
"XMP:Title": "I found one!",
"XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
"IPTC:Keywords": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
"XMP:PersonInImage": ["Katie"],
"XMP:Subject": ["Kids", "Katie"],
"XMP:Subject": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
"EXIF:DateTimeOriginal": "2018:09:28 16:07:07",
"EXIF:CreateDate": "2018:09:28 16:07:07",
"EXIF:OffsetTimeOriginal": "-04:00",
@@ -4442,7 +4806,7 @@ def test_save_load_config():
],
)
assert result.exit_code == 0
assert "Writing exiftool JSON sidecar" in result.output
assert "Writing JSON sidecar" in result.output
assert "Writing XMP sidecar" not in result.output
@@ -4512,3 +4876,242 @@ def test_export_exportdb():
"Error: --exportdb must be specified as filename not path" in result.output
)
def test_export_finder_tag_keywords():
""" test --finder-tag-keywords """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
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) 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) 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) for x in keywords]
assert sorted(md.tags) == sorted(expected)
def test_export_finder_tag_template():
""" test --finder-tag-template """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
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) 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) 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) 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 """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
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) for x in keywords + persons]
assert sorted(md.tags) == sorted(expected)
def test_export_finder_tag_template_keywords():
""" test --finder-tag-template with --finder-tag-keywords """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
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) for x in keywords + persons]
assert sorted(md.tags) == sorted(expected)

View File

@@ -1,5 +1,6 @@
import pytest
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.exiftool import get_exiftool_path
from osxphotos.utils import dd_to_dms_str
@@ -12,6 +13,12 @@ except:
PHOTOS_DB = "./tests/Test-10.15.7.photoslibrary/database/photos.db"
@pytest.fixture(scope="module")
def photosdb():
return osxphotos.PhotosDB(dbfile=PHOTOS_DB)
KEYWORDS = [
"Kids",
"wedding",
@@ -22,7 +29,7 @@ KEYWORDS = [
"St. James's Park",
"UK",
"United Kingdom",
"Maria"
"Maria",
]
# Photos 5 includes blank person for detected face
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
@@ -84,6 +91,21 @@ EXIF_JSON_EXPECTED = """
"EXIF:ModifyDate": "2019:07:27 17:33:28"}]
"""
EXIFTOOL_SIDECAR_EXPECTED = """
[{"ImageDescription": "Bride Wedding day",
"Description": "Bride Wedding day",
"TagsList": ["Maria", "wedding"],
"Keywords": ["Maria", "wedding"],
"PersonInImage": ["Maria"],
"Subject": ["wedding", "Maria"],
"DateTimeOriginal": "2019:04:15 14:40:24",
"CreateDate": "2019:04:15 14:40:24",
"OffsetTimeOriginal": "-04:00",
"DateCreated": "2019:04:15",
"TimeCreated": "14:40:24-04:00",
"ModifyDate": "2019:07:27 17:33:28"}]
"""
EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
@@ -100,18 +122,15 @@ EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
"""
def test_export_1():
def test_export_1(photosdb):
# test basic export
# get an unedited image and export it using default filename
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
@@ -122,18 +141,15 @@ def test_export_1():
assert os.path.isfile(got_dest)
def test_export_2():
def test_export_2(photosdb):
# test export with user provided filename
import os
import os.path
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
timestamp = time.time()
@@ -145,18 +161,15 @@ def test_export_2():
assert os.path.isfile(got_dest)
def test_export_3():
def test_export_3(photosdb):
# test file already exists and test increment=True (default)
import os
import os.path
import pathlib
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
@@ -172,7 +185,7 @@ def test_export_3():
assert os.path.isfile(got_dest_2)
def test_export_4():
def test_export_4(photosdb):
# test user supplied file already exists and test increment=True (default)
import os
import os.path
@@ -180,11 +193,8 @@ def test_export_4():
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
timestamp = time.time()
@@ -200,18 +210,15 @@ def test_export_4():
assert os.path.isfile(got_dest_2)
def test_export_5():
def test_export_5(photosdb):
# test file already exists and test increment=True (default)
# and overwrite = True
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
@@ -225,7 +232,7 @@ def test_export_5():
assert os.path.isfile(got_dest_2)
def test_export_6():
def test_export_6(photosdb):
# test user supplied file already exists and test increment=True (default)
# and overwrite = True
import os
@@ -234,11 +241,8 @@ def test_export_6():
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
timestamp = time.time()
@@ -253,18 +257,15 @@ def test_export_6():
assert os.path.isfile(got_dest_2)
def test_export_7():
def test_export_7(photosdb):
# test file already exists and test increment=False (not default), overwrite=False (default)
# should raise exception
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
@@ -277,18 +278,15 @@ def test_export_7():
assert e.type == type(FileExistsError())
def test_export_8():
def test_export_8(photosdb):
# try to export missing file
# should raise exception
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
filename = photos[0].filename
@@ -299,18 +297,15 @@ def test_export_8():
assert e.type == type(FileNotFoundError())
def test_export_9():
def test_export_9(photosdb):
# try to export edited file that's not edited
# should raise exception
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
with pytest.raises(Exception) as e:
@@ -318,7 +313,7 @@ def test_export_9():
assert e.type == ValueError
def test_export_10():
def test_export_10(photosdb):
# try to export edited file that's not edited and name provided
# should raise exception
import os
@@ -326,11 +321,8 @@ def test_export_10():
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
timestamp = time.time()
@@ -342,18 +334,15 @@ def test_export_10():
assert e.type == ValueError
def test_export_11():
def test_export_11(photosdb):
# export edited file with name provided
import os
import os.path
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
timestamp = time.time()
@@ -364,18 +353,15 @@ def test_export_11():
assert got_dest == expected_dest
def test_export_12():
def test_export_12(photosdb):
# export edited file with default name
import os
import os.path
import pathlib
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
edited_name = pathlib.Path(photos[0].path_edited).name
@@ -387,15 +373,13 @@ def test_export_12():
assert got_dest == expected_dest
def test_export_13():
def test_export_13(photosdb):
# export to invalid destination
# should raise exception
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
@@ -405,7 +389,6 @@ def test_export_13():
dest = os.path.join(dest, str(i))
i += 1
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
@@ -417,7 +400,6 @@ def test_export_13():
def test_dd_to_dms_str_1():
import osxphotos
lat_str, lon_str = dd_to_dms_str(
34.559331096, 69.206499174
@@ -428,7 +410,6 @@ def test_dd_to_dms_str_1():
def test_dd_to_dms_str_2():
import osxphotos
lat_str, lon_str = dd_to_dms_str(
-34.601997592, -58.375665164
@@ -439,7 +420,6 @@ def test_dd_to_dms_str_2():
def test_dd_to_dms_str_3():
import osxphotos
lat_str, lon_str = dd_to_dms_str(
-1.2666656, 36.7999968
@@ -450,7 +430,6 @@ def test_dd_to_dms_str_3():
def test_dd_to_dms_str_4():
import osxphotos
lat_str, lon_str = dd_to_dms_str(
38.889248, -77.050636
@@ -460,11 +439,9 @@ def test_dd_to_dms_str_4():
assert lon_str == "77 deg 3' 2.29\" W"
def test_exiftool_json_sidecar():
import osxphotos
def test_exiftool_json_sidecar(photosdb):
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
json_expected = json.loads(EXIF_JSON_EXPECTED)[0]
@@ -486,11 +463,9 @@ def test_exiftool_json_sidecar():
assert json_got[k] == v
def test_exiftool_json_sidecar_ignore_date_modified():
import osxphotos
def test_exiftool_json_sidecar_ignore_date_modified(photosdb):
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
json_expected = json.loads(EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED)[0]
@@ -512,12 +487,10 @@ def test_exiftool_json_sidecar_ignore_date_modified():
assert json_got[k] == v
def test_exiftool_json_sidecar_keyword_template_long(caplog):
import osxphotos
def test_exiftool_json_sidecar_keyword_template_long(caplog, photosdb):
from osxphotos._constants import _MAX_IPTC_KEYWORD_LEN
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
json_expected = json.loads(
@@ -526,8 +499,8 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog):
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["Maria", "wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"IPTC:Keywords": ["Maria", "wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"XMP:Subject": ["Maria", "wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
@@ -562,11 +535,9 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog):
assert json_got[k] == v
def test_exiftool_json_sidecar_keyword_template():
import osxphotos
def test_exiftool_json_sidecar_keyword_template(photosdb):
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
json_expected = json.loads(
@@ -575,8 +546,8 @@ def test_exiftool_json_sidecar_keyword_template():
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["Maria", "wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"IPTC:Keywords": ["Maria", "wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"XMP:Subject": ["Maria", "wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
@@ -622,11 +593,9 @@ def test_exiftool_json_sidecar_keyword_template():
assert json_got[k] == v
def test_exiftool_json_sidecar_use_persons_keyword():
import osxphotos
def test_exiftool_json_sidecar_use_persons_keyword(photosdb):
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
json_expected = json.loads(
@@ -664,11 +633,9 @@ def test_exiftool_json_sidecar_use_persons_keyword():
assert json_got[k] == v
def test_exiftool_json_sidecar_use_albums_keyword():
import osxphotos
def test_exiftool_json_sidecar_use_albums_keyword(photosdb):
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
json_expected = json.loads(
@@ -679,7 +646,7 @@ def test_exiftool_json_sidecar_use_albums_keyword():
"XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"],
"IPTC:Keywords": ["Kids", "Pumpkin Farm", "Test Album"],
"XMP:PersonInImage": ["Suzy", "Katie"],
"XMP:Subject": ["Kids", "Suzy", "Katie"],
"XMP:Subject": ["Kids", "Pumpkin Farm", "Test Album"],
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
"EXIF:CreateDate": "2018:09:28 15:35:49",
"EXIF:OffsetTimeOriginal": "-04:00",
@@ -706,13 +673,35 @@ def test_exiftool_json_sidecar_use_albums_keyword():
assert json_got[k] == v
def test_exiftool_sidecar(photosdb):
import json
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
json_expected = json.loads(EXIFTOOL_SIDECAR_EXPECTED)[0]
json_got = photos[0]._exiftool_json_sidecar(tag_groups=False)
json_got = json.loads(json_got)[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
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_xmp_sidecar_is_valid(tmp_path):
def test_xmp_sidecar_is_valid(tmp_path, photosdb):
""" validate XMP sidecar file with exiftool """
import osxphotos
from osxphotos.exiftool import ExifTool
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
photos[0].export(str(tmp_path), XMP_JPG_FILENAME, sidecar_xmp=True)
xmp_file = tmp_path / XMP_FILENAME
@@ -722,10 +711,8 @@ def test_xmp_sidecar_is_valid(tmp_path):
assert output == b"[ExifTool] Validate : 0 0 0"
def test_xmp_sidecar():
import osxphotos
def test_xmp_sidecar(photosdb):
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
@@ -738,12 +725,9 @@ def test_xmp_sidecar():
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
@@ -787,11 +771,9 @@ def test_xmp_sidecar():
assert line_expected == line_got
def test_xmp_sidecar_extension():
def test_xmp_sidecar_extension(photosdb):
""" test XMP sidecar when no extension is passed """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
@@ -804,12 +786,9 @@ def test_xmp_sidecar_extension():
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
@@ -853,10 +832,8 @@ def test_xmp_sidecar_extension():
assert line_expected == line_got
def test_xmp_sidecar_use_persons_keyword():
import osxphotos
def test_xmp_sidecar_use_persons_keyword(photosdb):
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
@@ -869,7 +846,6 @@ def test_xmp_sidecar_use_persons_keyword():
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
@@ -920,10 +896,8 @@ def test_xmp_sidecar_use_persons_keyword():
assert line_expected == line_got
def test_xmp_sidecar_use_albums_keyword():
import osxphotos
def test_xmp_sidecar_use_albums_keyword(photosdb):
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
@@ -936,12 +910,11 @@ def test_xmp_sidecar_use_albums_keyword():
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
<rdf:li>Pumpkin Farm</rdf:li>
<rdf:li>Test Album</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
@@ -987,11 +960,9 @@ def test_xmp_sidecar_use_albums_keyword():
assert line_expected == line_got
def test_xmp_sidecar_gps():
def test_xmp_sidecar_gps(photosdb):
""" Test export XMP sidecar with GPS info """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
@@ -1004,7 +975,6 @@ def test_xmp_sidecar_gps():
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description></dc:description>
<dc:title>St. James&#39;s Park</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>UK</rdf:li>
@@ -1057,10 +1027,8 @@ def test_xmp_sidecar_gps():
assert line_expected == line_got
def test_xmp_sidecar_keyword_template():
import osxphotos
def test_xmp_sidecar_keyword_template(photosdb):
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
@@ -1073,12 +1041,12 @@ def test_xmp_sidecar_keyword_template():
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
<rdf:li>Pumpkin Farm</rdf:li>
<rdf:li>Test Album</rdf:li>
<rdf:li>2018</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>

View File

@@ -1,9 +1,8 @@
import pytest
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
# TODO: put some of this code into a pre-function
PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
PHOTOS_DB_PATH = "/Test-10.14.6.photoslibrary/database/photos.db"
@@ -63,18 +62,20 @@ EXIF_JSON_EXPECTED = """
"""
def test_export_1():
@pytest.fixture(scope="module")
def photosdb():
return osxphotos.PhotosDB(dbfile=PHOTOS_DB)
def test_export_1(photosdb):
# test basic export
# get an unedited image and export it using default filename
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
@@ -85,18 +86,15 @@ def test_export_1():
assert os.path.isfile(got_dest)
def test_export_2():
def test_export_2(photosdb):
# test export with user provided filename
import os
import os.path
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
timestamp = time.time()
@@ -108,18 +106,15 @@ def test_export_2():
assert os.path.isfile(got_dest)
def test_export_3():
def test_export_3(photosdb):
# test file already exists and test increment=True (default)
import os
import os.path
import pathlib
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
@@ -135,7 +130,7 @@ def test_export_3():
assert os.path.isfile(got_dest_2)
def test_export_4():
def test_export_4(photosdb):
# test user supplied file already exists and test increment=True (default)
import os
import os.path
@@ -143,11 +138,8 @@ def test_export_4():
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
timestamp = time.time()
@@ -163,18 +155,15 @@ def test_export_4():
assert os.path.isfile(got_dest_2)
def test_export_5():
def test_export_5(photosdb):
# test file already exists and test increment=True (default)
# and overwrite = True
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
@@ -188,7 +177,7 @@ def test_export_5():
assert os.path.isfile(got_dest_2)
def test_export_6():
def test_export_6(photosdb):
# test user supplied file already exists and test increment=True (default)
# and overwrite = True
import os
@@ -197,11 +186,8 @@ def test_export_6():
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
timestamp = time.time()
@@ -216,18 +202,15 @@ def test_export_6():
assert os.path.isfile(got_dest_2)
def test_export_7():
def test_export_7(photosdb):
# test file already exists and test increment=False (not default), overwrite=False (default)
# should raise exception
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
@@ -240,18 +223,15 @@ def test_export_7():
assert e.type == type(FileExistsError())
def test_export_8():
def test_export_8(photosdb):
# try to export missing file
# should raise exception
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
filename = photos[0].filename
@@ -262,18 +242,15 @@ def test_export_8():
assert e.type == type(FileNotFoundError())
def test_export_9():
def test_export_9(photosdb):
# try to export edited file that's not edited
# should raise exception
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
filename = photos[0].filename
@@ -284,7 +261,7 @@ def test_export_9():
assert e.type == ValueError
def test_export_10():
def test_export_10(photosdb):
# try to export edited file that's not edited and name provided
# should raise exception
import os
@@ -292,11 +269,8 @@ def test_export_10():
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
timestamp = time.time()
@@ -308,18 +282,15 @@ def test_export_10():
assert e.type == ValueError
def test_export_11():
def test_export_11(photosdb):
# export edited file with name provided
import os
import os.path
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
timestamp = time.time()
@@ -330,18 +301,15 @@ def test_export_11():
assert got_dest == expected_dest
def test_export_12():
def test_export_12(photosdb):
# export edited file with default name
import os
import os.path
import pathlib
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
edited_name = pathlib.Path(photos[0].path_edited).name
@@ -353,15 +321,13 @@ def test_export_12():
assert got_dest == expected_dest
def test_export_13():
def test_export_13(photosdb):
# export to invalid destination
# should raise exception
import os
import os.path
import tempfile
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
@@ -371,7 +337,6 @@ def test_export_13():
dest = os.path.join(dest, str(i))
i += 1
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
filename = photos[0].filename
@@ -382,11 +347,9 @@ def test_export_13():
assert e.type == type(FileNotFoundError())
def test_exiftool_json_sidecar():
import osxphotos
def test_exiftool_json_sidecar(photosdb):
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
json_expected = json.loads(EXIF_JSON_EXPECTED)[0]
@@ -408,10 +371,8 @@ def test_exiftool_json_sidecar():
assert json_got[k] == v
def test_xmp_sidecar():
import osxphotos
def test_xmp_sidecar(photosdb):
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
@@ -424,12 +385,9 @@ def test_xmp_sidecar():
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Katie</rdf:li>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
@@ -471,10 +429,8 @@ def test_xmp_sidecar():
assert line_expected == line_got
def test_xmp_sidecar_keyword_template():
import osxphotos
def test_xmp_sidecar_keyword_template(photosdb):
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
@@ -487,12 +443,12 @@ def test_xmp_sidecar_keyword_template():
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
<rdf:li>Test Album</rdf:li>
<rdf:li>Pumpkin Farm</rdf:li>
<rdf:li>2018</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>

View File

@@ -13,6 +13,8 @@ EXPORT_RESULT_ATTRIBUTES = [
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_exiftool_written",
"sidecar_exiftool_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
"missing",
@@ -33,6 +35,8 @@ def test_exportresults_init():
assert results.converted_to_jpeg == []
assert results.sidecar_json_written == []
assert results.sidecar_json_skipped == []
assert results.sidecar_exiftool_written == []
assert results.sidecar_exiftool_skipped == []
assert results.sidecar_xmp_written == []
assert results.sidecar_xmp_skipped == []
assert results.missing == []
@@ -90,6 +94,6 @@ def test_str():
results = ExportResults()
assert (
str(results)
== "ExportResults(exported=[],new=[],updated=[],skipped=[],exif_updated=[],touched=[],converted_to_jpeg=[],sidecar_json_written=[],sidecar_json_skipped=[],sidecar_xmp_written=[],sidecar_xmp_skipped=[],missing=[],error=[],exiftool_warning=[],exiftool_error=[])"
== "ExportResults(exported=[],new=[],updated=[],skipped=[],exif_updated=[],touched=[],converted_to_jpeg=[],sidecar_json_written=[],sidecar_json_skipped=[],sidecar_exiftool_written=[],sidecar_exiftool_skipped=[],sidecar_xmp_written=[],sidecar_xmp_skipped=[],missing=[],error=[],exiftool_warning=[],exiftool_error=[])"
)

View File

@@ -32,15 +32,23 @@ def photosdb():
def test_search_info(photosdb):
for uuid in UUID_SEARCH_INFO:
photo = photosdb.get_photo(uuid)
assert photo.search_info.asdict() == UUID_SEARCH_INFO[uuid]
search_dict = photo.search_info.asdict()
for k, v in search_dict.items():
if type(v) == list:
assert sorted(v) == sorted(UUID_SEARCH_INFO[uuid][k])
else:
assert v == UUID_SEARCH_INFO[uuid][k]
def test_search_info_normalized(photosdb):
for uuid in UUID_SEARCH_INFO_NORMALIZED:
photo = photosdb.get_photo(uuid)
assert (
photo.search_info_normalized.asdict() == UUID_SEARCH_INFO_NORMALIZED[uuid]
)
search_dict = photo.search_info_normalized.asdict()
for k, v in search_dict.items():
if type(v) == list:
assert sorted(v) == sorted(UUID_SEARCH_INFO_NORMALIZED[uuid][k])
else:
assert v == UUID_SEARCH_INFO_NORMALIZED[uuid][k]
def test_search_info_all(photosdb):