Compare commits

..

53 Commits

Author SHA1 Message Date
Rhet Turnbull
4678f15bc8 Version bump 2020-12-17 19:48:23 -08:00
Rhet Turnbull
a7c688cfc2 Fixed issue #296 2020-12-17 19:47:22 -08:00
Rhet Turnbull
880a9b67a1 Added additional test cases for #286, --ignore-signature 2020-12-17 15:21:34 -08:00
Rhet Turnbull
d40b16a456 Updated README.md 2020-12-16 21:56:19 -08:00
Rhet Turnbull
dcd2fde6d0 Updated CHANGELOG.md 2020-12-16 21:52:57 -08:00
Rhet Turnbull
ad860b1500 Add @finestream as a contributor 2020-12-16 21:50:47 -08:00
Rhet Turnbull
7ad4db6c15 Help text update 2020-12-16 21:48:47 -08:00
Rhet Turnbull
0f1cc7cc71 Merge pull request #295 from finestream/master
Documentation fix for #293. Thanks to @finestream
2020-12-16 21:42:06 -08:00
Rhet Turnbull
5e6a6cd5fb Updated CHANGELOG.md 2020-12-16 20:27:10 -08:00
Rhet Turnbull
e394d8e6be Implemented --ignore-signature, issue #286 2020-12-16 20:11:01 -08:00
finestream
8237bc8267 Merge pull request #1 from finestream/patch-1
Patch 1
2020-12-16 21:48:22 +01:00
finestream
e097f3aad5 Update __main__.py
Possible fix of Issue RhetTbull/osxphotos#293
2020-12-16 21:25:52 +01:00
finestream
3155045ec8 Update README.md
Fixed language
2020-12-16 21:17:08 +01:00
finestream
4f64eeb996 Update README.md
Possible documentation improvement to Issue #293
2020-12-16 20:58:04 +01:00
Rhet Turnbull
3c14ace826 Updated CHANGELOG.md 2020-12-13 22:30:14 -08:00
Rhet Turnbull
d5730dd8ae Fix for issue #263 2020-12-13 22:18:39 -08:00
Rhet Turnbull
5c1c0c5c5a Updated CHANGELOG.md 2020-12-13 22:05:33 -08:00
Rhet Turnbull
d8593a01e2 Fix for QuickTime date/time, issue #282 2020-12-12 22:13:01 -08:00
Rhet Turnbull
1dffe894ff Updated CHANGELOG.md 2020-12-12 08:08:44 -08:00
Rhet Turnbull
29721dd4f0 Merge pull request #290 from RhetTbull/save_config
Added --save-config, --load-config
2020-12-12 07:58:04 -08:00
Rhet Turnbull
6559c4d8f6 removed extended_attributes reference 2020-12-12 07:53:18 -08:00
Rhet Turnbull
baf45ccd2a This is why I never use branches 2020-12-12 07:51:36 -08:00
Rhet Turnbull
aca85ee2aa Version bump 2020-12-12 07:45:24 -08:00
Rhet Turnbull
9584a9ccc5 Merge branch 'master' into save_config 2020-12-12 07:38:35 -08:00
Rhet Turnbull
182b816e34 Updated README.md for --save-config, --load-config 2020-12-12 07:29:16 -08:00
Rhet Turnbull
0262e0d97e Added tests for configoptions.py 2020-12-12 07:25:50 -08:00
Rhet Turnbull
73f936e061 Added link to discussions 2020-12-11 06:19:05 -08:00
Rhet Turnbull
09687cfca4 Initial test for --save-config, --load-config 2020-12-11 06:12:32 -08:00
Rhet Turnbull
e17ee0e388 Updated CHANGELOG.md 2020-12-10 20:48:19 -08:00
Rhet Turnbull
ec4b53ed9d Refactored FileUtil to use copy-on-write no APFS, issue #287 2020-12-10 20:29:47 -08:00
Rhet Turnbull
d7c81adae8 Updated validate code 2020-12-09 20:17:49 -08:00
Rhet Turnbull
37b1e5ca47 Refactoring of save-config/load-config code 2020-12-08 08:32:37 -08:00
Rhet Turnbull
22355fd446 Initial implementation of configoptions for --save-config, --load-config 2020-12-08 07:22:40 -08:00
Rhet Turnbull
d8de86cb6f Updated CHANGELOG.md 2020-12-06 22:10:20 -08:00
Rhet Turnbull
11f563a479 Fix for issue #262 2020-12-06 22:02:47 -08:00
Rhet Turnbull
f75ed17f9c Updated CHANGELOG.md 2020-12-05 21:36:12 -08:00
Rhet Turnbull
e5d6f21d8e Added --cleanup, issue #262 2020-12-05 21:22:49 -08:00
Rhet Turnbull
d371e63022 Updated CHANGELOG.md 2020-12-05 09:03:29 -08:00
Rhet Turnbull
1b6a03a9f8 Fix for issue #257, #275 2020-12-05 08:55:23 -08:00
Rhet Turnbull
0708a42155 Updated CHANGELOG.md 2020-12-05 07:27:40 -08:00
Rhet Turnbull
69cd236712 Merge branch 'master' of github.com:RhetTbull/osxphotos 2020-12-05 07:19:18 -08:00
Rhet Turnbull
4cce9d4939 Implement fix for issue #282, QuickTime metadata 2020-12-05 07:18:49 -08:00
Rhet Turnbull
cfb07cbfaf Implement fix for issue #282, QuickTime metadata 2020-12-05 07:17:26 -08:00
Rhet Turnbull
1eff6bae9e Updated README.md 2020-12-01 21:19:23 -08:00
Rhet Turnbull
435da2a5dd Updated CHANGELOG.md 2020-11-29 18:43:45 -08:00
Rhet Turnbull
ed3a9711dc Removed --use-photokit authorization check, issue 278 2020-11-29 18:26:55 -08:00
Rhet Turnbull
1bc0926948 Updated CHANGELOG.md 2020-11-29 18:26:00 -08:00
Rhet Turnbull
25eacc7cad Added --missing to export, see issue #277 2020-11-29 15:30:45 -08:00
Rhet Turnbull
d9dcf0917a Catch errors in export_photo 2020-11-28 20:00:10 -08:00
Rhet Turnbull
4f36c7c948 Updated CHANGELOG.md 2020-11-28 09:27:12 -08:00
Rhet Turnbull
d22eaf39ed Added --report option to CLI, implements #253 2020-11-28 09:24:16 -08:00
Rhet Turnbull
adf2ba7678 Updated CHANGELOG.md 2020-11-27 17:00:53 -08:00
Rhet Turnbull
af827d7a57 Updated template values 2020-11-27 16:58:11 -08:00
59 changed files with 3715 additions and 871 deletions

View File

@@ -109,6 +109,15 @@
"contributions": [
"code"
]
},
{
"login": "finestream",
"name": "finestream",
"avatar_url": "https://avatars1.githubusercontent.com/u/16638513?v=4",
"profile": "https://github.com/finestream",
"contributions": [
"doc"
]
}
],
"contributorsPerLine": 7

View File

@@ -4,6 +4,99 @@ 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.38.5](https://github.com/RhetTbull/osxphotos/compare/v0.38.4...v0.38.5)
> 17 December 2020
- Patch 1 [`#1`](https://github.com/RhetTbull/osxphotos/pull/1)
- Implemented --ignore-signature, issue #286 [`e394d8e`](https://github.com/RhetTbull/osxphotos/commit/e394d8e6be7607a1668029bcb37ccb30a4fa792f)
- Update __main__.py [`e097f3a`](https://github.com/RhetTbull/osxphotos/commit/e097f3aad546b5be5eabab529bd2c35ce3056876)
- Update README.md [`4f64eeb`](https://github.com/RhetTbull/osxphotos/commit/4f64eeb996d43953eb90618465d2bd046282c4bb)
- Update README.md [`3155045`](https://github.com/RhetTbull/osxphotos/commit/3155045ec87d83285f2e66210559f4be0a10e3a2)
#### [v0.38.4](https://github.com/RhetTbull/osxphotos/compare/v0.38.3...v0.38.4)
> 14 December 2020
- Fix for issue #263 [`d5730dd`](https://github.com/RhetTbull/osxphotos/commit/d5730dd8ae92bc819b61ab4df9b10ae64e23569f)
#### [v0.38.3](https://github.com/RhetTbull/osxphotos/compare/v0.38.2...v0.38.3)
> 13 December 2020
- Fix for QuickTime date/time, issue #282 [`d8593a0`](https://github.com/RhetTbull/osxphotos/commit/d8593a01e210a0b914d5668ad5f70976fc43b217)
#### [v0.38.2](https://github.com/RhetTbull/osxphotos/compare/v0.38.0...v0.38.2)
> 12 December 2020
- Added --save-config, --load-config [`#290`](https://github.com/RhetTbull/osxphotos/pull/290)
- removed extended_attributes reference [`6559c4d`](https://github.com/RhetTbull/osxphotos/commit/6559c4d8f64ad41df925182f9f24f6f67eecd1df)
- This is why I never use branches [`baf45cc`](https://github.com/RhetTbull/osxphotos/commit/baf45ccd2aa24858bb1a8f95ef798121ee80af30)
- Version bump [`aca85ee`](https://github.com/RhetTbull/osxphotos/commit/aca85ee2aa01fcdece0224332584082280a3f62c)
- Merge branch 'master' into save_config [`9584a9c`](https://github.com/RhetTbull/osxphotos/commit/9584a9ccc56ac8c6dc5eb96019adf9224f436690)
- Added tests for configoptions.py [`0262e0d`](https://github.com/RhetTbull/osxphotos/commit/0262e0d97e06ee36786b4491efa178608afb5de5)
#### [v0.38.0](https://github.com/RhetTbull/osxphotos/compare/v0.37.7...v0.38.0)
> 11 December 2020
- Initial implementation of configoptions for --save-config, --load-config [`22355fd`](https://github.com/RhetTbull/osxphotos/commit/22355fd44609f42e412c580dfc9e5e0b7cf6c464)
- Refactoring of save-config/load-config code [`37b1e5c`](https://github.com/RhetTbull/osxphotos/commit/37b1e5ca472e9679301fa96d2b7fdd8c4ad438b2)
- Refactored FileUtil to use copy-on-write no APFS, issue #287 [`ec4b53e`](https://github.com/RhetTbull/osxphotos/commit/ec4b53ed9dd2bc1e6b71349efdaf0b81c6d797e5)
#### [v0.37.7](https://github.com/RhetTbull/osxphotos/compare/v0.37.6...v0.37.7)
> 7 December 2020
- Fix for issue #262 [`11f563a`](https://github.com/RhetTbull/osxphotos/commit/11f563a47926798295e24872bc0efcaaba35906f)
#### [v0.37.6](https://github.com/RhetTbull/osxphotos/compare/v0.37.5...v0.37.6)
> 6 December 2020
- Added --cleanup, issue #262 [`e5d6f21`](https://github.com/RhetTbull/osxphotos/commit/e5d6f21d8e85f092fd0cc06ea4a0eaa12834c011)
#### [v0.37.5](https://github.com/RhetTbull/osxphotos/compare/v0.37.4...v0.37.5)
> 5 December 2020
- Fix for issue #257, #275 [`1b6a03a`](https://github.com/RhetTbull/osxphotos/commit/1b6a03a9f8c76cb5e50caab6eb138a56ccd841dd)
#### [v0.37.4](https://github.com/RhetTbull/osxphotos/compare/v0.37.3...v0.37.4)
> 5 December 2020
- Merge branch 'master' of github.com:RhetTbull/osxphotos [`69cd236`](https://github.com/RhetTbull/osxphotos/commit/69cd2367122a3a86044df2845e706d3510bdf2c1)
- Implement fix for issue #282, QuickTime metadata [`4cce9d4`](https://github.com/RhetTbull/osxphotos/commit/4cce9d4939a00ad2d265a510a2c6f0c8e6a8c655)
- Implement fix for issue #282, QuickTime metadata [`cfb07cb`](https://github.com/RhetTbull/osxphotos/commit/cfb07cbfafaac493f6221be482c432812534ddfa)
#### [v0.37.3](https://github.com/RhetTbull/osxphotos/compare/v0.37.2...v0.37.3)
> 30 November 2020
- Removed --use-photokit authorization check, issue 278 [`ed3a971`](https://github.com/RhetTbull/osxphotos/commit/ed3a9711dc0805aed1aacc30e01eeb9c1077d9e1)
#### [v0.37.2](https://github.com/RhetTbull/osxphotos/compare/v0.37.1...v0.37.2)
> 29 November 2020
- Catch errors in export_photo [`d9dcf09`](https://github.com/RhetTbull/osxphotos/commit/d9dcf0917a541725d1e472e7f918733e4e2613d0)
- Added --missing to export, see issue #277 [`25eacc7`](https://github.com/RhetTbull/osxphotos/commit/25eacc7caddd6721232b3f77a02532fcd35f7836)
#### [v0.37.1](https://github.com/RhetTbull/osxphotos/compare/v0.37.0...v0.37.1)
> 28 November 2020
- Added --report option to CLI, implements #253 [`d22eaf3`](https://github.com/RhetTbull/osxphotos/commit/d22eaf39edc8b0b489b011d6d21345dcedcc8dff)
- Updated template values [`af827d7`](https://github.com/RhetTbull/osxphotos/commit/af827d7a5769f41579d300a7cc511251d86b7eed)
#### [v0.37.0](https://github.com/RhetTbull/osxphotos/compare/v0.36.25...v0.37.0)
> 28 November 2020
- Added {exiftool} template, implements issue #259 [`48acb42`](https://github.com/RhetTbull/osxphotos/commit/48acb42631226a71bfc636eea2d3151f1b7165f4)
#### [v0.36.25](https://github.com/RhetTbull/osxphotos/compare/v0.36.24...v0.36.25)
> 27 November 2020

229
README.md
View File

@@ -3,7 +3,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python package](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-11-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-12-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
- [OSXPhotos](#osxphotos)
@@ -45,7 +45,7 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.6 / Photos 5.0.
Alpha support for MacOS 10.16/MacOS 11 Big Sur Beta / Photos 6.0.
Beta support for MacOS 10.16/MacOS 11 Big Sur Beta / Photos 6.0.
Requires python >= 3.7.
@@ -119,12 +119,12 @@ Example: `osxphotos help export`
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
Export photos from the Photos database. Export path DEST is required.
Optionally, query the Photos database using 1 or more search options; if
more than one option is provided, they are treated as "AND" (e.g. search
Optionally, query the Photos database using 1 or more search options; if
more than one option is provided, they are treated as "AND" (e.g. search
for photos matching all options). If no query options are provided, all
photos will be exported. By default, all versions of all photos will be
exported including edited versions, live photo movies, burst photos, and
associated raw images. See --skip-edited, --skip-live, --skip-bursts, and
associated raw images. See --skip-edited, --skip-live, --skip-bursts, and
--skip-raw options to modify this behavior.
Options:
@@ -227,14 +227,27 @@ Options:
--no-comment Search for photos with no comments.
--has-likes Search for photos that have likes.
--no-likes Search for photos with no likes.
--missing Export only photos missing from the Photos
library; must be used with --download-
missing.
--deleted Include photos from the 'Recently Deleted'
folder.
--deleted-only Include only photos from the 'Recently
Deleted' folder.
--update Only export new or updated files. See notes
below on export and --update.
--ignore-signature When used with --update, ignores file
signature when updating files. This is
useful if you have processed or edited
exported photos changing the file signature
(size & modification date). In this case,
--update would normally re-export the
processed files but with --ignore-signature,
files which exist in the export directory
will not be re-exported.
--dry-run Dry run (test) the export but don't actually
export any files; most useful with --verbose
export any files; most useful with
--verbose.
--export-as-hardlink Hardlink files instead of copying them.
Cannot be used with --exiftool which creates
copies of the files with embedded EXIF data.
@@ -261,6 +274,79 @@ Options:
photos if the raw photo does not have an
associated jpeg image (e.g. the raw file was
imported to Photos without a jpeg preview).
--current-name Use photo's current filename instead of
original filename for export. Note:
Starting with Photos 5, all photos are
renamed upon import. By default, photos are
exported with the the original name they had
before import.
--convert-to-jpeg Convert all non-jpeg images (e.g. raw, HEIC,
PNG, etc) to JPEG upon export. Only works
if your Mac has a GPU.
--jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with
--convert-to-jpeg. A value of 1.0 specifies
best quality, a value of 0.0 specifies
maximum compression. Defaults to 1.0
--download-missing Attempt to download missing photos from
iCloud. The current implementation uses
Applescript to interact with Photos to
export the photo which will force Photos to
download from iCloud if the photo does not
exist on disk. This will be slow and will
require internet connection. This obviously
only works if the Photos library is synched
to iCloud. Note: --download-missing does
not currently export all burst images; only
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
-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.
--exiftool Use exiftool to write metadata directly to
exported photos. To use this option,
exiftool must be installed and in the path.
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: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.
--ignore-date-modified If used with --exiftool or --sidecar, will
ignore the photo modification date and set
EXIF:ModifyDate to EXIF:DateTimeOriginal;
this is consistent with how Photos handles
the EXIF:ModifyDate tag.
--person-keyword Use person in image as keyword/tag when
exporting metadata.
--album-keyword Use album name as keyword/tag when exporting
@@ -287,53 +373,6 @@ Options:
could specify --description-template
"{descr} exported with osxphotos on
{today.date}" See Templating System below.
--current-name Use photo's current filename instead of
original filename for export. Note:
Starting with Photos 5, all photos are
renamed upon import. By default, photos are
exported with the the original name they had
before import.
--convert-to-jpeg Convert all non-jpeg images (e.g. raw, HEIC,
PNG, etc) to JPEG upon export. Only works
if your Mac has a GPU.
--jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with
--convert-to-jpeg. A value of 1.0 specifies
best quality, a value of 0.0 specifies
maximum compression. Defaults to 1.0.
--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
-j=photoname.json photoname.jpg" The sidecar
file is named in format photoname.json
--sidecar xmp: create XMP sidecar used by
Adobe Lightroom, etc.The sidecar file is
named in format photoname.xmp
--download-missing Attempt to download missing photos from
iCloud. The current implementation uses
Applescript to interact with Photos to
export the photo which will force Photos to
download from iCloud if the photo does not
exist on disk. This will be slow and will
require internet connection. This obviously
only works if the Photos library is synched
to iCloud. Note: --download-missing does
not currently export all burst images; only
the primary photo will be exported--
associated burst images will be skipped.
--exiftool Use exiftool to write metadata directly to
exported photos. To use this option,
exiftool must be installed and in the path.
exiftool may be installed from
https://exiftool.org/. Cannot be used with
--export-as-hardlink.
--ignore-date-modified If used with --exiftool or --sidecar, will
ignore the photo modification date and set
EXIF:ModifyDate to EXIF:DateTimeOriginal;
this is consistent with how Photos handles
the EXIF:ModifyDate tag.
--directory DIRECTORY Optional template for specifying name of
output directory in the form
'{name,DEFAULT}'. See below for additional
@@ -344,25 +383,24 @@ Options:
do not include an extension in the FILENAME
template. See below for additional details
on templating system.
--edited-suffix SUFFIX Optional suffix for naming edited photos.
Default name for edited photos is in form
'photoname_edited.ext'. For example, with '
--edited-suffix _bearbeiten', the edited
photo would be named
--edited-suffix SUFFIX Optional suffix template for naming edited
photos. Default name for edited photos is
in form 'photoname_edited.ext'. For example,
with '--edited-suffix _bearbeiten', the
edited photo would be named
'photoname_bearbeiten.ext'. The default
suffix is '_edited'.
--original-suffix SUFFIX Optional suffix for naming original photos.
Default name for original photos is in form
'filename.ext'. For example, with '--
original-suffix _original', the original
suffix is '_edited'. Multi-value templates
(see Templating System) are not permitted
with --edited-suffix.
--original-suffix SUFFIX Optional suffix template for naming original
photos. Default name for original photos is
in form 'filename.ext'. For example, with '
--original-suffix _original', the original
photo would be named
'filename_original.ext'. The default suffix
is '' (no suffix).
--no-extended-attributes Don't copy extended attributes when
exporting. You only need this if exporting
to a filesystem that doesn't support Mac OS
extended attributes. Only use this if you
get an error while exporting.
is '' (no suffix). Multi-value templates
(see Templating System) are not permitted
with --original-suffix.
--use-photos-export Force the use of AppleScript or PhotoKit to
export even if not missing (see also '--
download-missing' and '--use-photokit').
@@ -373,6 +411,29 @@ Options:
work with iTerm2 (use with Terminal.app).
This is faster and more reliable than the
default AppleScript interface.
--report <path to export report>
Write a CSV formatted report of all files
that were exported.
--cleanup Cleanup export directory by deleting any
files which were not included in this export
set. For example, photos which had
previously been exported and were
subsequently deleted in Photos.
--load-config <config file path>
Load options from file as written with
--save-config. This allows you to save a
complex export command to file for later
reuse. For example: 'osxphotos export <lots
of options here> --save-config
osxphotos.toml' then 'osxphotos export
/path/to/export --load-config
osxphotos.toml'. If any other command line
options are used in conjunction with --load-
config, they will override the corresponding
values in the config file.
--save-config <config file path>
Save options to file for use with --load-
config. File format is TOML.
-h, --help Show this message and exit.
** Export **
@@ -387,7 +448,10 @@ the export folder. If a file is changed in the export folder (for example,
you edited the exported image), osxphotos will detect this as a difference and
re-export the original image from the library thus overwriting the changes.
If using --update, the exported library should be treated as a backup, not a
working copy where you intend to make changes.
working copy where you intend to make changes. If you do edit or process the
exported files and do not want them to be overwritten withsubsequent --update,
use --ignore-signature which will match filename but not file signature when
exporting.
Note: The number of files reported for export and the number actually exported
may differ due to live photos, associated raw images, and edited photos which
@@ -410,6 +474,10 @@ will be rendered to substitute template fields with values from the photo.
For example, '{created.month}' would be replaced with the month name of the
photo creation date. e.g. 'November'.
Some options supporting templates may be repeated e.g., --keyword-template
'{label}' --keyword-template '{media_type}' to add both labels and media
types to the keywords.
The general format for a template is '{TEMPLATE_FIELD[,[DEFAULT]]}'. Some
templates have optional modifiers in form
'{[[DELIM]+]TEMPLATE_FIELD[(PATH_SEP)][?VALUE_IF_TRUE][,[DEFAULT]]}'
@@ -521,6 +589,9 @@ Substitution Description
'{photo_or_video,photo=fotos;video=videos}'
{hdr} Photo is HDR?; True/False value, use in
format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'
{edited} Photo has been edited (has adjustments)?;
True/False value, use in format
'{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'
{created.date} Photo's creation date in ISO format, e.g.
'2020-03-22'
{created.year} 4-digit year of photo creation time
@@ -674,6 +745,10 @@ Example: export photos to file structure based on 4-digit year and full name of
`osxphotos export ~/Desktop/export --directory "{created.year}/{created.month}"`
Example: export photos to file structure based on 4-digit year of photo's creation date and add keywords for media type and labels (labels are only awailable on Photos 5 and higher):
`osxphotos export ~/Desktop/export --directory "{created.year}" --keyword-template "{label}" --keyword-template "{media_type}"`
Example: export default library using 'country name/year' as output directory (but use "NoCountry/year" if country not specified), add persons, album names, and year as keywords, write exif metadata to files when exporting, update only changed files, print verbose ouput
`osxphotos export ~/Desktop/export --directory "{place.name.country,NoCountry}/{created.year}" --person-keyword --album-keyword --keyword-template "{created.year}" --exiftool --update --verbose`
@@ -1450,7 +1525,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, no_xattr=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_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).
@@ -1465,7 +1540,6 @@ Export photo from the Photos library to another destination on disk.
- 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
- exiftool: (boolean, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path
- no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
- use_albums_as_keywords: (boolean, default = False); if True, will use album names as keywords when exporting metadata with exiftool or sidecar
- use_persons_as_keywords: (boolean, default = False); if True, will use person names as keywords when exporting metadata with exiftool or sidecar
@@ -1487,7 +1561,6 @@ Then
If overwrite=False and increment=False, export will fail if destination file already exists
**Implementation Note**: Because the usual python file copy methods don't preserve all the metadata available on MacOS, export uses `/usr/bin/ditto` to do the copy for export. ditto preserves most metadata such as extended attributes, permissions, ACLs, etc.
#### <a name="rendertemplate">`render_template()`</a>
@@ -1995,6 +2068,7 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{media_type}|Special media type resolved in this precedence: selfie, time_lapse, panorama, slow_mo, screenshot, portrait, live_photo, burst, photo, video. Defaults to 'photo' or 'video' if no special type. Customize one or more media types using format: '{media_type,video=vidéo;time_lapse=vidéo_accélérée}'|
|{photo_or_video}|'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'|
|{hdr}|Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'|
|{edited}|Photo has been edited (has adjustments)?; True/False value, use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'|
|{created.date}|Photo's creation date in ISO format, e.g. '2020-03-22'|
|{created.year}|4-digit year of photo creation time|
|{created.yy}|2-digit year of photo creation time|
@@ -2053,6 +2127,7 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{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.|
### Utility Functions
@@ -2144,7 +2219,7 @@ if __name__ == "__main__":
## Contributing
Contributing is easy! if you find bugs or want to suggest additional features/changes, please open an [issue](https://github.com/rhettbull/osxphotos/issues/).
Contributing is easy! if you find bugs or want to suggest additional features/changes, please open an [issue](https://github.com/rhettbull/osxphotos/issues/) or join the [discussion](https://github.com/RhetTbull/osxphotos/discussions).
I'll gladly consider pull requests for bug fixes or feature implementations.
@@ -2175,6 +2250,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/agprimatic"><img src="https://avatars1.githubusercontent.com/u/4685054?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ag Primatic</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=agprimatic" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hhoeck"><img src="https://avatars1.githubusercontent.com/u/6313998?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Horst Höck</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hhoeck" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jstrine"><img src="https://avatars1.githubusercontent.com/u/33943447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jonathan Strine</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jstrine" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/finestream"><img src="https://avatars1.githubusercontent.com/u/16638513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>finestream</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=finestream" title="Documentation">📖</a></td>
</tr>
</table>
@@ -2199,8 +2275,6 @@ This package works by creating a copy of the sqlite3 database that photos uses t
If apple changes the database format this will likely break.
Apple does provide a framework ([PhotoKit](https://developer.apple.com/documentation/photokit?language=objc)) for querying the user's Photos library and I attempted to create the functionality in this package using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons) and Apple's System Integrity Protection (SIP) made the interface unreliable. If you'd like to experiment with the PhotoKit interface, here's some sample [code](https://gist.github.com/RhetTbull/41cc85e5bdeb30f761147ce32fba5c94). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
For additional details about how osxphotos is implemented or if you would like to extend the code, see the [wiki](https://github.com/RhetTbull/osxphotos/wiki).
## Dependencies
@@ -2211,9 +2285,10 @@ For additional details about how osxphotos is implemented or if you would like t
- [bpylist2](https://pypi.org/project/bpylist2/)
- [pathvalidate](https://pypi.org/project/pathvalidate/)
- [wurlitzer](https://pypi.org/project/wurlitzer/)
- [toml](https://github.com/uiri/toml)
## Acknowledgements
This project was originally inspired by [photo-export](https://github.com/patrikhson/photo-export) by Patrick Fältström, Copyright (c) 2015 Patrik Fältström paf@frobbit.se
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.

File diff suppressed because it is too large Load Diff

View File

@@ -109,3 +109,13 @@ MAX_FILENAME_LEN = 255
# Max directory name length on MacOS
MAX_DIRNAME_LEN = 255
# Default JPEG quality when converting to JPEG
DEFAULT_JPEG_QUALITY = 1.0
# Default suffix to add to edited images
DEFAULT_EDITED_SUFFIX = "_edited"
# Default suffix to add to original images
DEFAULT_ORIGINAL_SUFFIX = ""

View File

@@ -1,4 +1,5 @@
""" version info """
__version__ = "0.37.0"
__version__ = "0.38.6"

173
osxphotos/configoptions.py Normal file
View File

@@ -0,0 +1,173 @@
""" ConfigOptions class to load/save config settings for osxphotos CLI """
import toml
class ConfigOptionsException(Exception):
""" Invalid combination of options. """
def __init__(self, message):
self.message = message
super().__init__(self.message)
class ConfigOptionsInvalidError(ConfigOptionsException):
pass
class ConfigOptionsLoadError(ConfigOptionsException):
pass
class ConfigOptions:
""" data class to store and load options for osxphotos commands """
def __init__(self, name, attrs, ignore=None):
""" init ConfigOptions class
Args:
name: name for these options, will be used for section heading in TOML file when saving/loading from file
attrs: dict with name and default value for all allowed attributes
ignore: optional list of strings of keys to ignore from attrs dict
"""
self._name = name
self._attrs = attrs.copy()
if ignore:
for attrname in ignore:
self._attrs.pop(attrname, None)
self.set_attributes(attrs)
def set_attributes(self, args):
for attr in self._attrs:
try:
arg = args[attr]
# don't test 'not arg'; need to handle empty strings as valid values
if arg is None or arg == False:
if type(self._attrs[attr]) == tuple:
setattr(self, attr, ())
else:
setattr(self, attr, self._attrs[attr])
else:
setattr(self, attr, arg)
except KeyError:
raise KeyError(f"Missing argument: {attr}")
def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False):
""" validate combinations of otions
Args:
exclusive: list of tuples in form [("option_1", "option_2")...] which are exclusive;
ie. either option_1 can be set or option_2 but not both;
inclusive: list of tuples in form [("option_1", "option_2")...] which are inclusive;
ie. if either option_1 or option_2 is set, the other must be set
dependent: list of tuples in form [("option_1", ("option_2", "option_3"))...]
where if option_1 is set, then at least one of the options in the second tuple must also be set
cli: bool, set to True if called to validate CLI options;
will prepend '--' to option names in InvalidOptions.message and change _ to - in option names
Returns:
True if all options valid
Raises:
InvalidOption if any combination of options is invalid
InvalidOption.message will be descriptive message of invalid options
"""
if not any([exclusive, inclusive, dependent]):
return True
prefix = "--" if cli else ""
if exclusive:
for a, b in exclusive:
vala = getattr(self, a)
valb = getattr(self, b)
vala = any(vala) if isinstance(vala, tuple) else vala
valb = any(valb) if isinstance(valb, tuple) else valb
if vala and valb:
stra = a.replace("_", "-") if cli else a
strb = b.replace("_", "-") if cli else b
raise ConfigOptionsInvalidError(
f"{prefix}{stra} and {prefix}{strb} options cannot be used together."
)
if inclusive:
for a, b in inclusive:
vala = getattr(self, a)
valb = getattr(self, b)
vala = any(vala) if isinstance(vala, tuple) else vala
valb = any(valb) if isinstance(valb, tuple) else valb
if any([vala, valb]) and not all([vala, valb]):
stra = a.replace("_", "-") if cli else a
strb = b.replace("_", "-") if cli else b
raise ConfigOptionsInvalidError(
f"{prefix}{stra} and {prefix}{strb} options must be used together."
)
if dependent:
for a, b in dependent:
vala = getattr(self, a)
if not isinstance(b, tuple):
# python unrolls the tuple if there's a single element
b = (b,)
valb = [getattr(self, x) for x in b]
valb = [any(x) if isinstance(x, tuple) else x for x in valb]
if vala and not any(valb):
if cli:
stra = prefix + a.replace("_", "-")
strb = ", ".join(prefix + x.replace("_", "-") for x in b)
else:
stra = a
strb = ", ".join(b)
raise ConfigOptionsInvalidError(
f"{stra} must be used with at least one of: {strb}."
)
return True
def write_to_file(self, filename):
""" Write self to TOML file
Args:
filename: full path to TOML file to write; filename will be overwritten if it exists
"""
# todo: add overwrite and option to merge contents already in TOML file (under different [section] with new content)
data = {}
for attr in sorted(self._attrs.keys()):
val = getattr(self, attr)
if val in [False, ()]:
val = None
else:
val = list(val) if type(val) == tuple else val
data[attr] = val
with open(filename, "w") as fd:
toml.dump({self._name: data}, fd)
def load_from_file(self, filename, override=False):
""" Load options from a TOML file.
Args:
filename: full path to TOML file
override: bool; if True, values in the TOML file will override values already set in the instance
Raises:
ConfigOptionsLoadError if there are any errors during the parsing of the TOML file
"""
loaded = toml.load(filename)
name = self._name
if name not in loaded:
raise ConfigOptionsLoadError(f"[{name}] section missing from {filename}")
for attr in loaded[name]:
if attr not in self._attrs:
raise ConfigOptionsLoadError(
f"Unknown option: {attr} = {loaded[name][attr]}"
)
val = loaded[name][attr]
if not override:
# use value from self if set
val = getattr(self, attr) or val
if type(self._attrs[attr]) == tuple:
val = tuple(val)
setattr(self, attr, val)
return self
def asdict(self):
return {attr: getattr(self, attr) for attr in sorted(self._attrs.keys())}

View File

@@ -1,10 +1,10 @@
""" datetime utilities """
""" datetime.datetime helper functions for converting to/from UTC """
import datetime
def get_local_tz(dt):
""" return local timezone as datetime.timezone tzinfo for dt
""" Return local timezone as datetime.timezone tzinfo for dt
Args:
dt: datetime.datetime
@@ -21,21 +21,18 @@ def get_local_tz(dt):
raise ValueError("dt must be naive datetime.datetime object")
def datetime_remove_tz(dt):
""" remove timezone from a datetime.datetime object
dt: datetime.datetime object with tzinfo
returns: dt without any timezone info (naive datetime object) """
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
return dt.replace(tzinfo=None)
def datetime_has_tz(dt):
""" return True if datetime dt has tzinfo else False
""" Return True if datetime dt has tzinfo else False
Args:
dt: datetime.datetime
returns True if dt is timezone aware, else False """
Returns:
True if dt is timezone aware, else False
Raises:
TypeError if dt is not a datetime.datetime object
"""
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
@@ -43,11 +40,90 @@ def datetime_has_tz(dt):
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
def datetime_naive_to_local(dt):
""" convert naive (timezone unaware) datetime.datetime
to aware timezone in local timezone
def datetime_tz_to_utc(dt):
""" Convert datetime.datetime object with timezone to UTC timezone
Args:
dt: datetime.datetime object
Returns:
datetime.datetime in UTC timezone
Raises:
TypeError if dt is not datetime.datetime object
ValueError if dt does not have timeone information
"""
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
return dt.replace(tzinfo=dt.tzinfo).astimezone(tz=datetime.timezone.utc)
else:
raise ValueError(f"dt does not have timezone info")
def datetime_remove_tz(dt):
""" Remove timezone from a datetime.datetime object
Args:
dt: datetime.datetime object with tzinfo
Returns:
dt without any timezone info (naive datetime object)
Raises:
TypeError if dt is not a datetime.datetime object
"""
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
return dt.replace(tzinfo=None)
def datetime_naive_to_utc(dt):
""" Convert naive (timezone unaware) datetime.datetime
to aware timezone in UTC timezone
Args:
dt: datetime.datetime without timezone
returns: datetime.datetime with local timezone """
Returns:
datetime.datetime with UTC timezone
Raises:
TypeError if dt is not a datetime.datetime object
ValueError if dt is not a naive/timezone unaware object
"""
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
# has timezone info
raise ValueError(
"dt must be naive/timezone unaware: "
f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tzinfo.utcoffset(dt)}"
)
return dt.replace(tzinfo=datetime.timezone.utc)
def datetime_naive_to_local(dt):
""" Convert naive (timezone unaware) datetime.datetime
to aware timezone in local timezone
Args:
dt: datetime.datetime without timezone
Returns:
datetime.datetime with local timezone
Raises:
TypeError if dt is not a datetime.datetime object
ValueError if dt is not a naive/timezone unaware object
"""
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
@@ -60,3 +136,26 @@ def datetime_naive_to_local(dt):
)
return dt.replace(tzinfo=get_local_tz(dt))
def datetime_utc_to_local(dt):
""" Convert datetime.datetime object in UTC timezone to local timezone
Args:
dt: datetime.datetime object
Returns:
datetime.datetime in local timezone
Raises:
TypeError if dt is not a datetime.datetime object
ValueError if dt is not in UTC timezone
"""
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
if dt.tzinfo is not datetime.timezone.utc:
raise ValueError(f"{dt} must be in UTC timezone: timezone = {dt.tzinfo}")
return dt.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None)

View File

@@ -7,8 +7,11 @@ import subprocess
import sys
from abc import ABC, abstractmethod
import CoreFoundation
from .imageconverter import ImageConverter
class FileUtilABC(ABC):
""" Abstract base class for FileUtil """
@@ -27,6 +30,11 @@ class FileUtilABC(ABC):
def unlink(cls, dest):
pass
@classmethod
@abstractmethod
def rmdir(cls, dest):
pass
@classmethod
@abstractmethod
def utime(cls, path, times):
@@ -76,35 +84,38 @@ class FileUtilMacOS(FileUtilABC):
raise e
@classmethod
def copy(cls, src, dest, norsrc=False):
def copy(cls, src, dest):
""" Copies a file from src path to dest path
src: source path as string
Args:
src: source path as string; must be a valid file path
dest: destination path as string
norsrc: (bool) if True, uses --norsrc flag with ditto so it will not copy
resource fork or extended attributes. May be useful on volumes that
don't work with extended attributes (likely only certain SMB mounts)
default is False
Uses ditto to perform copy; will silently overwrite dest if it exists
Raises exception if copy fails or either path is None """
dest may be either directory or file; in either case, src file must not exist in dest
Note: src and dest may be either a string or a pathlib.Path object
Returns:
True if copy succeeded
Raises:
OSError if copy fails
TypeError if either path is None
"""
if not isinstance(src, pathlib.Path):
src = pathlib.Path(src)
if src is None or dest is None:
raise ValueError("src and dest must not be None", src, dest)
if not isinstance(dest, pathlib.Path):
dest = pathlib.Path(dest)
if not os.path.exists(src):
raise FileNotFoundError("src file does not appear to exist", src)
if dest.is_dir():
dest /= src.name
if norsrc:
command = ["/usr/bin/ditto", "--norsrc", src, dest]
else:
command = ["/usr/bin/ditto", src, dest]
# if error on copy, subprocess will raise CalledProcessError
try:
result = subprocess.run(command, check=True, stderr=subprocess.PIPE)
except subprocess.CalledProcessError as e:
raise e
return result.returncode
filemgr = CoreFoundation.NSFileManager.defaultManager()
error = filemgr.copyItemAtPath_toPath_error_(str(src), str(dest), None)
# error is a tuple of (bool, error_string)
# error[0] is True if copy succeeded
if not error[0]:
raise OSError(error[1])
return True
@classmethod
def unlink(cls, filepath):
@@ -114,6 +125,14 @@ class FileUtilMacOS(FileUtilABC):
else:
os.unlink(filepath)
@classmethod
def rmdir(cls, dirpath):
""" remove directory filepath; dirpath must be empty """
if isinstance(dirpath, pathlib.Path):
dirpath.rmdir()
else:
os.rmdir(dirpath)
@classmethod
def utime(cls, path, times):
""" Set the access and modified time of path. """
@@ -164,7 +183,7 @@ class FileUtilMacOS(FileUtilABC):
def file_sig(cls, f1):
""" return os.stat signature for file f1 """
return cls._sig(os.stat(f1))
@classmethod
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
""" converts image file src_file to jpeg format as dest_file
@@ -178,7 +197,9 @@ class FileUtilMacOS(FileUtilABC):
True if success, otherwise False
"""
converter = ImageConverter()
return converter.write_jpeg(src_file, dest_file, compression_quality=compression_quality)
return converter.write_jpeg(
src_file, dest_file, compression_quality=compression_quality
)
@staticmethod
def _sig(st):
@@ -189,6 +210,7 @@ class FileUtilMacOS(FileUtilABC):
# use int(st.st_mtime) because ditto does not copy fractional portion of mtime
return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
class FileUtil(FileUtilMacOS):
""" Various file utilities """
@@ -228,6 +250,10 @@ class FileUtilNoOp(FileUtil):
def unlink(cls, dest):
cls.verbose(f"unlink: {dest}")
@classmethod
def rmdir(cls, dest):
cls.verbose(f"rmdir: {dest}")
@classmethod
def utime(cls, path, times):
cls.verbose(f"utime: {path}, {times}")

View File

@@ -33,6 +33,7 @@ from .._constants import (
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
)
from ..datetime_utils import datetime_tz_to_utc
from ..exiftool import ExifTool
from ..export_db import ExportDBNoOp
from ..fileutil import FileUtil
@@ -53,8 +54,13 @@ ExportResults = namedtuple(
"skipped",
"exif_updated",
"touched",
"sidecar_json",
"sidecar_xmp",
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
"missing",
"error",
],
)
@@ -79,27 +85,27 @@ def _export_photo_uuid_applescript(
burst=False,
dry_run=False,
):
""" Export photo to dest path using applescript to control Photos
If photo is a live photo, exports both the photo and associated .mov file
uuid: UUID of photo to export
dest: destination path to export to
filestem: (string) if provided, exported filename will be named stem.ext
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
If not provided, file will be named with whatever name Photos uses
If filestem.ext exists, it wil be overwritten
original: (boolean) if True, export original image; default = True
edited: (boolean) if True, export edited photo; default = False
If photo not edited and edited=True, will still export the original image
caller must verify image has been edited
*Note*: must be called with either edited or original but not both,
will raise error if called with both edited and original = True
live_photo: (boolean) if True, export associated .mov live photo; default = False
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
burst: (boolean) set to True if file is a burst image to avoid Photos export error
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
Returns: list of paths to exported file(s) or None if export failed
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
has not been edited. This is due to how Photos Applescript interface works.
"""Export photo to dest path using applescript to control Photos
If photo is a live photo, exports both the photo and associated .mov file
uuid: UUID of photo to export
dest: destination path to export to
filestem: (string) if provided, exported filename will be named stem.ext
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
If not provided, file will be named with whatever name Photos uses
If filestem.ext exists, it wil be overwritten
original: (boolean) if True, export original image; default = True
edited: (boolean) if True, export edited photo; default = False
If photo not edited and edited=True, will still export the original image
caller must verify image has been edited
*Note*: must be called with either edited or original but not both,
will raise error if called with both edited and original = True
live_photo: (boolean) if True, export associated .mov live photo; default = False
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
burst: (boolean) set to True if file is a burst image to avoid Photos export error
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
Returns: list of paths to exported file(s) or None if export failed
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
has not been edited. This is due to how Photos Applescript interface works.
"""
# setup the applescript to do the export
@@ -188,10 +194,10 @@ def _export_photo_uuid_applescript(
# _check_export_suffix is not a class method, don't import this into PhotoInfo
def _check_export_suffix(src, dest, edited):
"""Helper function for exporting photos to check file extensions of destination path.
Checks that dst file extension is appropriate for the src.
If edited=True, will use src file extension of ".jpeg" if None provided for src.
Args:
src: path to source file or None.
dest: path to destination file.
@@ -240,49 +246,48 @@ def export(
use_photos_export=False,
timeout=120,
exiftool=False,
no_xattr=False,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
):
""" export photo
dest: must be valid destination path (or exception raised)
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
export will print a warning but will export the photo using the
incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
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
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
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
returns list of full paths to the exported files
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
returns: list of photos exported
"""
"""export photo
dest: must be valid destination path (or exception raised)
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
export will print a warning but will export the photo using the
incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
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
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
returns list of full paths to the exported files
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
Returns: list of photos exported
"""
# Implementation note: calls export2 to actually do the work
@@ -300,7 +305,6 @@ def export(
use_photos_export=use_photos_export,
timeout=timeout,
exiftool=exiftool,
no_xattr=no_xattr,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
@@ -325,12 +329,12 @@ def export2(
use_photos_export=False,
timeout=120,
exiftool=False,
no_xattr=False,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
update=False,
ignore_signature=False,
export_db=None,
fileutil=FileUtil,
dry_run=False,
@@ -341,56 +345,68 @@ def export2(
use_photokit=False,
verbose=None,
):
""" export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
will export the photo using the incorrect file extension (unless use_photos_export is true,
in which case export will use the extension provided by Photos upon export.
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
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
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
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
update: (boolean, default=False); if True export will run in update mode, that is, it will
not export the photo if the current version already exists in the destination
export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods
for getting/setting data related to exported files to compare update state
fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities
dry_run: (boolean, default=False); set to True to run in "dry run" mode
touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
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: 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.
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
will export the photo using the incorrect file extension (unless use_photos_export is true,
in which case export will use the extension provided by Photos upon export.
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
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
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
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
update: (boolean, default=False); if True export will run in update mode, that is, it will
not export the photo if the current version already exists in the destination
ignore_signature: (bool, default=False), ignore file signature when used with update (look only at filename)
export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods
for getting/setting data related to exported files to compare update state
fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities
dry_run: (boolean, default=False); set to True to run in "dry run" mode
touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
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: 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.
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
where each field is a list of file paths
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
"""
Returns: ExportResults namedtuple with fields:
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
"missing",
"error"
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
"""
# NOTE: This function is very complex and does a lot of things.
# Don't modify this code if you don't fully understand everything it does.
@@ -425,6 +441,9 @@ def export2(
# list of all files with utime touched (touch_file = True)
touched_files = []
# list of all files convereted to jpeg
converted_to_jpeg_files = []
# check edited and raise exception trying to export edited version of
# photo that hasn't been edited
if edited and not self.hasadjustments:
@@ -583,7 +602,6 @@ def export2(
update,
export_db,
overwrite,
no_xattr,
export_as_hardlink,
exiftool,
touch_file,
@@ -591,12 +609,14 @@ def export2(
fileutil=fileutil,
edited=edited,
jpeg_quality=jpeg_quality,
ignore_signature=ignore_signature,
)
exported_files = results.exported
update_new_files = results.new
update_updated_files = results.updated
update_skipped_files = results.skipped
touched_files = results.touched
converted_to_jpeg_files = results.converted_to_jpeg
# copy live photo associated .mov if requested
if live_photo and self.live_photo:
@@ -610,18 +630,19 @@ def export2(
update,
export_db,
overwrite,
no_xattr,
export_as_hardlink,
exiftool,
touch_file,
False,
fileutil=fileutil,
ignore_signature=ignore_signature,
)
exported_files.extend(results.exported)
update_new_files.extend(results.new)
update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched)
converted_to_jpeg_files.extend(results.converted_to_jpeg)
# copy associated RAW image if requested
if raw_photo and self.has_raw:
@@ -635,19 +656,20 @@ def export2(
update,
export_db,
overwrite,
no_xattr,
export_as_hardlink,
exiftool,
touch_file,
convert_to_jpeg,
fileutil=fileutil,
jpeg_quality=jpeg_quality,
ignore_signature=ignore_signature,
)
exported_files.extend(results.exported)
update_new_files.extend(results.new)
update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched)
converted_to_jpeg_files.extend(results.converted_to_jpeg)
else:
# use_photo_export
exported = []
@@ -748,10 +770,10 @@ def export2(
)
# export metadata
sidecar_json_files = []
sidecar_json_files_skipped = []
sidecar_json_files_written = []
if sidecar_json:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json")
sidecar_json_files.append(str(sidecar_filename))
sidecar_str = self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
@@ -774,6 +796,7 @@ def export2(
)
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(
@@ -783,11 +806,12 @@ def export2(
)
else:
verbose(f"Skipped up to date exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_skipped.append(str(sidecar_filename))
sidecar_xmp_files = []
sidecar_xmp_files_skipped = []
sidecar_xmp_files_written = []
if sidecar_xmp:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp")
sidecar_xmp_files.append(str(sidecar_filename))
sidecar_str = self._xmp_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
@@ -810,6 +834,7 @@ def export2(
)
if write_sidecar:
verbose(f"Writing XMP sidecar {sidecar_filename}")
sidecar_xmp_files_written.append(str(sidecar_filename))
if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str)
export_db.set_sidecar_for_file(
@@ -819,6 +844,7 @@ def export2(
)
else:
verbose(f"Skipped up to date XMP sidecar {sidecar_filename}")
sidecar_xmp_files_skipped.append(str(sidecar_filename))
# if exiftool, write the metadata
if update:
@@ -912,14 +938,19 @@ def export2(
touched_files = list(set(touched_files))
results = ExportResults(
exported_files,
update_new_files,
update_updated_files,
update_skipped_files,
exif_files_updated,
touched_files,
sidecar_json_files,
sidecar_xmp_files,
exported=exported_files,
new=update_new_files,
updated=update_updated_files,
skipped=update_skipped_files,
exif_updated=exif_files_updated,
touched=touched_files,
converted_to_jpeg=converted_to_jpeg_files,
sidecar_json_written=sidecar_json_files_written,
sidecar_json_skipped=sidecar_json_files_skipped,
sidecar_xmp_written=sidecar_xmp_files_written,
sidecar_xmp_skipped=sidecar_xmp_files_skipped,
missing=[],
error=[],
)
return results
@@ -931,7 +962,6 @@ def _export_photo(
update,
export_db,
overwrite,
no_xattr,
export_as_hardlink,
exiftool,
touch_file,
@@ -939,20 +969,20 @@ def _export_photo(
fileutil=FileUtil,
edited=False,
jpeg_quality=1.0,
ignore_signature=None,
):
""" Helper function for export()
Does the actual copy or hardlink taking the appropriate
"""Helper function for export()
Does the actual copy or hardlink taking the appropriate
action depending on update, overwrite, export_as_hardlink
Assumes destination is the right destination (e.g. UUID matches)
sets UUID and JSON info foo exported file using set_uuid_for_file, set_inf_for_uuido
Args:
src: src path (string)
dest: dest path (pathlib.Path)
update: bool
export_db: instance of ExportDB that conforms to ExportDB_ABC interface
overwrite: bool
no_xattr: don't copy extended attributes
export_as_hardlink: bool
exiftool: bool
touch_file: bool
@@ -960,6 +990,7 @@ def _export_photo(
fileutil: FileUtil class that conforms to fileutil.FileUtilABC
edited: bool; set to True if exporting edited version of 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_signature: bool, ignore file signature when used with update (look only at filename)
Returns:
ExportResults
@@ -976,16 +1007,19 @@ def _export_photo(
update_new_files = []
update_skipped_files = []
touched_files = []
converted_to_jpeg_files = []
dest_str = str(dest)
dest_exists = dest.exists()
op_desc = "export_as_hardlink" if export_as_hardlink else "export_by_copying"
if update: # updating
cmp_touch, cmp_orig = False, False
if dest_exists:
# update, destination exists, but we might not need to replace it...
if exiftool:
if ignore_signature:
cmp_orig = True
cmp_touch = fileutil.cmp(src, dest, mtime1=int(self.date.timestamp()))
elif exiftool:
sig_exif = export_db.get_stat_exif_for_file(dest_str)
cmp_orig = fileutil.cmp_file_sig(dest_str, sig_exif)
sig_exif = (sig_exif[0], sig_exif[1], int(self.date.timestamp()))
@@ -1065,8 +1099,9 @@ def _export_photo(
# use convert_to_jpeg to export the file
fileutil.convert_to_jpeg(src, dest_str, compression_quality=jpeg_quality)
converted_stat = fileutil.file_sig(dest_str)
converted_to_jpeg_files.append(dest_str)
else:
fileutil.copy(src, dest_str, norsrc=no_xattr)
fileutil.copy(src, dest_str)
export_db.set_data(
filename=dest_str,
@@ -1084,14 +1119,19 @@ def _export_photo(
fileutil.utime(dest, (ts, ts))
return ExportResults(
exported_files + update_new_files + update_updated_files,
update_new_files,
update_updated_files,
update_skipped_files,
[],
touched_files,
[],
[],
exported=exported_files + update_new_files + update_updated_files,
new=update_new_files,
updated=update_updated_files,
skipped=update_skipped_files,
exif_updated=[],
touched=touched_files,
converted_to_jpeg=converted_to_jpeg_files,
sidecar_json_written=[],
sidecar_json_skipped=[],
sidecar_xmp_written=[],
sidecar_xmp_skipped=[],
missing=[],
error=[],
)
@@ -1104,10 +1144,10 @@ def _write_exif_data(
description_template=None,
ignore_date_modified=False,
):
""" write exif data to image file at filepath
"""write exif data to image file at filepath
Args:
filepath: full path to the image file
filepath: full path to the image file
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
@@ -1125,9 +1165,7 @@ def _write_exif_data(
with ExifTool(filepath) as exiftool:
for exiftag, val in exif_info.items():
if exiftag == "_CreatedBy":
continue
elif type(val) == list:
if type(val) == list:
for v in val:
exiftool.setvalue(exiftag, v)
else:
@@ -1142,7 +1180,7 @@ def _exiftool_dict(
description_template=None,
ignore_date_modified=False,
):
""" Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
"""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:
@@ -1155,12 +1193,12 @@ def _exiftool_dict(
Returns: dict with exiftool tags / values
Exports the following:
EXIF:ImageDescription
EXIF:ImageDescription (may include template)
XMP:Description (may include template)
XMP:Title
XMP:TagsList
XMP:TagsList (may include album name, person name, or template)
IPTC:Keywords (may include album name, person name, or template)
XMP:Subject
XMP:Subject (set to keywords + persons)
XMP:PersonInImage
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:GPSLatitude, EXIF:GPSLongitude
@@ -1170,10 +1208,14 @@ def _exiftool_dict(
EXIF:ModifyDate
IPTC:DateCreated
IPTC:TimeCreated
QuickTime:CreationDate
QuickTime:CreateDate (UTC)
QuickTime:ModifyDate (UTC)
QuickTime:GPSCoordinates
UserData:GPSCoordinates
"""
exif = {}
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
if description_template is not None:
description = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
@@ -1251,12 +1293,16 @@ def _exiftool_dict(
(lat, lon) = self.location
if lat is not None and lon is not None:
exif["EXIF:GPSLatitude"] = lat
exif["EXIF:GPSLongitude"] = lon
lat_ref = "N" if lat >= 0 else "S"
lon_ref = "E" if lon >= 0 else "W"
exif["EXIF:GPSLatitudeRef"] = lat_ref
exif["EXIF:GPSLongitudeRef"] = lon_ref
if self.isphoto:
exif["EXIF:GPSLatitude"] = lat
exif["EXIF:GPSLongitude"] = lon
lat_ref = "N" if lat >= 0 else "S"
lon_ref = "E" if lon >= 0 else "W"
exif["EXIF:GPSLatitudeRef"] = lat_ref
exif["EXIF:GPSLongitudeRef"] = lon_ref
elif self.ismovie:
exif["Keys:GPSCoordinates"] = f"{lat} {lon}"
exif["UserData:GPSCoordinates"] = f"{lat} {lon}"
# process date/time and timezone offset
# Photos exports the following fields and sets modify date to creation date
@@ -1266,32 +1312,55 @@ def _exiftool_dict(
# [IPTC] Digital Creation Date : 2020:10:30
# [IPTC] Date Created : 2020:10:30
#
# for videos:
# [QuickTime] CreateDate : 2020:12:11 06:10:10
# [QuickTime] ModifyDate : 2020:12:11 06:10:10
# [Keys] CreationDate : 2020:12:10 22:10:10-08:00
# This code deviates from Photos in one regard:
# if photo has modification date, use it otherwise use creation date
date = self.date
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["EXIF:CreateDate"] = datetimeoriginal
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["EXIF:OffsetTimeOriginal"] = offsettime
dateoriginal = date.strftime("%Y:%m:%d")
exif["IPTC:DateCreated"] = dateoriginal
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
timeoriginal = date.strftime(f"%H:%M:%S{offsettime}")
exif["IPTC:TimeCreated"] = timeoriginal
if self.isphoto:
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["EXIF:CreateDate"] = datetimeoriginal
exif["EXIF:OffsetTimeOriginal"] = offsettime
if self.date_modified is not None and not ignore_date_modified:
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
else:
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
dateoriginal = date.strftime("%Y:%m:%d")
exif["IPTC:DateCreated"] = dateoriginal
timeoriginal = date.strftime(f"%H:%M:%S{offsettime}")
exif["IPTC:TimeCreated"] = timeoriginal
if self.date_modified is not None and not ignore_date_modified:
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
else:
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
elif self.ismovie:
# QuickTime spec specifies times in UTC
# QuickTime:CreateDate and ModifyDate are in UTC w/ no timezone
# QuickTime:CreationDate must include time offset or Photos shows invalid values
# reference: https://exiftool.org/TagNames/QuickTime.html#Keys
# https://exiftool.org/forum/index.php?topic=11927.msg64369#msg64369
exif["QuickTime:CreationDate"] = f"{datetimeoriginal}{offsettime}"
date_utc = datetime_tz_to_utc(date)
creationdate = date_utc.strftime("%Y:%m:%d %H:%M:%S")
exif["QuickTime:CreateDate"] = creationdate
if self.date_modified is not None and not ignore_date_modified:
exif["QuickTime:ModifyDate"] = datetime_tz_to_utc(
self.date_modified
).strftime("%Y:%m:%d %H:%M:%S")
else:
exif["QuickTime:ModifyDate"] = creationdate
return exif
@@ -1304,7 +1373,7 @@ def _exiftool_json_sidecar(
description_template=None,
ignore_date_modified=False,
):
""" Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
"""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:
@@ -1322,7 +1391,7 @@ def _exiftool_json_sidecar(
XMP:Title
XMP:TagsList
IPTC:Keywords (may include album name, person name, or template)
XMP:Subject
XMP:Subject (set to keywords + person)
XMP:PersonInImage
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:GPSLatitude, EXIF:GPSLongitude
@@ -1332,6 +1401,11 @@ def _exiftool_json_sidecar(
EXIF:ModifyDate
IPTC:DigitalCreationDate
IPTC:DateCreated
QuickTime:CreationDate
QuickTime:CreateDate (UTC)
QuickTime:ModifyDate (UTC)
QuickTime:GPSCoordinates
UserData:GPSCoordinates
"""
exif = self._exiftool_dict(
use_albums_as_keywords=use_albums_as_keywords,
@@ -1351,11 +1425,11 @@ def _xmp_sidecar(
description_template=None,
extension=None,
):
""" 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 """
"""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"""
xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME))
@@ -1440,8 +1514,8 @@ def _xmp_sidecar(
def _write_sidecar(self, filename, sidecar_str):
""" write sidecar_str to filename
used for exporting sidecar info """
"""write sidecar_str to filename
used for exporting sidecar info"""
if not (filename or sidecar_str):
raise (
ValueError(

View File

@@ -12,7 +12,6 @@ import sys
import tempfile
from datetime import datetime, timedelta, timezone
from pprint import pformat
from shutil import copyfile
from .._constants import (
_DB_TABLE_NAMES,
@@ -532,14 +531,14 @@ class PhotosDB:
try:
dest_name = pathlib.Path(fname).name
dest_path = os.path.join(self._tempdir_name, dest_name)
copyfile(fname, dest_path)
FileUtil.copy(fname, dest_path)
# copy write-ahead log and shared memory files (-wal and -shm) files if they exist
if os.path.exists(f"{fname}-wal"):
copyfile(f"{fname}-wal", f"{dest_path}-wal")
FileUtil.copy(f"{fname}-wal", f"{dest_path}-wal")
if os.path.exists(f"{fname}-shm"):
copyfile(f"{fname}-shm", f"{dest_path}-shm")
FileUtil.copy(f"{fname}-shm", f"{dest_path}-shm")
except:
print("Error copying " + fname + " to " + dest_path, file=sys.stderr)
print(f"Error copying{fname} to {dest_path}", file=sys.stderr)
raise Exception
if _debug():

View File

@@ -52,6 +52,7 @@ TEMPLATE_SUBSTITUTIONS = {
),
"{photo_or_video}": "'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'",
"{hdr}": "Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
"{edited}": "Photo has been edited (has adjustments)?; True/False value, use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
"{created.year}": "4-digit year of photo creation time",
"{created.yy}": "2-digit year of photo creation time",
@@ -509,7 +510,7 @@ class PhotoTemplate:
subfield = field[9:]
if not self.photo.path:
values = []
values = [None]
else:
exif = ExifTool(self.photo.path)
exifdict = exif.asdict()
@@ -632,7 +633,9 @@ class PhotoTemplate:
elif field == "photo_or_video":
value = self.get_photo_video_type(default)
elif field == "hdr":
value = self.get_photo_hdr(default, bool_val)
value = self.get_photo_bool_attribute("hdr", default, bool_val)
elif field == "edited":
value = self.get_photo_bool_attribute("hasadjustments", default, bool_val)
elif field == "created.date":
value = DateTimeFormatter(self.photo.date).date
elif field == "created.year":
@@ -867,7 +870,7 @@ class PhotoTemplate:
"""
""" return list of values for a multi-valued template field """
values = []
values = []
if field == "album":
values = self.photo.albums
elif field == "keyword":
@@ -962,8 +965,10 @@ class PhotoTemplate:
else:
return default_dict["photo"]
def get_photo_hdr(self, default, bool_val):
if self.photo.hdr:
def get_photo_bool_attribute(self, attr, default, bool_val):
# get value for a PhotoInfo bool attribute
val = getattr(self.photo, attr)
if val:
return bool_val
else:
return default

View File

@@ -80,6 +80,7 @@ setup(
"dataclasses==0.7;python_version<'3.7'",
"wurlitzer>=2.0.1",
"photoscript>=0.1.0",
"toml>=0.10.0",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,

View File

@@ -7,7 +7,7 @@
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<key>pid</key>
<integer>1797</integer>
<integer>485</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

View File

@@ -3,24 +3,24 @@
<plist version="1.0">
<dict>
<key>BackgroundHighlightCollection</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:43Z</date>
<key>BackgroundHighlightEnrichment</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:42Z</date>
<key>BackgroundJobAssetRevGeocode</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:43Z</date>
<key>BackgroundJobSearch</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:43Z</date>
<key>BackgroundPeopleSuggestion</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:41Z</date>
<key>BackgroundUserBehaviorProcessor</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:43Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2020-10-17T23:45:33Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-10-17T23:45:24Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-10-17T23:45:26Z</date>
<date>2020-12-16T05:41:44Z</date>
<key>SiriPortraitDonation</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:43Z</date>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ PLACES_PHOTOS_DB_13 = "tests/Test-Places-High-Sierra-10.13.6.photoslibrary"
PHOTOS_DB_15_4 = "tests/Test-10.15.4.photoslibrary"
PHOTOS_DB_15_5 = "tests/Test-10.15.5.photoslibrary"
PHOTOS_DB_15_6 = "tests/Test-10.15.6.photoslibrary"
PHOTOS_DB_15_7 = "tests/Test-10.15.7.photoslibrary"
PHOTOS_DB_TOUCH = PHOTOS_DB_15_6
PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary"
@@ -58,6 +59,8 @@ CLI_EXPORT_FILENAMES = [
"wedding_edited.jpeg",
]
CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES = ["Tulips.jpg", "wedding.jpg"]
CLI_EXPORT_FILENAMES_ALBUM = ["Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg"]
CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"]
@@ -65,7 +68,9 @@ CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"]
CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"]
CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
CLI_EXPORT_EDITED_SUFFIX_TEMPLATE = "{edited?_edited,}"
CLI_EXPORT_ORIGINAL_SUFFIX = "_original"
CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE = "{edited?_original,}"
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"Pumkins1.jpg",
@@ -78,6 +83,17 @@ CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"wedding_bearbeiten.jpeg",
]
CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE = [
"Pumkins1.jpg",
"Pumkins2.jpg",
"Pumpkins3.jpg",
"St James Park.jpg",
"St James Park_edited.jpeg",
"Tulips.jpg",
"wedding.jpg",
"wedding_edited.jpeg",
]
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
"Pumkins1_original.jpg",
"Pumkins2_original.jpg",
@@ -89,6 +105,17 @@ CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
"wedding_edited.jpeg",
]
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE = [
"Pumkins1.jpg",
"Pumkins2.jpg",
"Pumpkins3.jpg",
"St James Park_original.jpg",
"St James Park_edited.jpeg",
"Tulips.jpg",
"wedding_original.jpg",
"wedding_edited.jpeg",
]
CLI_EXPORT_FILENAMES_CURRENT = [
"1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg",
"3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg",
@@ -335,6 +362,31 @@ CLI_EXIFTOOL = {
}
}
CLI_EXIFTOOL_QUICKTIME = {
"35329C57-B963-48D6-BB75-6AFF9370CBBC": {
"File:FileName": "Jellyfish.MOV",
"XMP:Description": "Jellyfish Video",
"XMP:Title": "Jellyfish",
"XMP:TagsList": "Travel",
"XMP:Subject": "Travel",
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
"QuickTime:CreationDate": "2020:01:05 14:13:13-08:00",
"QuickTime:CreateDate": "2020:01:05 22:13:13",
"QuickTime:ModifyDate": "2020:01:05 22:13:13",
},
"D1359D09-1373-4F3B-B0E3-1A4DE573E4A3": {
"File:FileName": "Jellyfish1.mp4",
"XMP:Description": "Jellyfish Video",
"XMP:Title": "Jellyfish1",
"XMP:TagsList": "Travel",
"XMP:Subject": "Travel",
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
"QuickTime:CreationDate": "2020:12:04 21:21:52-08:00",
"QuickTime:CreateDate": "2020:12:05 05:21:52",
"QuickTime:ModifyDate": "2020:12:05 05:21:52",
},
}
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = {
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
"File:FileName": "wedding.jpg",
@@ -466,6 +518,12 @@ UUID_NO_LIKES = [
]
def modify_file(filename):
""" appends data to a file to modify it """
with open(filename, "ab") as fd:
fd.write(b"foo")
@pytest.fixture(autouse=True)
def reset_globals():
""" reset globals in __main__ that tests may have changed """
@@ -849,7 +907,7 @@ def test_export_using_hardlinks_incompat_options():
"-V",
],
)
assert result.exit_code == 0
assert result.exit_code == 1
assert "Incompatible export options" in result.output
@@ -994,6 +1052,46 @@ def test_export_exiftool_ignore_date_modified():
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_quicktime():
""" test --exiftol correctly writes QuickTime tags """
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_QUICKTIME:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(
[CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]]
)
exif = ExifTool(CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]).asdict()
for key in CLI_EXIFTOOL_QUICKTIME[uuid]:
assert exif[key] == CLI_EXIFTOOL_QUICKTIME[uuid][key]
# clean up exported files to avoid name conflicts
for filename in files:
os.unlink(filename)
def test_export_edited_suffix():
""" test export with --edited-suffix """
import glob
@@ -1021,6 +1119,33 @@ def test_export_edited_suffix():
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX)
def test_export_edited_suffix_template():
""" test export with --edited-suffix template """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--edited-suffix",
CLI_EXPORT_EDITED_SUFFIX_TEMPLATE,
"-V",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE)
def test_export_original_suffix():
""" test export with --original-suffix """
import glob
@@ -1048,6 +1173,33 @@ def test_export_original_suffix():
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX)
def test_export_original_suffix_template():
""" test export with --original-suffix template """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-suffix",
CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE,
"-V",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE)
@pytest.mark.skipif(
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
reason="Skip if running in Github actions, no GPU.",
@@ -2858,8 +3010,7 @@ def test_export_sidecar_keyword_template():
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Girl holding pumpkin",
[{"EXIF:ImageDescription": "Girl holding pumpkin",
"XMP:Description": "Girl holding pumpkin",
"XMP:Title": "I found one!",
"XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
@@ -2918,7 +3069,7 @@ def test_export_update_basic():
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 8 photos, updated EXIF data: 0 photos"
"Processed: 7 photos, exported: 0, updated: 0, skipped: 8, updated EXIF data: 0, missing: 1, error: 0"
in result.output
)
@@ -3002,7 +3153,7 @@ def test_export_update_exiftool():
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 8 photos, skipped: 0 photos, updated EXIF data: 8 photos"
"Processed: 7 photos, exported: 0, updated: 8, skipped: 0, updated EXIF data: 8, missing: 1, error: 0"
in result.output
)
@@ -3012,7 +3163,7 @@ def test_export_update_exiftool():
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 8 photos, updated EXIF data: 0 photos"
"Processed: 7 photos, exported: 0, updated: 0, skipped: 8, updated EXIF data: 0, missing: 1, error: 0"
in result.output
)
@@ -3049,7 +3200,7 @@ def test_export_update_hardlink():
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 8 photos, skipped: 0 photos, updated EXIF data: 0 photos"
"Processed: 7 photos, exported: 0, updated: 8, skipped: 0, updated EXIF data: 0, missing: 1, error: 0"
in result.output
)
assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path)
@@ -3088,7 +3239,7 @@ def test_export_update_hardlink_exiftool():
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 8 photos, skipped: 0 photos, updated EXIF data: 8 photos"
"Processed: 7 photos, exported: 0, updated: 8, skipped: 0, updated EXIF data: 8, missing: 1, error: 0"
in result.output
)
assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path)
@@ -3126,7 +3277,7 @@ def test_export_update_edits():
)
assert result.exit_code == 0
assert (
"Exported: 1 photo, updated: 1 photo, skipped: 6 photos, updated EXIF data: 0 photos"
"Processed: 7 photos, exported: 1, updated: 1, skipped: 6, updated EXIF data: 0, missing: 1, error: 0"
in result.output
)
@@ -3162,7 +3313,7 @@ def test_export_update_no_db():
# edited files will be re-exported because there won't be an edited signature
# in the database
assert (
"Exported: 0 photos, updated: 2 photos, skipped: 6 photos, updated EXIF data: 0 photos"
"Processed: 7 photos, exported: 0, updated: 2, skipped: 6, updated EXIF data: 0, missing: 1, error: 0"
in result.output
)
assert os.path.isfile(OSXPHOTOS_EXPORT_DB)
@@ -3201,15 +3352,15 @@ def test_export_then_hardlink():
],
)
assert result.exit_code == 0
assert "Exported: 8 photos" in result.output
assert "Processed: 7 photos, exported: 8, missing: 1, error: 0" in result.output
assert os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path)
def test_export_dry_run():
""" test export with dry-run flag """
import glob
import os
import os.path
import re
import osxphotos
from osxphotos.__main__ import export
@@ -3221,9 +3372,9 @@ def test_export_dry_run():
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--dry-run"]
)
assert result.exit_code == 0
assert "Exported: 8 photos" in result.output
assert "Processed: 7 photos, exported: 8, missing: 1, error: 0" in result.output
for filepath in CLI_EXPORT_FILENAMES:
assert f"Exported {filepath}" in result.output
assert re.search(r"Exported.*" + f"{filepath}", result.output)
assert not os.path.isfile(filepath)
@@ -3265,7 +3416,7 @@ def test_export_update_edits_dry_run():
)
assert result.exit_code == 0
assert (
"Exported: 1 photo, updated: 1 photo, skipped: 6 photos, updated EXIF data: 0 photos"
"Processed: 7 photos, exported: 1, updated: 1, skipped: 6, updated EXIF data: 0, missing: 1, error: 0"
in result.output
)
@@ -3275,10 +3426,10 @@ def test_export_update_edits_dry_run():
def test_export_directory_template_1_dry_run():
""" test export using directory template with dry-run flag """
import glob
import locale
import os
import os.path
import re
import osxphotos
from osxphotos.__main__ import export
@@ -3300,10 +3451,10 @@ def test_export_directory_template_1_dry_run():
],
)
assert result.exit_code == 0
assert "Exported: 8 photos" in result.output
assert "exported: 8" in result.output
workdir = os.getcwd()
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1:
assert f"Exported {filepath}" in result.output
assert re.search(r"Exported.*" + f"{filepath}", result.output)
assert not os.path.isfile(os.path.join(workdir, filepath))
@@ -3336,7 +3487,8 @@ def test_export_touch_files():
)
assert result.exit_code == 0
assert "Exported: 18 photos, touched date: 16 photos" in result.output
assert "exported: 18" in result.output
assert "touched date: 16" in result.output
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
st = os.stat(fname)
@@ -3368,7 +3520,7 @@ def test_export_touch_files_update():
)
assert result.exit_code == 0
assert "Exported: 18 photos" in result.output
assert "exported: 18" in result.output
assert not pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file()
@@ -3378,7 +3530,7 @@ def test_export_touch_files_update():
)
assert result.exit_code == 0
assert "Exported: 18 photos" in result.output
assert "exported: 18" in result.output
assert pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file()
@@ -3389,10 +3541,7 @@ def test_export_touch_files_update():
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos"
in result.output
)
assert "skipped: 18" in result.output
# --update --touch-file --dry-run
result = runner.invoke(
@@ -3407,10 +3556,8 @@ def test_export_touch_files_update():
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 16 photos"
in result.output
)
assert "skipped: 18" in result.output
assert "touched date: 16" in result.output
for fname, mtime in zip(
CLI_EXPORT_BY_DATE_NEED_TOUCH, CLI_EXPORT_BY_DATE_NEED_TOUCH_TIMES
@@ -3430,10 +3577,8 @@ def test_export_touch_files_update():
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 16 photos"
in result.output
)
assert "skipped: 18" in result.output
assert "touched date: 16" in result.output
for fname, mtime in zip(
CLI_EXPORT_BY_DATE_NEED_TOUCH, CLI_EXPORT_BY_DATE_NEED_TOUCH_TIMES
@@ -3456,10 +3601,8 @@ def test_export_touch_files_update():
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 1 photo, skipped: 17 photos, updated EXIF data: 0 photos, touched date: 1 photo"
in result.output
)
assert "updated: 1, skipped: 17" in result.output
assert "touched date: 1" in result.output
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
st = os.stat(fname)
@@ -3472,10 +3615,7 @@ def test_export_touch_files_update():
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos"
in result.output
)
assert "skipped: 18" in result.output
@pytest.mark.skip("TODO: This fails on some machines but not all")
@@ -3505,7 +3645,7 @@ def test_export_touch_files_exiftool_update():
)
assert result.exit_code == 0
assert "Exported: 18 photos" in result.output
assert "exported: 18" in result.output
assert not pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file()
@@ -3515,7 +3655,7 @@ def test_export_touch_files_exiftool_update():
)
assert result.exit_code == 0
assert "Exported: 18 photos" in result.output
assert "exported: 18" in result.output
assert pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file()
@@ -3526,10 +3666,7 @@ def test_export_touch_files_exiftool_update():
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos"
in result.output
)
assert "skipped: 18" in result.output
# --update --exiftool --dry-run
result = runner.invoke(
@@ -3545,10 +3682,8 @@ def test_export_touch_files_exiftool_update():
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 18 photos, skipped: 0 photos, updated EXIF data: 18 photos"
in result.output
)
assert "updated: 18" in result.output
assert "updated EXIF data: 18" in result.output
# --update --exiftool
result = runner.invoke(
@@ -3562,11 +3697,8 @@ def test_export_touch_files_exiftool_update():
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 18 photos, skipped: 0 photos, updated EXIF data: 18 photos"
in result.output
)
assert "updated: 18" in result.output
assert "updated EXIF data: 18" in result.output
# --update --touch-file --exiftool --dry-run
result = runner.invoke(
@@ -3582,10 +3714,8 @@ def test_export_touch_files_exiftool_update():
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 18 photos"
in result.output
)
assert "skipped: 18" in result.output
assert "touched date: 18" in result.output
# --update --touch-file --exiftool
result = runner.invoke(
@@ -3600,10 +3730,8 @@ def test_export_touch_files_exiftool_update():
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 18 photos"
in result.output
)
assert "skipped: 18" in result.output
assert "touched date: 18" in result.output
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
st = os.stat(fname)
@@ -3625,10 +3753,10 @@ def test_export_touch_files_exiftool_update():
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 1 photo, skipped: 17 photos, updated EXIF data: 1 photo, touched date: 1 photo"
in result.output
)
assert "updated: 1" in result.output
assert "skipped: 17" in result.output
assert "updated EXIF data: 1" in result.output
assert "touched date: 1" in result.output
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
st = os.stat(fname)
@@ -3647,10 +3775,8 @@ def test_export_touch_files_exiftool_update():
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 0 photos"
in result.output
)
assert "exported: 0" in result.output
assert "skipped: 18" in result.output
# run update without --touch-file
result = runner.invoke(
@@ -3665,9 +3791,185 @@ def test_export_touch_files_exiftool_update():
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos"
in result.output
assert "exported: 0" in result.output
assert "skipped: 18" in result.output
def test_export_ignore_signature():
""" test export with --ignore-signature """
import os
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# first, export some files
result = runner.invoke(export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V"])
assert result.exit_code == 0
# modify a couple of files
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
modify_file(f"./{filename}")
# export with --update and --ignore-signature
# which should ignore the two modified files
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--ignore-signature",
],
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
# export with --update and not --ignore-signature
# which should updated the two modified files
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update"]
)
assert result.exit_code == 0
assert "updated: 2" in result.output
# run --update again, should be 0 files exported
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update"]
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
def test_export_ignore_signature_sidecar():
""" test export with --ignore-signature and --sidecar """
"""
Test the following use cases:
If the metadata (in Photos) that went into the sidecar did not change, the sidecar will not be updated
If the metadata (in Photos) that went into the sidecar did change, a new sidecar is written but a new image file is not
If a sidecar does not exist for the photo, a sidecar will be written whether or not the photo file was written
"""
import osxphotos
import os
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# first, export some files
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--sidecar", "XMP"]
)
assert result.exit_code == 0
# export with --update and --ignore-signature
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--sidecar",
"XMP",
"--ignore-signature",
],
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
assert "Writing XMP sidecar" not in result.output
# modify a couple of files
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
modify_file(f"./{filename}")
# export with --update and --ignore-signature
# which should ignore the two modified files
# sidecar files should not be re-written
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--sidecar",
"XMP",
"--ignore-signature",
],
)
assert result.exit_code == 0
assert "exported: 0" in result.output
assert "Writing XMP sidecar" not in result.output
# change the sidecar data in export DB
# should result in a new sidecar being exported but not the image itself
exportdb = osxphotos.export_db.ExportDB("./.osxphotos_export.db")
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
exportdb.set_sidecar_for_file(f"{filename}.xmp", "FOO", (0, 1, 2))
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--ignore-signature",
"--sidecar",
"XMP",
],
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
assert result.output.count("Writing XMP sidecar") == len(
CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES
)
# run --update again, should be 0 files exported
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--ignore-signature",
"--sidecar",
"XMP",
],
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
assert "Writing XMP sidecar" not in result.output
# remove XMP files and run again to verify the files get written
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
os.unlink(f"./{filename}.xmp")
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--ignore-signature",
"--sidecar",
"XMP",
],
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
assert result.output.count("Writing XMP sidecar") == len(
CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES
)
@@ -3763,3 +4065,263 @@ def test_persons():
json_got = json.loads(result.output)
assert json_got == PERSONS_JSON
def test_export_report():
""" test export with --report option """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "report.csv"],
)
assert result.exit_code == 0
assert "Writing export report" in result.output
assert os.path.exists("report.csv")
def test_export_report_not_a_file():
""" test export with --report option and bad report value """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "."]
)
assert result.exit_code != 0
assert "Aborted!" in result.output
def test_export_as_hardlink_download_missing():
""" test export with incompatible export options """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--download-missing",
"--export-as-hardlink",
".",
],
)
assert result.exit_code != 0
assert "Aborted!" in result.output
def test_export_missing():
""" test export with --missing """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--missing",
"--download-missing",
".",
],
)
assert result.exit_code == 0
assert "Exporting 2 photos" in result.output
def test_export_missing_not_download_missing():
""" test export with incompatible export options """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--missing", "."]
)
assert result.exit_code != 0
assert "Aborted!" in result.output
def test_export_cleanup():
""" test export with --cleanup flag """
import pathlib
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"])
assert result.exit_code == 0
# create 2 files and a directory
with open("delete_me.txt", "w") as fd:
fd.write("delete me!")
os.mkdir("./foo")
with open("foo/delete_me_too.txt", "w") as fd:
fd.write("delete me too!")
assert pathlib.Path("./delete_me.txt").is_file()
# run cleanup with dry-run
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--update",
"--cleanup",
"--dry-run",
],
)
assert "Deleted: 2 files, 0 directories" in result.output
assert pathlib.Path("./delete_me.txt").is_file()
assert pathlib.Path("./foo/delete_me_too.txt").is_file()
# run cleanup without dry-run
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup"],
)
assert "Deleted: 2 files, 1 directory" in result.output
assert not pathlib.Path("./delete_me.txt").is_file()
assert not pathlib.Path("./foo/delete_me_too.txt").is_file()
def test_save_load_config():
""" test --save-config, --load-config """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# test save config file
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--sidecar",
"XMP",
"--touch-file",
"--update",
"--save-config",
"config.toml",
],
)
assert result.exit_code == 0
assert "Saving options to file" in result.output
files = glob.glob("*")
assert "config.toml" in files
# test load config file
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--load-config",
"config.toml",
],
)
assert result.exit_code == 0
assert "Loaded options from file" in result.output
assert "Skipped up to date XMP sidecar" in result.output
# test overwrite existing config file
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--sidecar",
"XMP",
"--touch-file",
"--not-live",
"--update",
"--save-config",
"config.toml",
],
)
assert result.exit_code == 0
assert "Saving options to file" in result.output
files = glob.glob("*")
assert "config.toml" in files
# test load config file with incompat command line option
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--load-config",
"config.toml",
"--live",
],
)
assert result.exit_code != 0
assert "Incompatible export options" in result.output
# test load config file with command line override
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--load-config",
"config.toml",
"--sidecar",
"json",
],
)
assert result.exit_code == 0
assert "Writing exiftool JSON sidecar" in result.output
assert "Writing XMP sidecar" not in result.output

View File

@@ -0,0 +1,86 @@
""" test ConfigOptions class """
import pathlib
import pytest
import toml
from osxphotos.configoptions import (
ConfigOptions,
ConfigOptionsInvalidError,
ConfigOptionsLoadError,
)
VARS = {"foo": "bar", "bar": False, "test1": (), "test2": None, "test2_setting": False}
def test_init():
cfg = ConfigOptions("test", VARS)
assert isinstance(cfg, ConfigOptions)
assert cfg.foo is "bar"
assert cfg.bar == False
assert type(cfg.test1) == tuple
def test_init_with_ignore():
cfg = ConfigOptions("test", VARS, ignore=["test2"])
assert isinstance(cfg, ConfigOptions)
assert hasattr(cfg, "test1")
assert not hasattr(cfg, "test2")
def test_write_to_file_load_from_file(tmpdir):
cfg = ConfigOptions("test", VARS)
cfg.bar = True
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
cfg.write_to_file(str(cfg_file))
assert cfg_file.is_file()
cfg_dict = toml.load(str(cfg_file))
assert cfg_dict["test"]["foo"] == "bar"
cfg2 = ConfigOptions("test", VARS).load_from_file(str(cfg_file))
assert cfg2.foo == "bar"
assert cfg2.bar
def test_load_from_file_error(tmpdir):
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
cfg = ConfigOptions("test", VARS)
cfg.write_to_file(str(cfg_file))
# try to load with a section that doesn't exist in the TOML file
with pytest.raises(ConfigOptionsLoadError):
cfg2 = ConfigOptions("FOO", VARS).load_from_file(str(cfg_file))
def test_asdict():
cfg = ConfigOptions("test", VARS)
cfg_dict = cfg.asdict()
assert cfg_dict["foo"] == "bar"
assert cfg_dict["bar"] == False
assert cfg_dict["test1"] == ()
def test_validate():
cfg = ConfigOptions("test", VARS)
# test exclusive
assert cfg.validate(exclusive=[("foo", "bar")])
cfg.bar = True
with pytest.raises(ConfigOptionsInvalidError):
assert cfg.validate(exclusive=[("foo", "bar")])
# test dependent
cfg.test2 = True
cfg.test2_setting = 1.0
assert cfg.validate(dependent=[("test2_setting", ("test2"))])
cfg.test2 = False
with pytest.raises(ConfigOptionsInvalidError):
assert cfg.validate(dependent=[("test2_setting", ("test2"))])
# test inclusive
cfg.foo = "foo"
cfg.bar = True
assert cfg.validate(inclusive=[("foo", "bar")])
cfg.foo = None
with pytest.raises(ConfigOptionsInvalidError):
assert cfg.validate(inclusive=[("foo", "bar")])

View File

@@ -1,90 +1,96 @@
""" test datetime_utils """
from datetime import date, timezone
import pytest
from osxphotos.datetime_utils import *
def test_get_local_tz():
""" test get_local_tz during time with no DST """
import datetime
import os
import time
from osxphotos.datetime_utils import get_local_tz
os.environ["TZ"] = "US/Pacific"
time.tzset()
dt = datetime.datetime(2018, 12, 31, 0, 0, 0)
local_tz = get_local_tz(dt)
assert local_tz == datetime.timezone(
datetime.timedelta(days=-1, seconds=57600), "PST"
)
dt = datetime.datetime(2020, 9, 1, 21, 10, 00)
tz = get_local_tz(dt)
assert tz == datetime.timezone(offset=datetime.timedelta(seconds=-25200))
def test_get_local_tz_dst():
""" test get_local_tz during time with DST """
import datetime
import os
import time
from osxphotos.datetime_utils import get_local_tz
os.environ["TZ"] = "US/Pacific"
time.tzset()
dt = datetime.datetime(2018, 6, 30, 0, 0, 0)
local_tz = get_local_tz(dt)
assert local_tz == datetime.timezone(
datetime.timedelta(days=-1, seconds=61200), "PDT"
)
def test_datetime_remove_tz():
""" test datetime_remove_tz """
import datetime
from osxphotos.datetime_utils import datetime_remove_tz
dt = datetime.datetime(
2018,
12,
31,
tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=57600), "PST"),
)
dt_no_tz = datetime_remove_tz(dt)
assert dt_no_tz.tzinfo is None
dt = datetime.datetime(2020, 12, 1, 21, 10, 00)
tz = get_local_tz(dt)
assert tz == datetime.timezone(offset=datetime.timedelta(seconds=-28800))
def test_datetime_has_tz():
""" test datetime_has_tz """
import datetime
from osxphotos.datetime_utils import datetime_has_tz
dt = datetime.datetime(
2018,
12,
31,
tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=57600), "PST"),
)
tz = datetime.timezone(offset=datetime.timedelta(seconds=-28800))
dt = datetime.datetime(2020, 9, 1, 21, 10, 00, tzinfo=tz)
assert datetime_has_tz(dt)
dt_notz = datetime.datetime(2018, 12, 31)
assert not datetime_has_tz(dt_notz)
dt = datetime.datetime(2020, 9, 1, 21, 10, 00)
assert not datetime_has_tz(dt)
def test_datetime_tz_to_utc():
import datetime
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
dt = datetime.datetime(2020, 9, 1, 22, 6, 0, tzinfo=tz)
utc = datetime_tz_to_utc(dt)
assert utc == datetime.datetime(2020, 9, 2, 5, 6, 0, tzinfo=datetime.timezone.utc)
def test_datetime_remove_tz():
import datetime
import os
os.environ["TZ"] = "US/Pacific"
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
dt = datetime.datetime(2020, 9, 1, 22, 6, 0, tzinfo=tz)
dt = datetime_remove_tz(dt)
assert dt == datetime.datetime(2020, 9, 1, 22, 6, 0)
assert not datetime_has_tz(dt)
def test_datetime_naive_to_utc():
import datetime
dt = datetime.datetime(2020, 9, 1, 12, 0, 0)
utc = datetime_naive_to_utc(dt)
assert utc == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
def test_datetime_naive_to_local():
""" test datetime_naive_to_local """
import datetime
import os
import time
from osxphotos.datetime_utils import datetime_naive_to_local
os.environ["TZ"] = "US/Pacific"
time.tzset()
dt = datetime.datetime(2018, 6, 30, 0, 0, 0)
dt_local = datetime_naive_to_local(dt)
assert dt_local.tzinfo == datetime.timezone(
datetime.timedelta(days=-1, seconds=61200), "PDT"
)
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
dt = datetime.datetime(2020, 9, 1, 12, 0, 0)
utc = datetime_naive_to_local(dt)
assert utc == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=tz)
def test_datetime_utc_to_local():
import datetime
import os
os.environ["TZ"] = "US/Pacific"
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
utc = datetime.datetime(2020, 9, 1, 19, 0, 0, tzinfo=datetime.timezone.utc)
dt = datetime_utc_to_local(utc)
assert dt == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=tz)
def test_datetime_utc_to_local_2():
import datetime
import os
os.environ["TZ"] = "CEST"
tz = datetime.timezone(offset=datetime.timedelta(seconds=7200))
utc = datetime.datetime(2020, 9, 1, 19, 0, 0, tzinfo=datetime.timezone.utc)
dt = datetime_utc_to_local(utc)
assert dt == datetime.datetime(2020, 9, 1, 21, 0, 0, tzinfo=tz)

View File

@@ -68,8 +68,7 @@ XMP_JPG_FILENAME = "Pumkins1.jpg"
EXIF_JSON_UUID = UUID_DICT["has_adjustments"]
EXIF_JSON_EXPECTED = """
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Bride Wedding day",
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding"],
"IPTC:Keywords": ["wedding"],
@@ -84,8 +83,7 @@ EXIF_JSON_EXPECTED = """
"""
EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Bride Wedding day",
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding"],
"IPTC:Keywords": ["wedding"],
@@ -416,28 +414,6 @@ def test_export_13():
assert e.type == type(FileNotFoundError())
def test_export_no_xattr():
# test basic export with no_xattr=True
# 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
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest, no_xattr=True)[0]
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
def test_dd_to_dms_str_1():
import osxphotos
@@ -544,8 +520,7 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog):
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Bride Wedding day",
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
@@ -594,8 +569,7 @@ def test_exiftool_json_sidecar_keyword_template():
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Bride Wedding day",
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
@@ -655,8 +629,7 @@ def test_exiftool_json_sidecar_use_persons_keyword():
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Girls with pumpkins",
[{"EXIF:ImageDescription": "Girls with pumpkins",
"XMP:Description": "Girls with pumpkins",
"XMP:Title": "Can we carry this?",
"XMP:TagsList": ["Kids", "Suzy", "Katie"],
@@ -698,8 +671,7 @@ def test_exiftool_json_sidecar_use_albums_keyword():
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Girls with pumpkins",
[{"EXIF:ImageDescription": "Girls with pumpkins",
"XMP:Description": "Girls with pumpkins",
"XMP:Title": "Can we carry this?",
"XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"],

View File

@@ -46,8 +46,7 @@ UUID_DICT = {
}
EXIF_JSON_EXPECTED = """
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"XMP:Title": "St. James\'s Park",
[{"XMP:Title": "St. James\'s Park",
"XMP:TagsList": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
"IPTC:Keywords": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
"XMP:Subject": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],

View File

@@ -16,7 +16,7 @@ def test_copy_file_valid():
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
src = "tests/test-images/wedding.jpg"
result = FileUtil.copy(src, temp_dir.name)
assert result == 0
assert result
assert os.path.isfile(os.path.join(temp_dir.name, "wedding.jpg"))
@@ -29,20 +29,7 @@ def test_copy_file_invalid():
with pytest.raises(Exception) as e:
src = "tests/test-images/wedding_DOES_NOT_EXIST.jpg"
assert FileUtil.copy(src, temp_dir.name)
assert e.type == FileNotFoundError
def test_copy_file_norsrc():
# copy file with --norsrc
import os.path
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
src = "tests/test-images/wedding.jpg"
result = FileUtil.copy(src, temp_dir.name, norsrc=True)
assert result == 0
assert os.path.isfile(os.path.join(temp_dir.name, "wedding.jpg"))
assert e.type == OSError
def test_hardlink_file_valid():
@@ -73,6 +60,18 @@ def test_unlink_file():
assert not os.path.isfile(dest)
def test_rmdir():
import os.path
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dir_name = temp_dir.name
assert os.path.isdir(dir_name)
FileUtil.rmdir(dir_name)
assert not os.path.isdir(dir_name)
@pytest.mark.skipif(
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
reason="Skip if running in Github actions, no GPU.",
@@ -90,6 +89,7 @@ def test_convert_to_jpeg():
assert FileUtil.convert_to_jpeg(imgfile, outfile)
assert outfile.is_file()
@pytest.mark.skipif(
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
reason="Skip if running in Github actions, no GPU.",

View File

@@ -59,10 +59,16 @@ TEMPLATE_VALUES_TITLE = {
}
# Boolean type values that render to True
UUID_BOOL_VALUES = {"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15"}
UUID_BOOL_VALUES = {
"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15",
"edited": "51F2BEF7-431A-4D31-8AC1-3284A57826AE",
}
# Boolean type values that render to False
UUID_BOOL_VALUES_NOT = {"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE"}
UUID_BOOL_VALUES_NOT = {
"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE",
"edited": "CCBE0EB9-AE9F-4479-BFFD-107042C75227",
}
# for exiftool template
UUID_EXIFTOOL = {