Compare commits

..

80 Commits

Author SHA1 Message Date
Rhet Turnbull
e394d8e6be Implemented --ignore-signature, issue #286 2020-12-16 20:11:01 -08: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
Rhet Turnbull
48acb42631 Added {exiftool} template, implements issue #259 2020-11-27 16:43:48 -08:00
Rhet Turnbull
eba661acf7 Updated CHANGELOG.md 2020-11-26 19:53:35 -08:00
Rhet Turnbull
399d432a66 Added --original-suffix for issue #263 2020-11-26 18:36:17 -08:00
Rhet Turnbull
4cebc57d60 Updated CHANGELOG.md 2020-11-26 15:26:54 -08:00
Rhet Turnbull
489fea56e9 Added tests for issue #265 2020-11-26 13:21:40 -08:00
Rhet Turnbull
0632a97f55 Simplified sidecar table in export_db 2020-11-26 10:42:10 -08:00
Rhet Turnbull
d5a9f76719 More work on issue #265 2020-11-26 10:15:09 -08:00
Rhet Turnbull
382fca3f92 Initial implementation for issue #265 2020-11-26 09:08:26 -08:00
Rhet Turnbull
a807894095 Removed debug code from _photoinfo_export.py 2020-11-25 21:42:27 -08:00
Rhet Turnbull
559350f71d Updated CHANGELOG.md 2020-11-25 20:55:15 -08:00
Rhet Turnbull
b5195f9d2b version bump 2020-11-25 20:32:36 -08:00
Rhet Turnbull
fa332186ab Fix for missing original_filename, issue #267 2020-11-25 20:31:19 -08:00
Rhet Turnbull
aa2ebf55bb Updated test 2020-11-25 19:04:36 -08:00
Rhet Turnbull
d1fbb9fe86 Updated CHANGELOG.md 2020-11-25 18:58:48 -08:00
Rhet Turnbull
116cb662fb Added test for missing original_filename 2020-11-25 18:32:12 -08:00
Rhet Turnbull
db68defc44 version bump 2020-11-25 17:55:07 -08:00
Rhet Turnbull
7460bc88fc Add @jstrine as a contributor 2020-11-25 17:54:53 -08:00
Rhet Turnbull
dbbbbf10a8 Merge pull request #272 from jstrine/fix_xml_escaping
Add XML escaping to XMP sidecar export, thanks to @jstrine for the fix!
2020-11-25 17:52:22 -08:00
Rhet Turnbull
0633814ab2 Merge pull request #270 from jstrine/fix_gps_xmp
Fix EXIF GPS format for XMP sidecar, thanks to @jstrine for the fix!
2020-11-25 17:51:57 -08:00
Rhet Turnbull
df7d45659a Merge pull request #268 from jstrine/fix_path_none
Continue even if the original filename is None, thanks to @jstrine for the fix!
2020-11-25 17:51:38 -08:00
Jonathan Strine
cec266bba4 Fix tests again
Third times the charm to fix a find-replace error this time.
2020-11-25 19:51:09 -05:00
Jonathan Strine
d0d2e80800 Fix tests for apostrophe
Previous commit didn't reflect all locations and had a copy/paste error.
2020-11-25 19:45:21 -05:00
Jonathan Strine
aafdbea564 Fix test to accomodate for escaped apostrophe 2020-11-25 19:36:09 -05:00
Jonathan Strine
c42050a10c Escape characters which cause XML parsing issues 2020-11-25 19:31:51 -05:00
Jonathan Strine
c27cfb1223 Fix test for XMP sidecar with GPS info 2020-11-25 19:24:56 -05:00
Jonathan Strine
ad144da8a0 Use GPSCoordinate format for geolocation 2020-11-25 18:09:38 -05:00
Jonathan Strine
5352aec3b9 Continue even if the original filename is None
Some photos seemed to be missing the original_filename (returning None).
This fix prevents the traceback.
2020-11-25 17:00:22 -05:00
Rhet Turnbull
e951e5361e Exposed --use-photos-export and --use-photokit 2020-11-25 09:15:16 -08:00
Rhet Turnbull
f7bd1376e1 Updated CHANGELOG.md 2020-11-24 06:50:52 -08:00
Rhet Turnbull
26f96d582c Added photokit export as hidden --use-photokit option 2020-11-23 06:23:19 -08:00
Rhet Turnbull
8cb15d1555 Removed debug statement in _photoinfo_export 2020-11-18 22:03:23 -08:00
Rhet Turnbull
2d9429c8ee Fixed missing data file for photoscript 2020-11-14 14:18:47 -08:00
Rhet Turnbull
3b6dd08d2b Version bump, updated requirements 2020-11-14 13:37:46 -08:00
Rhet Turnbull
3c85f26f90 Moved AppleScript to photoscript 2020-11-14 13:34:50 -08:00
Rhet Turnbull
52c054f81f Updated CHANGELOG.md 2020-11-14 09:32:08 -08:00
Rhet Turnbull
8dc59cbc35 Fixed erroneous attempt to export edited with --download-missing 2020-11-12 06:51:36 -08:00
Rhet Turnbull
802e2f069a version bump 2020-11-12 06:18:56 -08:00
Rhet Turnbull
5d4d7d7db7 Fixed path for photos actually missing off disk 2020-11-12 06:18:28 -08:00
Rhet Turnbull
ea9b41bae4 Avoid copying db files if not necessary 2020-11-11 07:03:57 -08:00
Rhet Turnbull
38397b507b Fix for issue #247 2020-11-08 21:17:19 -08:00
83 changed files with 6489 additions and 1156 deletions

View File

@@ -100,6 +100,15 @@
"contributions": [
"code"
]
},
{
"login": "jstrine",
"name": "Jonathan Strine",
"avatar_url": "https://avatars1.githubusercontent.com/u/33943447?v=4",
"profile": "https://github.com/jstrine",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7

View File

@@ -4,6 +4,177 @@ 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.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
- Added --original-suffix for issue #263 [`399d432`](https://github.com/RhetTbull/osxphotos/commit/399d432a66354b9c235f30d10c6985fbde1b7e4f)
#### [v0.36.24](https://github.com/RhetTbull/osxphotos/compare/v0.36.23...v0.36.24)
> 26 November 2020
- Initial implementation for issue #265 [`382fca3`](https://github.com/RhetTbull/osxphotos/commit/382fca3f92a3c251c12426dd0dc6d7dc21b691cf)
- More work on issue #265 [`d5a9f76`](https://github.com/RhetTbull/osxphotos/commit/d5a9f767199d25ebd9d5925d05ee39ea7e51ac26)
- Simplified sidecar table in export_db [`0632a97`](https://github.com/RhetTbull/osxphotos/commit/0632a97f55af67c7e5265b0d3283155c7c087e89)
#### [v0.36.23](https://github.com/RhetTbull/osxphotos/compare/v0.36.22...v0.36.23)
> 26 November 2020
- Fix for missing original_filename, issue #267 [`fa33218`](https://github.com/RhetTbull/osxphotos/commit/fa332186ab3cdbe1bfd6496ff29b652ef984a5f8)
- version bump [`b5195f9`](https://github.com/RhetTbull/osxphotos/commit/b5195f9d2b81cf6737b65e3cd3793ea9b0da13eb)
- Updated test [`aa2ebf5`](https://github.com/RhetTbull/osxphotos/commit/aa2ebf55bb50eec14f86a532334b376e407f4bbc)
#### [v0.36.22](https://github.com/RhetTbull/osxphotos/compare/v0.36.21...v0.36.22)
> 26 November 2020
- Add XML escaping to XMP sidecar export, thanks to @jstrine for the fix! [`#272`](https://github.com/RhetTbull/osxphotos/pull/272)
- Fix EXIF GPS format for XMP sidecar, thanks to @jstrine for the fix! [`#270`](https://github.com/RhetTbull/osxphotos/pull/270)
- Continue even if the original filename is None, thanks to @jstrine for the fix! [`#268`](https://github.com/RhetTbull/osxphotos/pull/268)
- Added test for missing original_filename [`116cb66`](https://github.com/RhetTbull/osxphotos/commit/116cb662fbddf9153f6858c6ea97dc7f65c77705)
- Add @jstrine as a contributor [`7460bc8`](https://github.com/RhetTbull/osxphotos/commit/7460bc88fcc5e1e7435c9b9bcdf7ec9c7c5e39ea)
- Escape characters which cause XML parsing issues [`c42050a`](https://github.com/RhetTbull/osxphotos/commit/c42050a10cac40b0b5ac70c587e07f257a9b50dd)
- Fix tests for apostrophe [`d0d2e80`](https://github.com/RhetTbull/osxphotos/commit/d0d2e8080096bf66f93a830386800ce713680c51)
- Fix test for XMP sidecar with GPS info [`c27cfb1`](https://github.com/RhetTbull/osxphotos/commit/c27cfb1223fa82b9e5549b93c283e9444693270a)
#### [v0.36.21](https://github.com/RhetTbull/osxphotos/compare/v0.36.20...v0.36.21)
> 25 November 2020
- Exposed --use-photos-export and --use-photokit [`e951e53`](https://github.com/RhetTbull/osxphotos/commit/e951e5361e59060229787bb1ea3fc4e088ffff99)
#### [v0.36.20](https://github.com/RhetTbull/osxphotos/compare/v0.36.19...v0.36.20)
> 23 November 2020
- Added photokit export as hidden --use-photokit option [`26f96d5`](https://github.com/RhetTbull/osxphotos/commit/26f96d582c01ce9816b1f54f0e74c8570f133f7c)
#### [v0.36.19](https://github.com/RhetTbull/osxphotos/compare/v0.36.18...v0.36.19)
> 19 November 2020
- Removed debug statement in _photoinfo_export [`8cb15d1`](https://github.com/RhetTbull/osxphotos/commit/8cb15d15551094dcaf1b0ef32d6ac0273be7fd37)
#### [v0.36.18](https://github.com/RhetTbull/osxphotos/compare/v0.36.17...v0.36.18)
> 14 November 2020
- Moved AppleScript to photoscript [`3c85f26`](https://github.com/RhetTbull/osxphotos/commit/3c85f26f901645ce297685ccd639792757fbc995)
- Fixed missing data file for photoscript [`2d9429c`](https://github.com/RhetTbull/osxphotos/commit/2d9429c8eefabe6233fc580f65511c48ee6c01e5)
- Version bump, updated requirements [`3b6dd08`](https://github.com/RhetTbull/osxphotos/commit/3b6dd08d2bb2b20a55064bf24fe7ce788e7268ef)
#### [v0.36.17](https://github.com/RhetTbull/osxphotos/compare/v0.36.15...v0.36.17)
> 12 November 2020
- Fixed path for photos actually missing off disk [`5d4d7d7`](https://github.com/RhetTbull/osxphotos/commit/5d4d7d7db7ca1109b6230803fe777d7a30882efe)
- Fixed erroneous attempt to export edited with --download-missing [`8dc59cb`](https://github.com/RhetTbull/osxphotos/commit/8dc59cbc35c33e71d0d912f4139e855180ac4fbd)
- version bump [`802e2f0`](https://github.com/RhetTbull/osxphotos/commit/802e2f069a5f8b37ddc6b3b8ba07519ce10f88a7)
#### [v0.36.15](https://github.com/RhetTbull/osxphotos/compare/v0.36.14...v0.36.15)
> 11 November 2020
- Avoid copying db files if not necessary [`ea9b41b`](https://github.com/RhetTbull/osxphotos/commit/ea9b41bae41a05aad53454f67871c5e6c9a49f79)
#### [v0.36.14](https://github.com/RhetTbull/osxphotos/compare/v0.36.13...v0.36.14)
> 9 November 2020
- Fix for issue #247 [`38397b5`](https://github.com/RhetTbull/osxphotos/commit/38397b507b456169cf3be2d2dc6743ec8653feb3)
#### [v0.36.13](https://github.com/RhetTbull/osxphotos/compare/v0.36.11...v0.36.13)
> 9 November 2020
- Refactored phototemplate.py to add PATH_SEP option [`3636fcb`](https://github.com/RhetTbull/osxphotos/commit/3636fcbc76100d9898a59f24ed6e9b1965cc6022)
- More work on phototemplate.py to add inline expansion [`a6231e2`](https://github.com/RhetTbull/osxphotos/commit/a6231e29ff28b2c7dc3239445f41afcb35926a7a)
#### [v0.36.11](https://github.com/RhetTbull/osxphotos/compare/v0.36.10...v0.36.11)
> 8 November 2020

260
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-10-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-11-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,18 +383,57 @@ 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'.
--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.
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). 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').
--use-photokit Use with '--download-missing' or '--use-
photos-export' to use direct Photos
interface instead of AppleScript to export.
Highly experimental alpha feature; does not
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 **
@@ -370,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
@@ -504,6 +585,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
@@ -619,18 +703,26 @@ exported, one to each directory. For example: --directory
of the following directories if the photos were created in 2019 and were in
albums 'Vacation' and 'Family': 2019/Vacation, 2019/Family
Substitution Description
{album} Album(s) photo is contained in
{folder_album} Folder path + album photo is contained in. e.g.
'Folder/Subfolder/Album' or just 'Album' if no enclosing
folder
{keyword} Keyword(s) assigned to photo
{person} Person(s) / face(s) in a photo
{label} Image categorization label associated with a photo
(Photos 5 only)
{label_normalized} All lower case version of 'label' (Photos 5 only)
{comment} Comment(s) on shared Photos; format is 'Person name:
comment text' (Photos 5 only)
Substitution Description
{album} Album(s) photo is contained in
{folder_album} Folder path + album photo is contained in. e.g.
'Folder/Subfolder/Album' or just 'Album' if no
enclosing folder
{keyword} Keyword(s) assigned to photo
{person} Person(s) / face(s) in a photo
{label} Image categorization label associated with a photo
(Photos 5 only)
{label_normalized} All lower case version of 'label' (Photos 5 only)
{comment} Comment(s) on shared Photos; format is 'Person
name: comment text' (Photos 5 only)
{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.
```
Example: export all photos to ~/Desktop/export group in folders by date created
@@ -1425,7 +1517,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).
@@ -1440,7 +1532,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
@@ -1462,7 +1553,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>
@@ -1970,6 +2060,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|
@@ -2028,6 +2119,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
@@ -2119,7 +2211,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.
@@ -2149,6 +2241,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/grundsch"><img src="https://avatars0.githubusercontent.com/u/3874928?v=4?s=100" width="100px;" alt=""/><br /><sub><b>grundsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=grundsch" title="Code">💻</a></td>
<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>
</tr>
</table>
@@ -2173,8 +2266,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
@@ -2185,9 +2276,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.

View File

@@ -5,4 +5,4 @@
# If you need to install pyinstaller:
# python3 -m pip install --upgrade pyinstaller
pyinstaller --onefile --hidden-import="pkg_resources.py2_warn" --name osxphotos --add-data osxphotos/templates/xmp_sidecar.mako:osxphotos/templates cli.py
pyinstaller osxphotos.spec

48
osxphotos.spec Normal file
View File

@@ -0,0 +1,48 @@
# -*- mode: python ; coding: utf-8 -*-
# spec file for pyinstaller
# run `pyinstaller osxphotos.spec`
import os
import importlib
pathex = os.getcwd()
# include necessary data files
datas=[('osxphotos/templates/xmp_sidecar.mako', 'osxphotos/templates')]
package_imports = [['photoscript', ['photoscript.applescript']]]
for package, files in package_imports:
proot = os.path.dirname(importlib.import_module(package).__file__)
datas.extend((os.path.join(proot, f), package) for f in files)
block_cipher = None
a = Analysis(['cli.py'],
pathex=[pathex],
binaries=[],
datas=datas,
hiddenimports=['pkg_resources.py2_warn'],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='osxphotos',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True )

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.36.13"
__version__ = "0.38.5"

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

@@ -14,7 +14,7 @@ from sqlite3 import Error
from ._version import __version__
OSXPHOTOS_EXPORTDB_VERSION = "2.0"
OSXPHOTOS_EXPORTDB_VERSION = "3.2"
class ExportDB_ABC(ABC):
@@ -76,6 +76,14 @@ class ExportDB_ABC(ABC):
def set_exifdata_for_file(self, uuid, exifdata):
pass
@abstractmethod
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
pass
@abstractmethod
def get_sidecar_for_file(self, filename):
pass
@abstractmethod
def set_data(
self,
@@ -141,6 +149,12 @@ class ExportDBNoOp(ExportDB_ABC):
def set_exifdata_for_file(self, uuid, exifdata):
pass
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
pass
def get_sidecar_for_file(self, filename):
return None, (None, None, None)
def set_data(
self,
filename,
@@ -379,6 +393,48 @@ class ExportDB(ExportDB_ABC):
except Error as e:
logging.warning(e)
def get_sidecar_for_file(self, filename):
""" returns the sidecar data and signature for a file """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"SELECT sidecar_data, mode, size, mtime FROM sidecar WHERE filepath_normalized = ?",
(filename,),
)
results = c.fetchone()
if results:
sidecar_data = results[0]
sidecar_sig = (
results[1],
results[2],
int(results[3]) if results[3] is not None else None,
)
else:
sidecar_data = None
sidecar_sig = (None, None, None)
except Error as e:
logging.warning(e)
sidecar_data = None
sidecar_sig = (None, None, None)
return sidecar_data, sidecar_sig
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
""" sets the sidecar data and signature for a file """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"INSERT OR REPLACE INTO sidecar(filepath_normalized, sidecar_data, mode, size, mtime) VALUES (?, ?, ?, ?, ?);",
(filename, sidecar_data, *sidecar_sig),
)
conn.commit()
except Error as e:
logging.warning(e)
def set_data(
self,
filename,
@@ -479,13 +535,11 @@ class ExportDB(ExportDB_ABC):
if not os.path.isfile(dbfile):
conn = self._get_db_connection(dbfile)
if conn:
self._create_db_tables(conn)
self.was_created = True
self.was_upgraded = ()
self.version = OSXPHOTOS_EXPORTDB_VERSION
else:
if not conn:
raise Exception("Error getting connection to database {dbfile}")
self._create_db_tables(conn)
self.was_created = True
self.was_upgraded = ()
else:
conn = self._get_db_connection(dbfile)
self.was_created = False
@@ -495,8 +549,7 @@ class ExportDB(ExportDB_ABC):
self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION)
else:
self.was_upgraded = ()
self.version = OSXPHOTOS_EXPORTDB_VERSION
self.version = OSXPHOTOS_EXPORTDB_VERSION
return conn
def _get_db_connection(self, dbfile):
@@ -570,11 +623,20 @@ class ExportDB(ExportDB_ABC):
size INTEGER,
mtime REAL
); """,
"sql_sidecar_table": """ CREATE TABLE IF NOT EXISTS sidecar (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
sidecar_data TEXT,
mode INTEGER,
size INTEGER,
mtime REAL
); """,
"sql_files_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """,
"sql_info_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """,
"sql_exifdata_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """,
"sql_edited_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_edited_filename on edited (filepath_normalized);""",
"sql_converted_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_converted_filename on converted (filepath_normalized);""",
"sql_sidecar_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_sidecar_filename on sidecar (filepath_normalized);""",
}
try:
c = conn.cursor()

View File

@@ -1,6 +1,5 @@
""" FileUtil class with methods for copy, hardlink, unlink, etc. """
import logging
import os
import pathlib
import stat
@@ -8,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 """
@@ -28,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):
@@ -74,42 +81,41 @@ class FileUtilMacOS(FileUtilABC):
try:
os.link(src, dest)
except Exception as e:
logging.critical(f"os.link returned error: {e}")
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.isfile(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:
logging.critical(
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
)
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):
@@ -119,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. """
@@ -169,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
@@ -183,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):
@@ -194,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 """
@@ -233,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

@@ -1,8 +1,9 @@
""" utility functions for validating/sanitizing path components """
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
import pathvalidate
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
def sanitize_filepath(filepath):
""" sanitize a filepath """

File diff suppressed because it is too large Load Diff

View File

@@ -91,9 +91,10 @@ class PhotoInfo:
and self.raw_original
):
# return the JPEG version as that's what Photos 5+ does
return self._info["raw_pair_info"]["originalFilename"]
original_name = self._info["raw_pair_info"]["originalFilename"]
else:
return self._info["originalFilename"]
original_name = self._info["originalFilename"]
return original_name or self.filename
@property
def date(self):
@@ -164,6 +165,8 @@ class PhotoInfo:
photopath = os.path.join(
self._db._masters_path, self._info["imagePath"]
)
if not os.path.isfile(photopath):
photopath = None
self._path = photopath
return photopath
@@ -175,6 +178,8 @@ class PhotoInfo:
self._info["directory"],
self._info["filename"],
)
if not os.path.isfile(photopath):
photopath = None
self._path = photopath
return photopath
@@ -188,6 +193,8 @@ class PhotoInfo:
self._info["directory"],
self._info["filename"],
)
if not os.path.isfile(photopath):
photopath = None
self._path = photopath
return photopath

1215
osxphotos/photokit.py Normal file

File diff suppressed because it is too large Load Diff

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,
@@ -35,6 +34,7 @@ from .._constants import (
from .._version import __version__
from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..fileutil import FileUtil
from ..personinfo import PersonInfo
from ..photoinfo import PhotoInfo
from ..utils import (
@@ -531,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():
@@ -546,6 +546,33 @@ class PhotosDB:
return dest_path
# NOTE: This method seems to cause problems with applescript
# Bummer...would'be been nice to avoid copying the DB
# def _link_db_file(self, fname):
# """ links the sqlite database file to a temp file """
# """ returns the name of the temp file """
# """ If sqlite shared memory and write-ahead log files exist, those are copied too """
# # required because python's sqlite3 implementation can't read a locked file
# # _, suffix = os.path.splitext(fname)
# dest_name = dest_path = ""
# try:
# dest_name = pathlib.Path(fname).name
# dest_path = os.path.join(self._tempdir_name, dest_name)
# FileUtil.hardlink(fname, dest_path)
# # link write-ahead log and shared memory files (-wal and -shm) files if they exist
# if os.path.exists(f"{fname}-wal"):
# FileUtil.hardlink(f"{fname}-wal", f"{dest_path}-wal")
# if os.path.exists(f"{fname}-shm"):
# FileUtil.hardlink(f"{fname}-shm", f"{dest_path}-shm")
# except:
# print("Error linking " + fname + " to " + dest_path, file=sys.stderr)
# raise Exception
# if _debug():
# logging.debug(dest_path)
# return dest_path
def _process_database4(self):
""" process the Photos database to extract info
works on Photos version <= 4.0 """

View File

@@ -18,6 +18,7 @@ from functools import partial
from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifTool
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
# ensure locale set to user's locale
@@ -51,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",
@@ -126,6 +128,10 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{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.",
}
# Just the multi-valued substitution names without the braces
@@ -150,6 +156,62 @@ class PhotoTemplate:
# gets initialized in get_template_value
self.today = None
def make_subst_function(
self, none_str, filename, dirname, replacement, get_func=None
):
""" returns: substitution function for use in re.sub
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
if get_func is None:
# used by make_subst_function to get the value for a template substitution
get_func = partial(
self.get_template_value,
filename=filename,
dirname=dirname,
replacement=replacement,
)
# closure to capture photo, none_str, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups != 5:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
delim = matchobj.group(1)
field = matchobj.group(2)
path_sep = matchobj.group(3)
bool_val = matchobj.group(4)
default = matchobj.group(5)
# drop the '+' on delim
delim = delim[:-1] if delim is not None else None
# drop () from path_sep
path_sep = path_sep.strip("()") if path_sep is not None else None
# drop the ? on bool_val
bool_val = bool_val[1:] if bool_val is not None else None
# drop the comma on default
default_val = default[1:] if default is not None else None
try:
val = get_func(field, default_val, bool_val, delim, path_sep)
except ValueError:
return matchobj.group(0)
if val is None:
# field valid but didn't match a value
if default == ",":
val = ""
else:
val = default_val if default_val is not None else none_str
return val
return subst
def render(
self,
template,
@@ -208,60 +270,7 @@ class PhotoTemplate:
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
# used by make_subst_function to get the value for a template substitution
get_func = partial(
self.get_template_value,
filename=filename,
dirname=dirname,
replacement=replacement,
)
def make_subst_function(self, none_str, get_func=get_func):
""" returns: substitution function for use in re.sub
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
# closure to capture photo, none_str, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 5:
delim = matchobj.group(1)
field = matchobj.group(2)
path_sep = matchobj.group(3)
bool_val = matchobj.group(4)
default = matchobj.group(5)
# drop the '+' on delim
delim = delim[:-1] if delim is not None else None
# drop () from path_sep
path_sep = path_sep.strip("()") if path_sep is not None else None
# drop the ? on bool_val
bool_val = bool_val[1:] if bool_val is not None else None
# drop the comma on default
default_val = default[1:] if default is not None else None
try:
val = get_func(field, default_val, bool_val, delim, path_sep)
except ValueError:
return matchobj.group(0)
if val is None:
# field valid but didn't match a value
if default == ",":
val = ""
else:
val = default_val if default_val is not None else none_str
return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
return subst
subst_func = make_subst_function(self, none_str)
subst_func = self.make_subst_function(none_str, filename, dirname, replacement)
# do the replacements
rendered = re.sub(regex, subst_func, template)
@@ -289,88 +298,28 @@ class PhotoTemplate:
# '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',]
rendered_strings = [rendered]
for field in MULTI_VALUE_SUBSTITUTIONS:
# Build a regex that matches only the field being processed
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"("
+ field # group 2: field name
+ r")"
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
)
regex_multi = re.compile(re_str)
rendered_strings = self._render_multi_valued_templates(
rendered,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
else path_sep
)
values = self.get_template_value_multi(
field,
path_sep,
filename=filename,
dirname=dirname,
replacement=replacement,
)
if expand_inplace or matches.group(1) is not None:
delim = (
matches.group(1)[:-1] if matches.group(1) is not None else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = delim.join(sorted(values)) if values and values[0] else None
def lookup_template_value_multi(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(f"Unexpected value: {lookup_value}")
subst = make_subst_function(
self, none_str, get_func=lookup_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = {new_string}
else:
# create a new template string for each value
for val in values:
def lookup_template_value_multi(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = make_subst_function(
self, none_str, get_func=lookup_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = list(new_strings.keys())
# process exiftool: templates
rendered_strings = self._render_exiftool_template(
rendered_strings,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
)
# find any {fields} that weren't replaced
unmatched = []
@@ -396,6 +345,244 @@ class PhotoTemplate:
return rendered_strings, unmatched
def _render_multi_valued_templates(
self,
rendered,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
):
rendered_strings = [rendered]
new_rendered_strings = []
while new_rendered_strings != rendered_strings:
new_rendered_strings = rendered_strings
for field in MULTI_VALUE_SUBSTITUTIONS:
# Build a regex that matches only the field being processed
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"("
+ field # group 2: field name
+ r")"
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
)
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
else path_sep
)
values = self.get_template_value_multi(
field,
path_sep,
filename=filename,
dirname=dirname,
replacement=replacement,
)
if expand_inplace or matches.group(1) is not None:
delim = (
matches.group(1)[:-1]
if matches.group(1) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = (
delim.join(sorted(values))
if values and values[0]
else None
)
def lookup_template_value_multi(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_multi,
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = list({new_string})
else:
# create a new template string for each value
for val in values:
def lookup_template_value_multi(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_multi,
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = sorted(list(new_strings.keys()))
return rendered_strings
def _render_exiftool_template(
self,
rendered_strings,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
):
# TODO: lots of code commonality with render_multi_valued_templates -- combine or pull out
# TODO: put these in globals
if path_sep is None:
path_sep = os.path.sep
if inplace_sep is None:
inplace_sep = ","
# Build a regex that matches only the field being processed
# todo: pull out regexes into globals?
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"(exiftool:[^\\,}+\?]+)" # group 3 field name
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
)
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_rendered_strings = []
while new_rendered_strings != rendered_strings:
new_rendered_strings = rendered_strings
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
# allmatches = regex_multi.finditer(str_template)
# for matches in allmatches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
else path_sep
)
field = matches.group(2)
subfield = field[9:]
if not self.photo.path:
values = []
else:
exif = ExifTool(self.photo.path)
exifdict = exif.asdict()
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
subfield = subfield.lower()
if subfield in exifdict:
values = exifdict[subfield]
values = (
[values] if not isinstance(values, list) else values
)
else:
values = [None]
if expand_inplace or matches.group(1) is not None:
delim = (
matches.group(1)[:-1]
if matches.group(1) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = (
delim.join(sorted(values)) if values and values[0] else None
)
def lookup_template_value_exif(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(f"Unexpected value: {lookup_value}")
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = list({new_string})
else:
# create a new template string for each value
for val in values:
def lookup_template_value_exif(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = sorted(list(new_strings.keys()))
return rendered_strings
def get_template_value(
self,
field,
@@ -446,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":
@@ -681,6 +870,7 @@ class PhotoTemplate:
"""
""" return list of values for a multi-valued template field """
values = []
if field == "album":
values = self.photo.albums
elif field == "keyword":
@@ -724,7 +914,7 @@ class PhotoTemplate:
values = [
f"{comment.user}: {comment.text}" for comment in self.photo.comments
]
else:
elif not field.startswith("exiftool:"):
raise ValueError(f"Unhandled template value: {field}")
# sanitize directory names if needed, folder_album handled differently above
@@ -775,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

@@ -12,7 +12,7 @@
% if desc is None:
<dc:description></dc:description>
% else:
<dc:description>${desc}</dc:description>
<dc:description>${desc | x}</dc:description>
% endif
</%def>
@@ -20,7 +20,7 @@
% if title is None:
<dc:title></dc:title>
% else:
<dc:title>${title}</dc:title>
<dc:title>${title | x}</dc:title>
% endif
</%def>
@@ -30,7 +30,7 @@
<dc:subject>
<rdf:Seq>
% for subj in subject:
<rdf:li>${subj}</rdf:li>
<rdf:li>${subj | x}</rdf:li>
% endfor
</rdf:Seq>
</dc:subject>
@@ -48,7 +48,7 @@
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
% for person in persons:
<rdf:li>${person}</rdf:li>
<rdf:li>${person | x}</rdf:li>
% endfor
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
@@ -60,7 +60,7 @@
<digiKam:TagsList>
<rdf:Seq>
% for keyword in keywords:
<rdf:li>${keyword}</rdf:li>
<rdf:li>${keyword | x}</rdf:li>
% endfor
</rdf:Seq>
</digiKam:TagsList>
@@ -81,10 +81,8 @@
<%def name="gps_info(latitude, longitude)">
% if latitude is not None and longitude is not None:
<exif:GPSLongitudeRef>${"E" if longitude >= 0 else "W"}</exif:GPSLongitudeRef>
<exif:GPSLongitude>${abs(longitude)}</exif:GPSLongitude>
<exif:GPSLatitude>${abs(latitude)}</exif:GPSLatitude>
<exif:GPSLatitudeRef>${"N" if latitude >= 0 else "S"}</exif:GPSLatitudeRef>
<exif:GPSLongitude>${int(abs(longitude))},${(abs(longitude) % 1) * 60}${"E" if longitude >= 0 else "W"}</exif:GPSLongitude>
<exif:GPSLatitude>${int(abs(latitude))},${(abs(latitude) % 1) * 60}${"N" if latitude >= 0 else "S"}</exif:GPSLatitude>
% endif
</%def>

View File

@@ -17,7 +17,6 @@ from plistlib import load as plistload
import CoreFoundation
import CoreServices
import objc
from Foundation import *
from ._constants import UNICODE_FORMAT
from .fileutil import FileUtil
@@ -57,10 +56,12 @@ def _debug():
""" returns True if debugging turned on (via _set_debug), otherwise, false """
return _DEBUG
def noop(*args, **kwargs):
""" do nothing (no operation) """
pass
def _get_os_version():
# returns tuple containing OS version
# e.g. 10.13.6 = (10, 13, 6)
@@ -200,7 +201,7 @@ def get_last_library_path():
# pylint: disable=no-member
# pylint: disable=undefined-variable
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
CoreFoundation.kCFAllocatorDefault, photosurlref, 0, None, None, None, None
)
# the CFURLRef we got is a sruct that python treats as an array
@@ -361,9 +362,35 @@ def _db_is_locked(dbname):
def normalize_unicode(value):
""" normalize unicode data """
if value is not None:
if not isinstance(value, str):
raise ValueError("value must be str")
return unicodedata.normalize(UNICODE_FORMAT, value)
else:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("value must be str")
return unicodedata.normalize(UNICODE_FORMAT, value)
def increment_filename(filepath):
""" Return filename (1).ext, etc if filename.ext exists
If file exists in filename's parent folder with same stem as filename,
add (1), (2), etc. until a non-existing filename is found.
Args:
filepath: str; full path, including file name
Returns:
new filepath (or same if not incremented)
Note: This obviously is subject to race condition so using with caution.
"""
dest = pathlib.Path(str(filepath))
count = 1
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = dest.stem
while dest_new.lower() in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
return str(dest)

View File

@@ -47,6 +47,7 @@ parso==0.6.2
pathspec==0.7.0
pathvalidate==2.2.1
pexpect==4.8.0
photoscript==0.1.0
pickleshare==0.7.5
Pillow==7.2.0
pkginfo==1.5.0.1

View File

@@ -79,6 +79,8 @@ setup(
"pathvalidate==2.2.1",
"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

114
tests/tempdiskimage.py Normal file
View File

@@ -0,0 +1,114 @@
""" Create a temporary disk image on MacOS """
import pathlib
import platform
import subprocess
import tempfile
import time
class TempDiskImage:
""" Create and mount a temporary disk image """
def __init__(self, size=100, prefix=None):
""" Create and mount a temporary disk image.
Args:
size: int; size in MB of disk image, default = 100
prefix: str; optional prefix to prepend to name of the temporary disk image
name: str; name of the mounted volume, default = "TemporaryDiskImage"
Raises:
TypeError if size is not int
RunTimeError if not on MacOS
"""
if type(size) != int:
raise TypeError("size must be int")
system = platform.system()
if system != "Darwin":
raise RuntimeError("TempDiskImage only runs on MacOS")
self._tempdir = tempfile.TemporaryDirectory()
# hacky mktemp: this could create a race condition but unlikely given it's created in a TemporaryDirectory
prefix = "TemporaryDiskImage" if prefix is None else prefix
volume_name = f"{prefix}_{str(time.time()).replace('.','_')}_{str(time.perf_counter()).replace('.','_')}"
image_name = f"{volume_name}.dmg"
image_path = pathlib.Path(self._tempdir.name) / image_name
hdiutil = subprocess.run(
[
"/usr/bin/hdiutil",
"create",
"-size",
f"{size}m",
"-fs",
"HFS+",
"-volname",
volume_name,
image_path,
],
check=True,
text=True,
capture_output=True,
)
if "created" not in hdiutil.stdout:
raise OSError(f"Could not create DMG {image_path}")
self.path = image_path
self._mount_point, self.name = self._mount_image(self.path)
def _mount_image(self, image_path):
""" mount a DMG file and return path, returns (mount_point, path) """
hdiutil = subprocess.run(
["/usr/bin/hdiutil", "attach", image_path],
check=True,
text=True,
capture_output=True,
)
mount_point, path = None, None
for line in hdiutil.stdout.split("\n"):
line = line.strip()
if "Apple_HFS" not in line:
continue
output = line.split()
if len(output) < 3:
raise ValueError(f"Error mounting disk image {image_path}")
mount_point = output[0]
path = output[2]
break
return (mount_point, path)
def unmount(self):
try:
if self._mount_point:
hdiutil = subprocess.run(
["/usr/bin/hdiutil", "detach", self._mount_point],
check=True,
text=True,
capture_output=True,
)
self._mount_point = None
except AttributeError:
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.unmount()
if exc_type:
return False
if __name__ == "__main__":
# Create a temporary disk image, 50mb in size
img = TempDiskImage(size=50, prefix="MyDiskImage")
# Be sure to unmount it, image will be cleaned up automatically
img.unmount()
# Or use it as a context handler
# Default values are 100mb and prefix = "TemporaryDiskImage"
with TempDiskImage() as img:
print(f"image: {img.path}")
print(f"mounted at: {img.name}")

View File

@@ -177,6 +177,12 @@ RAW_DICT = {
),
}
ORIGINAL_FILENAME_DICT = {
"uuid": "D79B8D77-BFFC-460B-9312-034F2877D35B",
"filename": "D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg",
"original_filename": "Pumkins2.jpg",
}
@pytest.fixture(scope="module")
def photosdb():
@@ -864,6 +870,27 @@ def test_export_14(photosdb, caplog):
assert "Invalid destination suffix" not in caplog.text
def test_export_no_original_filename(photosdb):
# test export OK if original filename is null
# issue #267
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
# monkey patch original_filename for testing
original_filename = photos[0]._info["originalFilename"]
photos[0]._info["originalFilename"] = None
filename = f"{photos[0].uuid}.jpeg"
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)[0]
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
photos[0]._info["originalFilename"] = original_filename
def test_eq():
""" Test equality of two PhotoInfo objects """
@@ -1070,3 +1097,18 @@ def test_verbose(capsys):
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB, verbose=print)
captured = capsys.readouterr()
assert "Processing database" in captured.out
def test_original_filename(photosdb):
""" test original filename """
uuid = ORIGINAL_FILENAME_DICT["uuid"]
photo = photosdb.get_photo(uuid)
assert photo.original_filename == ORIGINAL_FILENAME_DICT["original_filename"]
assert photo.filename == ORIGINAL_FILENAME_DICT["filename"]
# monkey patch
original_filename = photo._info["originalFilename"]
photo._info["originalFilename"] = None
assert photo.original_filename == ORIGINAL_FILENAME_DICT["filename"]
photo._info["originalFilename"] = original_filename

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -11,9 +11,9 @@ pytestmark = pytest.mark.skipif(
PHOTOS_DB = "/Users/rhet/Pictures/Photos Library.photoslibrary"
UUID_DICT = {
"has_adjustments": "A8111956-E900-4DEC-9191-A04A87C07BC5",
"no_adjustments": "EA7BB55F-92F1-4818-94E3-E8DEDC6B2E31",
"live": "9032C168-9319-40C0-8210-5ADC42F4C603",
"has_adjustments": "2B2D5434-6D31-49E2-BF47-B973D34A317B",
"no_adjustments": "A8D646C3-89A9-4D74-8001-4EB46BA55B94",
"live": "BFF29EBD-22DF-4FCF-9817-317E7104EA50",
}

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"],
@@ -78,14 +77,13 @@ EXIF_JSON_EXPECTED = """
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2019:04:15",
"IPTC:DateCreated": "2019:04:15",
"IPTC:TimeCreated": "14:40:24-04:00",
"EXIF:ModifyDate": "2019:07:27 17:33:28"}]
"""
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"],
@@ -94,8 +92,8 @@ EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2019:04:15",
"IPTC:DateCreated": "2019:04:15",
"IPTC:TimeCreated": "14:40:24-04:00",
"EXIF:ModifyDate": "2019:04:15 14:40:24"}]
"""
@@ -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"],
@@ -554,8 +529,8 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog):
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2019:04:15",
"IPTC:DateCreated": "2019:04:15",
"IPTC:TimeCreated": "14:40:24-04:00",
"EXIF:ModifyDate": "2019:07:27 17:33:28"}]
"""
)[0]
@@ -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"],
@@ -604,8 +578,8 @@ def test_exiftool_json_sidecar_keyword_template():
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2019:04:15",
"IPTC:DateCreated": "2019:04:15",
"IPTC:TimeCreated": "14:40:24-04:00",
"EXIF:ModifyDate": "2019:07:27 17:33:28"}]
"""
)[0]
@@ -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"],
@@ -666,8 +639,8 @@ def test_exiftool_json_sidecar_use_persons_keyword():
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
"EXIF:CreateDate": "2018:09:28 15:35:49",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2018:09:28",
"IPTC:DateCreated": "2018:09:28",
"IPTC:TimeCreated": "15:35:49-04:00",
"EXIF:ModifyDate": "2018:09:28 15:35:49"}]
"""
)[0]
@@ -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"],
@@ -709,8 +681,8 @@ def test_exiftool_json_sidecar_use_albums_keyword():
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
"EXIF:CreateDate": "2018:09:28 15:35:49",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2018:09:28",
"IPTC:DateCreated": "2018:09:28",
"IPTC:TimeCreated": "15:35:49-04:00",
"EXIF:ModifyDate": "2018:09:28 15:35:49"}]
"""
)[0]
@@ -1029,7 +1001,7 @@ def test_xmp_sidecar_gps():
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description></dc:description>
<dc:title>St. James's Park</dc:title>
<dc:title>St. James&#39;s Park</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
@@ -1038,7 +1010,7 @@ def test_xmp_sidecar_gps():
<rdf:li>London</rdf:li>
<rdf:li>United Kingdom</rdf:li>
<rdf:li>London 2018</rdf:li>
<rdf:li>St. James's Park</rdf:li>
<rdf:li>St. James&#39;s Park</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-10-13T09:18:12.501000-04:00</photoshop:DateCreated>
@@ -1055,7 +1027,7 @@ def test_xmp_sidecar_gps():
<rdf:li>London</rdf:li>
<rdf:li>United Kingdom</rdf:li>
<rdf:li>London 2018</rdf:li>
<rdf:li>St. James's Park</rdf:li>
<rdf:li>St. James&#39;s Park</rdf:li>
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
@@ -1066,10 +1038,8 @@ def test_xmp_sidecar_gps():
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
<exif:GPSLongitudeRef>W</exif:GPSLongitudeRef>
<exif:GPSLongitude>0.1318055</exif:GPSLongitude>
<exif:GPSLatitude>51.50357167</exif:GPSLatitude>
<exif:GPSLatitudeRef>N</exif:GPSLatitudeRef>
<exif:GPSLongitude>0,7.908329999999999W</exif:GPSLongitude>
<exif:GPSLatitude>51,30.21430019999997N</exif:GPSLatitude>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""

View File

@@ -20,10 +20,10 @@ NAMES_DICT = {
"heic": "7783E8E6-9CAC-40F3-BE22-81FB7051C266.jpeg",
}
UUID_LIVE_HEIC = "1337F3F6-5C9F-4FC7-80CC-BD9A5B928F72"
UUID_LIVE_HEIC = "612CE30B-3D8F-417A-9B14-EC42CBA10ACC"
NAMES_LIVE_HEIC = [
"1337F3F6-5C9F-4FC7-80CC-BD9A5B928F72.jpeg",
"1337F3F6-5C9F-4FC7-80CC-BD9A5B928F72.mov",
"612CE30B-3D8F-417A-9B14-EC42CBA10ACC.jpeg",
"612CE30B-3D8F-417A-9B14-EC42CBA10ACC.mov",
]

View File

@@ -4,6 +4,8 @@ import pytest
EXIF_DATA = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "EXIF:ImageDescription": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Title": "Elder Park", "EXIF:GPSLatitude": "34 deg 55' 8.01\" S", "EXIF:GPSLongitude": "138 deg 35' 48.70\" E", "Composite:GPSPosition": "34 deg 55' 8.01\" S, 138 deg 35' 48.70\" E", "EXIF:GPSLatitudeRef": "South", "EXIF:GPSLongitudeRef": "East", "EXIF:DateTimeOriginal": "2017:06:20 17:18:56", "EXIF:OffsetTimeOriginal": "+09:30", "EXIF:ModifyDate": "2020:05:18 14:42:04"}]"""
INFO_DATA = """{"uuid": "3DD2C897-F19E-4CA6-8C22-B027D5A71907", "filename": "3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "original_filename": "IMG_4547.jpg", "date": "2017-06-20T17:18:56.518000+09:30", "description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "title": "Elder Park", "keywords": [], "labels": ["Statue", "Art"], "albums": ["AlbumInFolder"], "folders": {"AlbumInFolder": ["Folder1", "SubFolder2"]}, "persons": [], "path": "/Users/rhet/Pictures/Test-10.15.4.photoslibrary/originals/3/3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "ismissing": false, "hasadjustments": true, "external_edit": false, "favorite": false, "hidden": false, "latitude": -34.91889167000001, "longitude": 138.59686167, "path_edited": "/Users/rhet/Pictures/Test-10.15.4.photoslibrary/resources/renders/3/3DD2C897-F19E-4CA6-8C22-B027D5A71907_1_201_a.jpeg", "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": false, "incloud": null, "date_modified": "2020-05-18T14:42:04.608664+09:30", "portrait": false, "screenshot": false, "slow_mo": false, "time_lapse": false, "hdr": false, "selfie": false, "panorama": false, "has_raw": false, "uti_raw": null, "path_raw": null, "place": {"name": "Elder Park, Adelaide, South Australia, Australia, River Torrens", "names": {"field0": [], "country": ["Australia"], "state_province": ["South Australia"], "sub_administrative_area": ["Adelaide"], "city": ["Adelaide", "Adelaide"], "field5": [], "additional_city_info": ["Adelaide CBD", "Tarndanya"], "ocean": [], "area_of_interest": ["Elder Park", ""], "inland_water": ["River Torrens", "River Torrens"], "field10": [], "region": [], "sub_throughfare": [], "field13": [], "postal_code": [], "field15": [], "field16": [], "street_address": [], "body_of_water": ["River Torrens", "River Torrens"]}, "country_code": "AU", "ishome": false, "address_str": "River Torrens, Adelaide SA, Australia", "address": {"street": null, "sub_locality": "Tarndanya", "city": "Adelaide", "sub_administrative_area": "Adelaide", "state_province": "SA", "postal_code": null, "country": "Australia", "iso_country_code": "AU"}}, "exif": {"flash_fired": false, "iso": 320, "metering_mode": 3, "sample_rate": null, "track_format": null, "white_balance": 0, "aperture": 2.2, "bit_rate": null, "duration": null, "exposure_bias": 0.0, "focal_length": 4.15, "fps": null, "latitude": null, "longitude": null, "shutter_speed": 0.058823529411764705, "camera_make": "Apple", "camera_model": "iPhone 6s", "codec": null, "lens_model": "iPhone 6s back camera 4.15mm f/2.2"}}"""
SIDECAR_DATA = """FOO_BAR"""
EXIF_DATA2 = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "XMP:Title": "St. James's Park", "XMP:TagsList": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "IPTC:Keywords": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "XMP:Subject": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "EXIF:GPSLatitude": "51 deg 30' 12.86\" N", "EXIF:GPSLongitude": "0 deg 7' 54.50\" W", "Composite:GPSPosition": "51 deg 30' 12.86\" N, 0 deg 7' 54.50\" W", "EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", "EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:12:08 14:06:44"}]"""
INFO_DATA2 = """{"uuid": "F2BB3F98-90F0-4E4C-A09B-25C6822A4529", "filename": "F2BB3F98-90F0-4E4C-A09B-25C6822A4529.jpeg", "original_filename": "IMG_8440.JPG", "date": "2019-06-11T11:42:06.711805-07:00", "description": null, "title": null, "keywords": [], "labels": ["Sky", "Cloudy", "Fence", "Land", "Outdoor", "Park", "Amusement Park", "Roller Coaster"], "albums": [], "folders": {}, "persons": [], "path": "/Volumes/MacBook Catalina - Data/Users/rhet/Pictures/Photos Library.photoslibrary/originals/F/F2BB3F98-90F0-4E4C-A09B-25C6822A4529.jpeg", "ismissing": false, "hasadjustments": false, "external_edit": false, "favorite": false, "hidden": false, "latitude": 33.81558666666667, "longitude": -117.99298, "path_edited": null, "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": true, "incloud": true, "date_modified": "2019-10-14T00:51:47.141950-07:00", "portrait": false, "screenshot": false, "slow_mo": false, "time_lapse": false, "hdr": false, "selfie": false, "panorama": false, "has_raw": false, "uti_raw": null, "path_raw": null, "place": {"name": "Adventure City, Stanton, California, United States", "names": {"field0": [], "country": ["United States"], "state_province": ["California"], "sub_administrative_area": ["Orange"], "city": ["Stanton", "Anaheim", "Anaheim"], "field5": [], "additional_city_info": ["West Anaheim"], "ocean": [], "area_of_interest": ["Adventure City", "Adventure City"], "inland_water": [], "field10": [], "region": [], "sub_throughfare": [], "field13": [], "postal_code": [], "field15": [], "field16": [], "street_address": [], "body_of_water": []}, "country_code": "US", "ishome": false, "address_str": "Adventure City, 1240 S Beach Blvd, Anaheim, CA 92804, United States", "address": {"street": "1240 S Beach Blvd", "sub_locality": "West Anaheim", "city": "Stanton", "sub_administrative_area": "Orange", "state_province": "CA", "postal_code": "92804", "country": "United States", "iso_country_code": "US"}}, "exif": {"flash_fired": false, "iso": 25, "metering_mode": 5, "sample_rate": null, "track_format": null, "white_balance": 0, "aperture": 2.2, "bit_rate": null, "duration": null, "exposure_bias": 0.0, "focal_length": 4.15, "fps": null, "latitude": null, "longitude": null, "shutter_speed": 0.0004940711462450593, "camera_make": "Apple", "camera_model": "iPhone 6s", "codec": null, "lens_model": "iPhone 6s back camera 4.15mm f/2.2"}}"""
DATABASE_VERSION1 = "tests/export_db_version1.db"
@@ -41,6 +43,8 @@ def test_export_db():
assert db.get_stat_edited_for_file(filepath) == (10, 11, 12)
db.set_stat_converted_for_file(filepath, (7, 8, 9))
assert db.get_stat_converted_for_file(filepath) == (7, 8, 9)
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
assert db.get_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
# test set_data which sets all at the same time
filepath2 = os.path.join(tempdir.name, "test2.jpg")
@@ -109,6 +113,8 @@ def test_export_db_no_op():
assert db.get_stat_converted_for_file(filepath) is None
db.set_stat_edited_for_file(filepath, (10, 11, 12))
assert db.get_stat_edited_for_file(filepath) is None
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
assert db.get_sidecar_for_file(filepath) == (None, (None, None, None))
# test set_data which sets all at the same time
filepath2 = os.path.join(tempdir.name, "test2.jpg")
@@ -160,6 +166,7 @@ def test_export_db_in_memory():
db.set_stat_exif_for_file(filepath, (4, 5, 6))
db.set_stat_converted_for_file(filepath, (7, 8, 9))
db.set_stat_edited_for_file(filepath, (10, 11, 12))
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
db.close()
@@ -176,6 +183,7 @@ def test_export_db_in_memory():
assert dbram.get_stat_exif_for_file(filepath) == (4, 5, 6)
assert dbram.get_stat_converted_for_file(filepath) == (7, 8, 9)
assert dbram.get_stat_edited_for_file(filepath) == (10, 11, 12)
assert dbram.get_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
# change a value
dbram.set_uuid_for_file(filepath, "FUBAR")
@@ -185,6 +193,7 @@ def test_export_db_in_memory():
dbram.set_stat_exif_for_file(filepath, (10, 11, 12))
dbram.set_stat_converted_for_file(filepath, (1, 2, 3))
dbram.set_stat_edited_for_file(filepath, (4, 5, 6))
dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22))
assert dbram.get_uuid_for_file(filepath_lower) == "FUBAR"
assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2
@@ -193,6 +202,7 @@ def test_export_db_in_memory():
assert dbram.get_stat_exif_for_file(filepath) == (10, 11, 12)
assert dbram.get_stat_converted_for_file(filepath) == (1, 2, 3)
assert dbram.get_stat_edited_for_file(filepath) == (4, 5, 6)
assert dbram.get_sidecar_for_file(filepath) == ("FUBAR", (20, 21, 22))
dbram.close()
@@ -205,6 +215,7 @@ def test_export_db_in_memory():
assert db.get_stat_exif_for_file(filepath) == (4, 5, 6)
assert db.get_stat_converted_for_file(filepath) == (7, 8, 9)
assert db.get_stat_edited_for_file(filepath) == (10, 11, 12)
assert db.get_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
assert db.get_info_for_uuid("FUBAR") is None
@@ -232,6 +243,7 @@ def test_export_db_in_memory_nofile():
dbram.set_stat_exif_for_file(filepath, (10, 11, 12))
dbram.set_stat_converted_for_file(filepath, (1, 2, 3))
dbram.set_stat_edited_for_file(filepath, (4, 5, 6))
dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22))
assert dbram.get_uuid_for_file(filepath_lower) == "FUBAR"
assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2
@@ -240,5 +252,6 @@ def test_export_db_in_memory_nofile():
assert dbram.get_stat_exif_for_file(filepath) == (10, 11, 12)
assert dbram.get_stat_converted_for_file(filepath) == (1, 2, 3)
assert dbram.get_stat_edited_for_file(filepath) == (4, 5, 6)
assert dbram.get_sidecar_for_file(filepath) == ("FUBAR", (20, 21, 22))
dbram.close()

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"],
@@ -58,8 +57,8 @@ EXIF_JSON_EXPECTED = """
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
"EXIF:CreateDate": "2018:10:13 09:18:12",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2018:10:13",
"IPTC:DateCreated": "2018:10:13",
"IPTC:TimeCreated": "09:18:12-04:00",
"EXIF:ModifyDate": "2019:12:01 11:43:45"}]
"""

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.",

387
tests/test_photokit.py Normal file
View File

@@ -0,0 +1,387 @@
""" test photokit.py methods """
import os
import pathlib
import tempfile
import pytest
from osxphotos.photokit import (
LivePhotoAsset,
PhotoAsset,
PhotoLibrary,
VideoAsset,
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
)
skip_test = "OSXPHOTOS_TEST_EXPORT" not in os.environ
pytestmark = pytest.mark.skipif(
skip_test, reason="Skip if not running with author's personal library."
)
UUID_DICT = {
"plain_photo": {
"uuid": "A8D646C3-89A9-4D74-8001-4EB46BA55B94",
"filename": "IMG_8844.JPG",
},
"hdr": {"uuid": "DA87C6FF-60E8-4DCB-A21D-9C57595667F1", "filename": "IMG_6162.JPG"},
"selfie": {
"uuid": "316AEBE0-971D-4A33-833C-6BDBFF83469B",
"filename": "IMG_1929.JPG",
},
"video": {
"uuid": "5814D9DE-FAB6-473A-9C9A-5A73C6DD1AF5",
"filename": "IMG_9411.TRIM.MOV",
},
"hasadjustments": {
"uuid": "2B2D5434-6D31-49E2-BF47-B973D34A317B",
"filename": "IMG_2860.JPG",
"adjusted_size": 3012634,
"unadjusted_size": 2580058,
},
"slow_mo": {
"uuid": "DAABC6D9-1FBA-4485-AA39-0A2B100300B1",
"filename": "IMG_4055.MOV",
},
"live_photo": {
"uuid": "612CE30B-3D8F-417A-9B14-EC42CBA10ACC",
"filename": "IMG_3259.HEIC",
"filename_video": "IMG_3259.mov",
},
"burst": {
"uuid": "CD97EC84-71F0-40C6-BAC1-2BABEE305CAC",
"filename": "IMG_8196.JPG",
"burst_selected": 3,
"burst_all": 5,
},
}
def test_fetch_uuid():
""" test fetch_uuid """
uuid = UUID_DICT["plain_photo"]["uuid"]
filename = UUID_DICT["plain_photo"]["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert isinstance(photo, PhotoAsset)
def test_plain_photo():
""" test plain_photo """
uuid = UUID_DICT["plain_photo"]["uuid"]
filename = UUID_DICT["plain_photo"]["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert photo.original_filename == filename
assert photo.isphoto
assert not photo.ismovie
def test_hdr():
""" test hdr """
uuid = UUID_DICT["hdr"]["uuid"]
filename = UUID_DICT["hdr"]["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert photo.original_filename == filename
assert photo.hdr
def test_burst():
""" test burst and burstid """
test_dict = UUID_DICT["burst"]
uuid = test_dict["uuid"]
filename = test_dict["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert photo.original_filename == filename
assert photo.burst
assert photo.burstid
# def test_selfie():
# """ test selfie """
# uuid = UUID_DICT["selfie"]["uuid"]
# filename = UUID_DICT["selfie"]["filename"]
# lib = PhotoLibrary()
# photo = lib.fetch_uuid(uuid)
# assert photo.original_filename == filename
# assert photo.selfie
def test_video():
""" test ismovie """
uuid = UUID_DICT["video"]["uuid"]
filename = UUID_DICT["video"]["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert isinstance(photo, VideoAsset)
assert photo.original_filename == filename
assert photo.ismovie
assert not photo.isphoto
def test_slow_mo():
""" test slow_mo """
test_dict = UUID_DICT["slow_mo"]
uuid = test_dict["uuid"]
filename = test_dict["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert isinstance(photo, VideoAsset)
assert photo.original_filename == filename
assert photo.ismovie
assert photo.slow_mo
assert not photo.isphoto
### PhotoAsset
def test_export_photo_original():
""" test PhotoAsset.export """
test_dict = UUID_DICT["hasadjustments"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_ORIGINAL)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
assert export_path.stat().st_size == test_dict["unadjusted_size"]
def test_export_photo_unadjusted():
""" test PhotoAsset.export """
test_dict = UUID_DICT["hasadjustments"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_UNADJUSTED)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
assert export_path.stat().st_size == test_dict["unadjusted_size"]
def test_export_photo_current():
""" test PhotoAsset.export """
test_dict = UUID_DICT["hasadjustments"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
assert export_path.stat().st_size == test_dict["adjusted_size"]
### VideoAsset
def test_export_video_original():
""" test VideoAsset.export """
test_dict = UUID_DICT["video"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_ORIGINAL)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
def test_export_video_unadjusted():
""" test VideoAsset.export """
test_dict = UUID_DICT["video"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_UNADJUSTED)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
def test_export_video_current():
""" test VideoAsset.export """
test_dict = UUID_DICT["video"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_CURRENT)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
### Slow-Mo VideoAsset
def test_export_slow_mo_original():
""" test VideoAsset.export for slow mo video"""
test_dict = UUID_DICT["slow_mo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_ORIGINAL)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
def test_export_slow_mo_unadjusted():
""" test VideoAsset.export for slow mo video"""
test_dict = UUID_DICT["slow_mo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_UNADJUSTED)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
def test_export_slow_mo_current():
""" test VideoAsset.export for slow mo video"""
test_dict = UUID_DICT["slow_mo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_CURRENT)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
### LivePhotoAsset
def test_export_live_original():
""" test LivePhotoAsset.export """
test_dict = UUID_DICT["live_photo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_ORIGINAL)
for f in export_path:
filepath = pathlib.Path(f)
assert filepath.is_file()
filename = test_dict["filename"]
assert filepath.stem == pathlib.Path(filename).stem
def test_export_live_unadjusted():
""" test LivePhotoAsset.export """
test_dict = UUID_DICT["live_photo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_UNADJUSTED)
for file in export_path:
filepath = pathlib.Path(file)
assert filepath.is_file()
filename = test_dict["filename"]
assert filepath.stem == pathlib.Path(filename).stem
def test_export_live_current():
""" test LivePhotAsset.export """
test_dict = UUID_DICT["live_photo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_CURRENT)
for file in export_path:
filepath = pathlib.Path(file)
assert filepath.is_file()
filename = test_dict["filename"]
assert filepath.stem == pathlib.Path(filename).stem
def test_export_live_current_just_photo():
""" test LivePhotAsset.export """
test_dict = UUID_DICT["live_photo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, photo=True, video=False)
assert len(export_path) == 1
assert export_path[0].lower().endswith(".heic")
def test_export_live_current_just_video():
""" test LivePhotAsset.export """
test_dict = UUID_DICT["live_photo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, photo=False, video=True)
assert len(export_path) == 1
assert export_path[0].lower().endswith(".mov")
def test_fetch_burst_uuid():
""" test fetch_burst_uuid """
test_dict = UUID_DICT["burst"]
uuid = test_dict["uuid"]
filename = test_dict["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
bursts_selected = lib.fetch_burst_uuid(photo.burstid)
assert len(bursts_selected) == test_dict["burst_selected"]
assert isinstance(bursts_selected[0], PhotoAsset)
bursts_all = lib.fetch_burst_uuid(photo.burstid, all=True)
assert len(bursts_all) == test_dict["burst_all"]
assert isinstance(bursts_all[0], PhotoAsset)

View File

@@ -1,6 +1,13 @@
""" Test template.py """
import pytest
from osxphotos.exiftool import get_exiftool_path
try:
exiftool = get_exiftool_path()
except:
exiftool = None
PHOTOS_DB_PLACES = (
"./tests/Test-Places-Catalina-10_15_7.photoslibrary/database/photos.db"
)
@@ -52,10 +59,39 @@ 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 = {
"A92D9C26-3A50-4197-9388-CB5F7DB9FA91": {
"{exiftool:EXIF:Make}": ["Canon"],
"{exiftool:EXIF:Model}": ["Canon PowerShot G10"],
"{exiftool:EXIF:Make}/{exiftool:EXIF:Model}": ["Canon/Canon PowerShot G10"],
"{exiftool:IPTC:Keywords,foo}": ["foo"],
},
"DC99FBDD-7A52-4100-A5BB-344131646C30": {
"{exiftool:IPTC:Keywords}": [
"England",
"London",
"London 2018",
"St. James's Park",
"UK",
"United Kingdom",
],
"{,+exiftool:IPTC:Keywords}": [
"England,London,London 2018,St. James's Park,UK,United Kingdom"
],
},
}
TEMPLATE_VALUES = {
"{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
@@ -737,3 +773,15 @@ def test_expand_in_place_with_delim_single_value():
for template in TEMPLATE_VALUES_TITLE:
rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(TEMPLATE_VALUES_TITLE[template])
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_exiftool_template():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
for uuid in UUID_EXIFTOOL:
photo = photosdb.get_photo(uuid)
for template in UUID_EXIFTOOL[uuid]:
rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(UUID_EXIFTOOL[uuid][template])