Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29721dd4f0 | ||
|
|
6559c4d8f6 | ||
|
|
baf45ccd2a | ||
|
|
aca85ee2aa | ||
|
|
9584a9ccc5 | ||
|
|
182b816e34 | ||
|
|
0262e0d97e | ||
|
|
73f936e061 | ||
|
|
09687cfca4 | ||
|
|
e17ee0e388 | ||
|
|
ec4b53ed9d | ||
|
|
d7c81adae8 | ||
|
|
37b1e5ca47 | ||
|
|
22355fd446 | ||
|
|
d8de86cb6f | ||
|
|
11f563a479 | ||
|
|
f75ed17f9c | ||
|
|
e5d6f21d8e | ||
|
|
d371e63022 | ||
|
|
1b6a03a9f8 | ||
|
|
0708a42155 | ||
|
|
69cd236712 | ||
|
|
4cce9d4939 | ||
|
|
cfb07cbfaf | ||
|
|
1eff6bae9e | ||
|
|
435da2a5dd | ||
|
|
ed3a9711dc | ||
|
|
1bc0926948 | ||
|
|
25eacc7cad | ||
|
|
d9dcf0917a | ||
|
|
4f36c7c948 | ||
|
|
d22eaf39ed | ||
|
|
adf2ba7678 | ||
|
|
af827d7a57 | ||
|
|
48acb42631 | ||
|
|
eba661acf7 | ||
|
|
399d432a66 | ||
|
|
4cebc57d60 | ||
|
|
489fea56e9 | ||
|
|
0632a97f55 | ||
|
|
d5a9f76719 | ||
|
|
382fca3f92 | ||
|
|
a807894095 | ||
|
|
559350f71d | ||
|
|
b5195f9d2b | ||
|
|
fa332186ab | ||
|
|
aa2ebf55bb | ||
|
|
d1fbb9fe86 | ||
|
|
116cb662fb | ||
|
|
db68defc44 | ||
|
|
7460bc88fc | ||
|
|
dbbbbf10a8 | ||
|
|
0633814ab2 | ||
|
|
df7d45659a | ||
|
|
cec266bba4 | ||
|
|
d0d2e80800 | ||
|
|
aafdbea564 | ||
|
|
c42050a10c | ||
|
|
c27cfb1223 | ||
|
|
ad144da8a0 | ||
|
|
5352aec3b9 | ||
|
|
e951e5361e | ||
|
|
f7bd1376e1 | ||
|
|
26f96d582c | ||
|
|
8cb15d1555 | ||
|
|
2d9429c8ee | ||
|
|
3b6dd08d2b | ||
|
|
3c85f26f90 | ||
|
|
52c054f81f | ||
|
|
8dc59cbc35 | ||
|
|
802e2f069a | ||
|
|
5d4d7d7db7 | ||
|
|
ea9b41bae4 | ||
|
|
38397b507b | ||
|
|
3636fcbc76 | ||
|
|
a6231e29ff | ||
|
|
8c36c6712a | ||
|
|
7fa3704840 | ||
|
|
e829212987 | ||
|
|
df37a017a8 | ||
|
|
101525c95f | ||
|
|
ae2fd2e3db | ||
|
|
9588853ea2 | ||
|
|
9d38885416 | ||
|
|
653b7e6600 | ||
|
|
9429ea8ace | ||
|
|
2202f1b1e9 | ||
|
|
a509ef18d3 | ||
|
|
0492f94060 | ||
|
|
cf7fab4c7a | ||
|
|
c7c5320587 | ||
|
|
cd710771cd | ||
|
|
663e33bc17 | ||
|
|
3660b6360a | ||
|
|
11459d1da4 | ||
|
|
fd14242022 | ||
|
|
6ac311199e | ||
|
|
13df6a2395 | ||
|
|
28dce72a67 | ||
|
|
e5548ed160 | ||
|
|
5714509765 | ||
|
|
46b62af4e2 | ||
|
|
01ea88fe57 | ||
|
|
e6d043ab65 | ||
|
|
5b1174db5d | ||
|
|
9cff8e89c6 | ||
|
|
1553563629 | ||
|
|
db262f58b0 |
115
.all-contributorsrc
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"projectName": "osxphotos",
|
||||
"projectOwner": "RhetTbull",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": true,
|
||||
"commitConvention": "none",
|
||||
"contributors": [
|
||||
{
|
||||
"login": "britiscurious",
|
||||
"name": "britiscurious",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/25646439?v=4",
|
||||
"profile": "https://github.com/britiscurious",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mwort",
|
||||
"name": "Michel Wortmann",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/8170417?v=4",
|
||||
"profile": "https://github.com/mwort",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "PabloKohan",
|
||||
"name": "Pablo 'merKur' Kohan",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/8790976?v=4",
|
||||
"profile": "https://github.com/PabloKohan",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hshore29",
|
||||
"name": "hshore29",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/7023497?v=4",
|
||||
"profile": "https://github.com/hshore29",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dmd",
|
||||
"name": "Daniel M. Drucker",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/41439?v=4",
|
||||
"profile": "http://3e.org/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jystervinou",
|
||||
"name": "Jean-Yves Stervinou",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/132356?v=4",
|
||||
"profile": "https://github.com/jystervinou",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dethi",
|
||||
"name": "Thibault Deutsch",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/1011520?v=4",
|
||||
"profile": "https://dethi.me/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "grundsch",
|
||||
"name": "grundsch",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/3874928?v=4",
|
||||
"profile": "https://github.com/grundsch",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "agprimatic",
|
||||
"name": "Ag Primatic",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/4685054?v=4",
|
||||
"profile": "https://github.com/agprimatic",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hhoeck",
|
||||
"name": "Horst Höck",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/6313998?v=4",
|
||||
"profile": "https://github.com/hhoeck",
|
||||
"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
|
||||
}
|
||||
214
CHANGELOG.md
@@ -4,6 +4,220 @@ 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.0](https://github.com/RhetTbull/osxphotos/compare/v0.37.7...v0.38.0)
|
||||
|
||||
> 11 December 2020
|
||||
|
||||
- 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
|
||||
|
||||
- Implemented boolean type template fields [`7fa3704`](https://github.com/RhetTbull/osxphotos/commit/7fa3704840f7800689b4ac5f8edee8210eb3e8db)
|
||||
- Bug fix in handling missing edited photos [`e829212`](https://github.com/RhetTbull/osxphotos/commit/e829212987bbc1a88f845922abcffef70c159883)
|
||||
- Fixed message in CLI [`df37a01`](https://github.com/RhetTbull/osxphotos/commit/df37a017a8efdc8d0b9bc8d00a4452dc4cb892b3)
|
||||
|
||||
#### [v0.36.10](https://github.com/RhetTbull/osxphotos/compare/v0.36.9...v0.36.10)
|
||||
|
||||
> 8 November 2020
|
||||
|
||||
- Implemented issue #255 [`ae2fd2e`](https://github.com/RhetTbull/osxphotos/commit/ae2fd2e3db984756e6cc3f7b3338b8ba819ce28c)
|
||||
|
||||
#### [v0.36.9](https://github.com/RhetTbull/osxphotos/compare/v0.36.8...v0.36.9)
|
||||
|
||||
> 7 November 2020
|
||||
|
||||
- Refactored regex in phototemplate [`653b7e6`](https://github.com/RhetTbull/osxphotos/commit/653b7e6600e0738ecd00f74d510a893e0d447ca4)
|
||||
- Fix for exporting slow mo videos, issue #252 [`9d38885`](https://github.com/RhetTbull/osxphotos/commit/9d38885416b528bd8c91bb09120be85a8b109f29)
|
||||
|
||||
#### [v0.36.8](https://github.com/RhetTbull/osxphotos/compare/v0.36.7...v0.36.8)
|
||||
|
||||
> 5 November 2020
|
||||
|
||||
- Refactored exiftool.py [`2202f1b`](https://github.com/RhetTbull/osxphotos/commit/2202f1b1e9c4f83558ef48e58cb94af6b3a38cdd)
|
||||
- README.md update [`a509ef1`](https://github.com/RhetTbull/osxphotos/commit/a509ef18d3db2ac15a661e763a7254974cf8d84a)
|
||||
|
||||
#### [v0.36.7](https://github.com/RhetTbull/osxphotos/compare/v0.36.6...v0.36.7)
|
||||
|
||||
> 4 November 2020
|
||||
|
||||
- Implemented context manager for ExifTool, closes #250 [`#250`](https://github.com/RhetTbull/osxphotos/issues/250)
|
||||
|
||||
#### [v0.36.6](https://github.com/RhetTbull/osxphotos/compare/v0.36.5...v0.36.6)
|
||||
|
||||
> 2 November 2020
|
||||
|
||||
- Fix for issue #39 [`c7c5320`](https://github.com/RhetTbull/osxphotos/commit/c7c5320587e31070b55cc8c7e74f30b0f9e61379)
|
||||
|
||||
#### [v0.36.5](https://github.com/RhetTbull/osxphotos/compare/v0.36.4...v0.36.5)
|
||||
|
||||
> 1 November 2020
|
||||
|
||||
- Added --ignore-date-modified flag, issue #247 [`663e33b`](https://github.com/RhetTbull/osxphotos/commit/663e33bc1709f767e1a08242f6bfe86a3fc78552)
|
||||
|
||||
#### [v0.36.4](https://github.com/RhetTbull/osxphotos/compare/v0.36.2...v0.36.4)
|
||||
|
||||
> 1 November 2020
|
||||
|
||||
- Updated --exiftool to set dates/times as Photos does, issue #247 [`11459d1`](https://github.com/RhetTbull/osxphotos/commit/11459d1da4d7d13e36e9db4bdc940b74baad9d11)
|
||||
- Partial fix for issue #247 on Mojave [`6ac3111`](https://github.com/RhetTbull/osxphotos/commit/6ac311199e9f7afe6170cbbd68ceaa1bb9f0682b)
|
||||
- Add @mwort as a contributor [`9cff8e8`](https://github.com/RhetTbull/osxphotos/commit/9cff8e89c6e939d3d371a4f60649f6e5595a55b9)
|
||||
|
||||
#### [v0.36.2](https://github.com/RhetTbull/osxphotos/compare/v0.36.1...v0.36.2)
|
||||
|
||||
> 31 October 2020
|
||||
|
||||
- Fixed handling of date_modified for Catalina, issue #247 [`0cce234`](https://github.com/RhetTbull/osxphotos/commit/0cce234a8cbba63dc1cba439c06fe9de078ff480)
|
||||
|
||||
#### [v0.36.1](https://github.com/RhetTbull/osxphotos/compare/v0.36.0...v0.36.1)
|
||||
|
||||
> 30 October 2020
|
||||
|
||||
- Added --has-comment/--has-likes to CLI, issue #240 [`c5dba8c`](https://github.com/RhetTbull/osxphotos/commit/c5dba8c89bba35d7a77e087b180b2a3d7b94280a)
|
||||
- Cleaned up as_dict/asdict, issue #144, #188 [`603dabb`](https://github.com/RhetTbull/osxphotos/commit/603dabb8f420a89e993d5aadcd3a5614bbb262dd)
|
||||
- Updated README.md [`d16932d`](https://github.com/RhetTbull/osxphotos/commit/d16932d0fd8d160ccf44e9842329d5933dc25b36)
|
||||
|
||||
#### [v0.36.0](https://github.com/RhetTbull/osxphotos/compare/v0.35.7...v0.36.0)
|
||||
|
||||
> 26 October 2020
|
||||
|
||||
457
README.md
@@ -1,8 +1,10 @@
|
||||
# OSXPhotos
|
||||
|
||||
[](https://github.com/python/black)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||

|
||||
[](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
- [OSXPhotos](#osxphotos)
|
||||
* [What is osxphotos?](#what-is-osxphotos)
|
||||
@@ -43,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.
|
||||
|
||||
@@ -117,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:
|
||||
@@ -225,6 +227,9 @@ 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
|
||||
@@ -232,7 +237,8 @@ Options:
|
||||
--update Only export new or updated files. See notes
|
||||
below on export and --update.
|
||||
--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.
|
||||
@@ -259,6 +265,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 (UTC);
|
||||
QuickTime:ModifyDate (UTC) (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
|
||||
@@ -285,48 +364,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.
|
||||
--directory DIRECTORY Optional template for specifying name of
|
||||
output directory in the form
|
||||
'{name,DEFAULT}'. See below for additional
|
||||
@@ -344,11 +381,45 @@ Options:
|
||||
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.
|
||||
--original-suffix SUFFIX Optional suffix for naming original photos.
|
||||
Default name for original photos is in form
|
||||
'filename.ext'. For example, with '--
|
||||
original-suffix _original', the original
|
||||
photo would be named
|
||||
'filename_original.ext'. The default suffix
|
||||
is '' (no 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 REPORTNAME.CSV 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 **
|
||||
@@ -381,16 +452,72 @@ option to re-export the entire library thus rebuilding the
|
||||
|
||||
** Templating System **
|
||||
|
||||
Several options, such as --directory, allow you to specify a template which
|
||||
will be rendered to substitute template fields with values from the photo.
|
||||
For example, '{created.month}' would be replaced with the month name of the
|
||||
photo creation date. e.g. 'November'.
|
||||
|
||||
The general format for a template is '{TEMPLATE_FIELD[,[DEFAULT]]}'. Some
|
||||
templates have optional modifiers in form
|
||||
'{[[DELIM]+]TEMPLATE_FIELD[(PATH_SEP)][?VALUE_IF_TRUE][,[DEFAULT]]}'
|
||||
|
||||
The ',' and DEFAULT value are optional. If TEMPLATE_FIELD results in a null
|
||||
(empty) value, the default is '_'. You may specify an alternate default
|
||||
value by appending ',DEFAULT' after template_field. e.g. '{title,no_title}'
|
||||
would result in 'no_title' if the photo had no title. You may include other
|
||||
text in the template string outside the {} and use more than one template
|
||||
field, e.g. '{created.year} - {created.month}' (e.g. '2020 - November').
|
||||
|
||||
Some template fields such as 'hdr' are boolean and resolve to True or False.
|
||||
These take the form: '{TEMPLATE_FIELD?VALUE_IF_TRUE,VALUE_IF_FALSE}', e.g.
|
||||
{hdr?is_hdr,not_hdr} which would result in 'is_hdr' if photo is an HDR image
|
||||
and 'not_hdr' otherwise.
|
||||
|
||||
Some template fields such as 'folder_template' are "path-like" in that they
|
||||
join multiple elements into a single path-like string. For example, if photo
|
||||
is in album Album1 in folder Folder1, '{folder_album}` results in
|
||||
'Folder1/Album1'. This is so these template fields may be used as paths in
|
||||
--directory. If you intend to use such a field as a string, e.g. in the
|
||||
filename, you may specify a different path separator using the form:
|
||||
'{TEMPLATE_FIELD(PATH_SEP)}'. For example, using the example above,
|
||||
'{folder_album(-)}' would result in 'Folder1-Album1' and '{folder_album()}'
|
||||
would result in 'Folder1Album1'.
|
||||
|
||||
Some templates may resolve to more than one value. For example, a photo can
|
||||
have multiple keywords so '{keyword}' can result in multiple values. If used
|
||||
in a filename or directory, these templates may result in more than one copy
|
||||
of the photo being exported. For example, if photo has keywords "foo" and
|
||||
"bar", --directory '{keyword}' will result in copies of the photo being
|
||||
exported to 'foo/image_name.jpeg' and 'bar/image_name.jpeg'.
|
||||
|
||||
Multi-value template fields such as '{keyword}' may be expanded 'in place'
|
||||
with an optional delimiter using the template form '{DELIM+TEMPLATE_FIELD}'.
|
||||
For example, a photo with keywords 'foo' and 'bar':
|
||||
|
||||
'{keyword}' renders to 'foo' and 'bar'
|
||||
|
||||
'{,+keyword}' renders to: 'foo,bar'
|
||||
|
||||
'{; +keyword}' renders to: 'foo; bar'
|
||||
|
||||
'{+keyword}' renders to 'foobar'
|
||||
|
||||
Some template fields such as '{media_type}' use the 'DEFAULT' value to allow
|
||||
customization of the output. For example, '{media_type}' resolves to the
|
||||
special media type of the photo such as 'panorama' or 'selfie'. You may use
|
||||
the 'DEFAULT' value to override these in form:
|
||||
'{media_type,video=vidéo;time_lapse=vidéo_accélérée}'. In this example, if
|
||||
photo is a time_lapse photo, 'media_type' would resolve to 'vidéo_accélérée'
|
||||
instead of 'time_lapse' and video would resolve to 'vidéo' if photo is an
|
||||
ordinary video.
|
||||
|
||||
With the --directory and --filename options you may specify a template for the
|
||||
export directory or filename, respectively. The directory will be appended to
|
||||
the export path specified in the export DEST argument to export. For example,
|
||||
if template is '{created.year}/{created.month}', and export desitnation DEST
|
||||
if template is '{created.year}/{created.month}', and export destination DEST
|
||||
is '/Users/maria/Pictures/export', the actual export directory for a photo
|
||||
would be '/Users/maria/Pictures/export/2020/March' if the photo was created in
|
||||
March 2020. Some template substitutions may result in more than one value, for
|
||||
example '{album}' if photo is in more than one album or '{keyword}' if photo
|
||||
has more than one keyword. In this case, more than one copy of the photo will
|
||||
be exported, each in a separate directory or with a different filename.
|
||||
March 2020.
|
||||
|
||||
The templating system may also be used with the --keyword-template option to
|
||||
set keywords on export (with --exiftool or --sidecar), for example, to set a
|
||||
@@ -419,12 +546,28 @@ value, '_' (underscore) will be used as the default value. For example, in the
|
||||
above example, this would result in '2020/_/photoname.jpg' if address was
|
||||
null.
|
||||
|
||||
You may specify a null default (e.g. "" or empty string) by omitting the value
|
||||
after the comma, e.g. {title,} which would render to "" if title had no value.
|
||||
|
||||
Substitution Description
|
||||
{name} Current filename of the photo
|
||||
{original_name} Photo's original filename when imported to
|
||||
Photos
|
||||
{title} Title of the photo
|
||||
{descr} Description of the photo
|
||||
{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}'
|
||||
{created.date} Photo's creation date in ISO format, e.g.
|
||||
'2020-03-22'
|
||||
{created.year} 4-digit year of photo creation time
|
||||
@@ -540,18 +683,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
|
||||
@@ -1262,6 +1413,9 @@ Returns True if photo is a panorama, otherwise False.
|
||||
|
||||
**Note**: The result of `PhotoInfo.panorama` will differ from the "Panoramas" Media Types smart album in that it will also identify panorama photos from older phones that Photos does not recognize as panoramas.
|
||||
|
||||
#### `slow_mo`
|
||||
Returns True if photo is a slow motion video, otherwise False
|
||||
|
||||
#### `labels`
|
||||
Returns image categorization labels associated with the photo as list of str.
|
||||
|
||||
@@ -1343,7 +1497,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).
|
||||
@@ -1358,7 +1512,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
|
||||
|
||||
@@ -1380,15 +1533,16 @@ 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>
|
||||
|
||||
`render_template(template_str, none_str = "_", path_sep = None, expand_inplace = False, inplace_sep = None, filename=False, dirname=False, replacement=":",)`
|
||||
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
|
||||
- `template_str`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
|
||||
`render_template(template_str, none_str = "_", path_sep = None, expand_inplace = False, inplace_sep = None, filename=False, dirname=False, replacement=":",)`
|
||||
|
||||
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
|
||||
|
||||
- `template_str`: str in format "{[[DELIM]+]name[(PATH_SEP)][?TRUE_VALUE][,[DEFAULT]]}" where name is one of the values in the [Template Substitutions](#template-substitutions) table. See notes below regarding specific details of the syntax.
|
||||
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
|
||||
- `path_sep`: optional character to use as path separator, default is os.path.sep
|
||||
- `path_sep`: optional character to use as path separator, default is `os.path.sep`
|
||||
- `expand_inplace`: expand multi-valued substitutions in-place as a single string instead of returning individual strings
|
||||
- `inplace_sep`: optional string to use as separator between multi-valued keywords with expand_inplace; default is ','
|
||||
- `filename`: if True, template output will be sanitized to produce valid file name
|
||||
@@ -1405,6 +1559,56 @@ e.g. `render_template("{created.year}/{{foo}}", photo)` would return `(["2020/{f
|
||||
|
||||
Some substitutions, notably `album`, `keyword`, and `person` could return multiple values, hence a new string will be return for each possible substitution (hence why a list of rendered strings is returned). For example, a photo in 2 albums: 'Vacation' and 'Family' would result in the following rendered values if template was "{created.year}/{album}" and created.year == 2020: `["2020/Vacation","2020/Family"]`
|
||||
|
||||
The template field format contains optional modifiers:
|
||||
|
||||
`"{[[DELIM]+]name[(PATH_SEP)][?TRUE_VALUE][,[DEFAULT]]}"`
|
||||
|
||||
`DELIM`: optional delimiter string to use when expanding multi-valued template values in-place
|
||||
|
||||
`+`: If present before template `name`, expands the template in place. If `DELIM` not provided, values are joined with no delimiter.
|
||||
|
||||
e.g. if Photo keywords are `["foo","bar"]`:
|
||||
|
||||
- `"{keyword}"` renders to `["foo", "bar"]`
|
||||
- `"{,+keyword}"` renders to: `["foo,bar"]`
|
||||
- `"{; +keyword}"` renders to: `["foo; bar"]`
|
||||
- `"{+keyword}"` renders to `["foobar"]`
|
||||
|
||||
`PATH_SEP`: optional path separator to use when joining path like fields, for example `{folder_album}`. May also be provided as `path_sep` argument in `render_template()`. If provided both in the call to `render_template()` and in the template itself, the value in the template string takes precedence. If not provided in either the template string or in `path_sep` argument, defaults to `os.path.sep`.
|
||||
|
||||
e.g. If Photo is in `Album1` in `Folder1`:
|
||||
|
||||
- `"{folder_album}"` renders to `["Folder1/Album1"]`
|
||||
- `"{folder_album(:)}"` renders to `["Folder1:Album1"]`
|
||||
- `"{folder_album()}"` renders to `["Folder1Album1"]`
|
||||
|
||||
`?TRUE_VALUE`: optional value to use if name is boolean-type field which evaluates to true. For example `"{hdr}"` evaluates to True if photo is an high dynamic range (HDR) image and False otherwise. In these types of fields, use `?TRUE_VALUE` to provide the value if True and `,DEFAULT` to provide the value of False.
|
||||
|
||||
e.g. if photo is an HDR image,
|
||||
|
||||
- `"{hdr?ISHDR,NOTHDR}"` renders to `["ISHDR"]`
|
||||
|
||||
and if it is not an HDR image,
|
||||
|
||||
- `"{hdr?ISHDR,NOTHDR}"` renders to `["NOTHDR"]`
|
||||
|
||||
Either or both `TRUE_VALUE` or `DEFAULT` (False value) may be empty which would result in empty string `[""]` when rendered.
|
||||
|
||||
`,DEFAULT`: optional default value to use if the template name has no value. This modifier is also used for the value if False for boolean-type fields (see above) as well as to hold a sub-template for values like `{created.strftime}`. If no default value provided, "_" is used. May also be provided in the `none_str` argument to `render_template()`. If provided both in the template string and in `none_str`, the value in the template string takes precedence.
|
||||
|
||||
e.g., if photo has no title set,
|
||||
|
||||
- `"{title}"` renders to ["_"]
|
||||
- `"{title,I have no title}"` renders to `["I have no title"]`
|
||||
|
||||
Template fields such as `created.strftime` use the DEFAULT value to pass the template to use for `strftime`.
|
||||
|
||||
e.g., if photo date is 4 February 2020, 19:07:38,
|
||||
|
||||
- `"{created.strftime,%Y-%m-%d-%H%M%S}"` renders to `["2020-02-04-190738"]`
|
||||
|
||||
Some template fields such as `"{media_type}"` use the `DEFAULT` value to allow customization of the output. For example, `"{media_type}"` resolves to the special media type of the photo such as `panorama` or `selfie`. You may use the `DEFAULT` value to override these in form: `"{media_type,video=vidéo;time_lapse=vidéo_accélérée}"`. In this example, if photo was a time_lapse photo, `media_type` would resolve to `vidéo_accélérée` instead of `time_lapse`.
|
||||
|
||||
See [Template Substitutions](#template-substitutions) for additional details.
|
||||
|
||||
### ExifInfo
|
||||
@@ -1825,37 +2029,42 @@ To get the path of every raw photo, whether it's a single raw photo or a raw+JPE
|
||||
|
||||
### Template Substitutions
|
||||
|
||||
The following substitutions are availabe for use with `PhotoInfo.render_template()`
|
||||
The following template field substitutions are availabe for use with `PhotoInfo.render_template()`
|
||||
|
||||
| Substitution | Description |
|
||||
|--------------|-------------|
|
||||
|{name}|Current filename of the photo|
|
||||
|{original_name}|Photo's original filename when imported to Photos|
|
||||
|{title}|Title of the photo|
|
||||
|{descr}|Description of the photo|
|
||||
|{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}'|
|
||||
|{created.date}|Photo's creation date in ISO format, e.g. '2020-03-22'|
|
||||
|{created.year}|4-digit year of file creation time|
|
||||
|{created.yy}|2-digit year of file creation time|
|
||||
|{created.mm}|2-digit month of the file creation time (zero padded)|
|
||||
|{created.month}|Month name in user's locale of the file creation time|
|
||||
|{created.mon}|Month abbreviation in the user's locale of the file creation time|
|
||||
|{created.dd}|2-digit day of the month (zero padded) of file creation time|
|
||||
|{created.dow}|Day of week in user's locale of the file creation time|
|
||||
|{created.doy}|3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)|
|
||||
|{created.hour}|2-digit hour of the file creation time|
|
||||
|{created.min}|2-digit minute of the file creation time|
|
||||
|{created.sec}|2-digit second of the file creation time|
|
||||
|{created.year}|4-digit year of photo creation time|
|
||||
|{created.yy}|2-digit year of photo creation time|
|
||||
|{created.mm}|2-digit month of the photo creation time (zero padded)|
|
||||
|{created.month}|Month name in user's locale of the photo creation time|
|
||||
|{created.mon}|Month abbreviation in the user's locale of the photo creation time|
|
||||
|{created.dd}|2-digit day of the month (zero padded) of photo creation time|
|
||||
|{created.dow}|Day of week in user's locale of the photo creation time|
|
||||
|{created.doy}|3-digit day of year (e.g Julian day) of photo creation time, starting from 1 (zero padded)|
|
||||
|{created.hour}|2-digit hour of the photo creation time|
|
||||
|{created.min}|2-digit minute of the photo creation time|
|
||||
|{created.sec}|2-digit second of the photo creation time|
|
||||
|{created.strftime}|Apply strftime template to file creation date/time. Should be used in form {created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates.|
|
||||
|{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'|
|
||||
|{modified.year}|4-digit year of file modification time|
|
||||
|{modified.yy}|2-digit year of file modification time|
|
||||
|{modified.mm}|2-digit month of the file modification time (zero padded)|
|
||||
|{modified.month}|Month name in user's locale of the file modification time|
|
||||
|{modified.mon}|Month abbreviation in the user's locale of the file modification time|
|
||||
|{modified.dd}|2-digit day of the month (zero padded) of the file modification time|
|
||||
|{modified.doy}|3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)|
|
||||
|{modified.hour}|2-digit hour of the file modification time|
|
||||
|{modified.min}|2-digit minute of the file modification time|
|
||||
|{modified.sec}|2-digit second of the file modification time|
|
||||
|{modified.year}|4-digit year of photo modification time|
|
||||
|{modified.yy}|2-digit year of photo modification time|
|
||||
|{modified.mm}|2-digit month of the photo modification time (zero padded)|
|
||||
|{modified.month}|Month name in user's locale of the photo modification time|
|
||||
|{modified.mon}|Month abbreviation in the user's locale of the photo modification time|
|
||||
|{modified.dd}|2-digit day of the month (zero padded) of the photo modification time|
|
||||
|{modified.dow}|Day of week in user's locale of the photo modification time|
|
||||
|{modified.doy}|3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded)|
|
||||
|{modified.hour}|2-digit hour of the photo modification time|
|
||||
|{modified.min}|2-digit minute of the photo modification time|
|
||||
|{modified.sec}|2-digit second of the photo modification time|
|
||||
|{today.date}|Current date in iso format, e.g. '2020-03-22'|
|
||||
|{today.year}|4-digit year of current date|
|
||||
|{today.yy}|2-digit year of current date|
|
||||
@@ -1889,6 +2098,7 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|
||||
|{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
|
||||
|
||||
@@ -1980,7 +2190,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.
|
||||
|
||||
@@ -1988,21 +2198,38 @@ If you have an interesting example that shows usage of this package, submit an i
|
||||
|
||||
Testing against "real world" Photos libraries would be especially helpful. If you discover issues in testing against your Photos libraries, please open an issue. I've done extensive testing against my own Photos library but that's a since data point and I'm certain there are issues lurking in various edge cases I haven't discovered yet.
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank-you to the following people who have contributed to improving osxphotos! If I've inadvertently left you off, please open an issue or send me a note.
|
||||
### Contributors ✨
|
||||
|
||||
- [britiscurious](https://github.com/britiscurious)
|
||||
- [Michel Wortmann](https://github.com/mwort)
|
||||
- [hshore29](https://github.com/hshore29)
|
||||
- [Pablo 'merKur' Kohan](https://github.com/PabloKohan)
|
||||
- [Jean-Yves Stervinou](https://github.com/jystervinou)
|
||||
- [Thibault Deutsch](https://github.com/dethi)
|
||||
- [grundsch](https://github.com/grundsch)
|
||||
- [Ag Primatic](https://github.com/agprimatic)
|
||||
- [Daniel M. Drucker](https://github.com/dmd)
|
||||
- [Horst Höck](https://github.com/hhoeck)
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/britiscurious"><img src="https://avatars1.githubusercontent.com/u/25646439?v=4?s=100" width="100px;" alt=""/><br /><sub><b>britiscurious</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=britiscurious" title="Documentation">📖</a> <a href="https://github.com/RhetTbull/osxphotos/commits?author=britiscurious" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/mwort"><img src="https://avatars3.githubusercontent.com/u/8170417?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michel Wortmann</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=mwort" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/PabloKohan"><img src="https://avatars3.githubusercontent.com/u/8790976?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Pablo 'merKur' Kohan</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=PabloKohan" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/hshore29"><img src="https://avatars2.githubusercontent.com/u/7023497?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hshore29</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hshore29" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://3e.org/"><img src="https://avatars0.githubusercontent.com/u/41439?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel M. Drucker</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=dmd" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/jystervinou"><img src="https://avatars3.githubusercontent.com/u/132356?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jean-Yves Stervinou</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jystervinou" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://dethi.me/"><img src="https://avatars2.githubusercontent.com/u/1011520?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Thibault Deutsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=dethi" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
## Known Bugs
|
||||
|
||||
@@ -2018,8 +2245,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
|
||||
@@ -2030,10 +2255,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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 )
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.36.2"
|
||||
__version__ = "0.38.2"
|
||||
|
||||
|
||||
|
||||
173
osxphotos/configoptions.py
Normal 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())}
|
||||
@@ -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)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
I rolled my own for following reasons:
|
||||
1. I wanted something under MIT license (best alternative was licensed under GPL/BSD)
|
||||
2. I wanted singleton behavior so only a single exiftool process was ever running
|
||||
3. When used as a context manager, I wanted the operations to batch until exiting the context (improved performance)
|
||||
If these aren't important to you, I highly recommend you use Sven Marnach's excellent
|
||||
pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """
|
||||
|
||||
@@ -10,10 +11,8 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache # pylint: disable=syntax-error
|
||||
|
||||
from .utils import _debug
|
||||
|
||||
# exiftool -stay_open commands outputs this EOF marker after command is run
|
||||
EXIFTOOL_STAYOPEN_EOF = "{ready}"
|
||||
@@ -23,9 +22,7 @@ EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
|
||||
@lru_cache(maxsize=1)
|
||||
def get_exiftool_path():
|
||||
""" return path of exiftool, cache result """
|
||||
exiftool_path = shutil.which('exiftool')
|
||||
if _debug():
|
||||
logging.debug("exiftool path = %s" % (exiftool_path))
|
||||
exiftool_path = shutil.which("exiftool")
|
||||
if exiftool_path:
|
||||
return exiftool_path.rstrip()
|
||||
else:
|
||||
@@ -59,7 +56,7 @@ class _ExifToolProc:
|
||||
)
|
||||
return
|
||||
|
||||
self._exiftool = exiftool if exiftool else get_exiftool_path()
|
||||
self._exiftool = exiftool or get_exiftool_path()
|
||||
self._process_running = False
|
||||
self._start_proc()
|
||||
|
||||
@@ -98,12 +95,12 @@ class _ExifToolProc:
|
||||
"-", # read from stdin
|
||||
"-common_args", # specifies args common to all commands subsequently run
|
||||
"-n", # no print conversion (e.g. print tag values in machine readable format)
|
||||
"-P", # Preserve file modification date/time (possible interfere w/ --touch-file)
|
||||
"-P", # Preserve file modification date/time
|
||||
"-G", # print group name for each tag
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
self._process_running = True
|
||||
|
||||
@@ -136,38 +133,72 @@ class ExifTool:
|
||||
""" Basic exiftool interface for reading and writing EXIF tags """
|
||||
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True):
|
||||
""" Return ExifTool object
|
||||
file: path to image file
|
||||
exiftool: path to exiftool, if not specified will look in path
|
||||
overwrite: if True, will overwrite image file without creating backup, default=False """
|
||||
""" Create ExifTool object
|
||||
|
||||
Args:
|
||||
file: path to image file
|
||||
exiftool: path to exiftool, if not specified will look in path
|
||||
overwrite: if True, will overwrite image file without creating backup, default=False
|
||||
Returns:
|
||||
ExifTool instance
|
||||
"""
|
||||
self.file = filepath
|
||||
self.overwrite = overwrite
|
||||
self.data = {}
|
||||
self.error = None
|
||||
# if running as a context manager, self._context_mgr will be True
|
||||
self._context_mgr = False
|
||||
self._exiftoolproc = _ExifToolProc(exiftool=exiftool)
|
||||
self._process = self._exiftoolproc.process
|
||||
self._read_exif()
|
||||
|
||||
def setvalue(self, tag, value):
|
||||
""" Set tag to value(s)
|
||||
if value is None, will delete tag """
|
||||
""" Set tag to value(s); if value is None, will delete tag
|
||||
|
||||
Args:
|
||||
tag: str; name of tag to set
|
||||
value: str; value to set tag to
|
||||
|
||||
Returns:
|
||||
True if success otherwise False
|
||||
|
||||
If error generated by exiftool, returns False and sets self.error to error string
|
||||
If called in context manager, returns True (execution is delayed until exiting context manager)
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
value = ""
|
||||
command = [f"-{tag}={value}"]
|
||||
if self.overwrite:
|
||||
if self.overwrite and not self._context_mgr:
|
||||
command.append("-overwrite_original")
|
||||
self.run_commands(*command)
|
||||
if self._context_mgr:
|
||||
self._commands.extend(command)
|
||||
return True
|
||||
else:
|
||||
_, self.error = self.run_commands(*command)
|
||||
return self.error is None
|
||||
|
||||
def addvalues(self, tag, *values):
|
||||
""" Add one or more value(s) to tag
|
||||
If more than one value is passed, each value will be added to the tag
|
||||
Notes: exiftool may add duplicate values for some tags so the caller must ensure
|
||||
the values being added are not already in the EXIF data
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
|
||||
It's up to the caller to know what exiftool will do for each tag
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
but if addvalues called without first calling setvalue, exiftool will add duplicate values
|
||||
|
||||
Args:
|
||||
tag: str; tag to set
|
||||
*values: str; one or more values to set
|
||||
|
||||
Returns:
|
||||
True if success otherwise False
|
||||
|
||||
If error generated by exiftool, returns False and sets self.error to error string
|
||||
If called in context manager, returns True (execution is delayed until exiting context manager)
|
||||
|
||||
Notes: exiftool may add duplicate values for some tags so the caller must ensure
|
||||
the values being added are not already in the EXIF data
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
|
||||
It's up to the caller to know what exiftool will do for each tag
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
but if addvalues called without first calling setvalue, exiftool will add duplicate values
|
||||
"""
|
||||
if not values:
|
||||
raise ValueError("Must pass at least one value")
|
||||
@@ -178,23 +209,41 @@ class ExifTool:
|
||||
raise ValueError("Can't add None value to tag")
|
||||
command.append(f"-{tag}+={value}")
|
||||
|
||||
if self.overwrite:
|
||||
if self.overwrite and not self._context_mgr:
|
||||
command.append("-overwrite_original")
|
||||
|
||||
if command:
|
||||
self.run_commands(*command)
|
||||
if self._context_mgr:
|
||||
self._commands.extend(command)
|
||||
return True
|
||||
else:
|
||||
_, self.error = self.run_commands(*command)
|
||||
return self.error is None
|
||||
|
||||
def run_commands(self, *commands, no_file=False):
|
||||
""" run commands in the exiftool process and return result
|
||||
no_file: (bool) do not pass the filename to exiftool (default=False)
|
||||
by default, all commands will be run against self.file
|
||||
use no_file=True to run a command without passing the filename """
|
||||
""" Run commands in the exiftool process and return result.
|
||||
|
||||
Args:
|
||||
*commands: exiftool commands to run
|
||||
no_file: (bool) do not pass the filename to exiftool (default=False)
|
||||
by default, all commands will be run against self.file
|
||||
use no_file=True to run a command without passing the filename
|
||||
Returns:
|
||||
(output, errror)
|
||||
output: bytes is containing output of exiftool commands
|
||||
error: if exiftool generated an error, bytes containing error string otherwise None
|
||||
|
||||
Note: Also sets self.error if error generated.
|
||||
"""
|
||||
if not (hasattr(self, "_process") and self._process):
|
||||
raise ValueError("exiftool process is not running")
|
||||
|
||||
if not commands:
|
||||
raise TypeError("must provide one or more command to run")
|
||||
|
||||
if self._context_mgr and self.overwrite:
|
||||
commands = list(commands)
|
||||
commands.append("-overwrite_original")
|
||||
|
||||
filename = os.fsencode(self.file) if not no_file else b""
|
||||
command_str = (
|
||||
b"\n".join([c.encode("utf-8") for c in commands])
|
||||
@@ -204,18 +253,22 @@ class ExifTool:
|
||||
+ b"-execute\n"
|
||||
)
|
||||
|
||||
if _debug():
|
||||
logging.debug(command_str)
|
||||
|
||||
# send the command
|
||||
self._process.stdin.write(command_str)
|
||||
self._process.stdin.flush()
|
||||
|
||||
# read the output
|
||||
output = b""
|
||||
error = b""
|
||||
while EXIFTOOL_STAYOPEN_EOF not in str(output):
|
||||
output += self._process.stdout.readline().strip()
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN]
|
||||
line = self._process.stdout.readline()
|
||||
if line.startswith(b"Warning"):
|
||||
error += line
|
||||
else:
|
||||
output += line.strip()
|
||||
error = None if error == b"" else error
|
||||
self.error = error
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], error
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
@@ -225,14 +278,14 @@ class ExifTool:
|
||||
@property
|
||||
def version(self):
|
||||
""" returns exiftool version """
|
||||
ver = self.run_commands("-ver", no_file=True)
|
||||
ver, _ = self.run_commands("-ver", no_file=True)
|
||||
return ver.decode("utf-8")
|
||||
|
||||
def asdict(self):
|
||||
""" return dictionary of all EXIF tags and values from exiftool
|
||||
returns empty dict if no tags
|
||||
"""
|
||||
json_str = self.run_commands("-json")
|
||||
json_str, _ = self.run_commands("-json")
|
||||
if json_str:
|
||||
exifdict = json.loads(json_str)
|
||||
return exifdict[0]
|
||||
@@ -241,7 +294,8 @@ class ExifTool:
|
||||
|
||||
def json(self):
|
||||
""" returns JSON string containing all EXIF tags and values from exiftool """
|
||||
return self.run_commands("-json")
|
||||
json, _ = self.run_commands("-json")
|
||||
return json
|
||||
|
||||
def _read_exif(self):
|
||||
""" read exif data from file """
|
||||
@@ -250,3 +304,14 @@ class ExifTool:
|
||||
|
||||
def __str__(self):
|
||||
return f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
|
||||
|
||||
def __enter__(self):
|
||||
self._context_mgr = True
|
||||
self._commands = []
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
if exc_type:
|
||||
return False
|
||||
elif self._commands:
|
||||
_, self.error = self.run_commands(*self._commands)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -53,6 +53,7 @@ class PhotoInfo:
|
||||
export,
|
||||
export2,
|
||||
_export_photo,
|
||||
_exiftool_dict,
|
||||
_exiftool_json_sidecar,
|
||||
_write_exif_data,
|
||||
_write_sidecar,
|
||||
@@ -60,7 +61,7 @@ class PhotoInfo:
|
||||
ExportResults,
|
||||
)
|
||||
from ._photoinfo_scoreinfo import score, ScoreInfo
|
||||
from ._photoinfo_comments import comments, likes
|
||||
from ._photoinfo_comments import comments, likes
|
||||
|
||||
def __init__(self, db=None, uuid=None, info=None):
|
||||
self._uuid = uuid
|
||||
@@ -70,7 +71,11 @@ class PhotoInfo:
|
||||
@property
|
||||
def filename(self):
|
||||
""" filename of the picture """
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original:
|
||||
if (
|
||||
self._db._db_version <= _PHOTOS_4_VERSION
|
||||
and self.has_raw
|
||||
and self.raw_original
|
||||
):
|
||||
# return the JPEG version as that's what Photos 5+ does
|
||||
return self._info["raw_pair_info"]["filename"]
|
||||
else:
|
||||
@@ -80,11 +85,16 @@ class PhotoInfo:
|
||||
def original_filename(self):
|
||||
""" original filename of the picture
|
||||
Photos 5 mangles filenames upon import """
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original:
|
||||
if (
|
||||
self._db._db_version <= _PHOTOS_4_VERSION
|
||||
and self.has_raw
|
||||
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):
|
||||
@@ -95,12 +105,20 @@ class PhotoInfo:
|
||||
def date_modified(self):
|
||||
""" image modification date as timezone aware datetime object
|
||||
or None if no modification date set """
|
||||
imagedate = self._info["lastmodifieddate"]
|
||||
if imagedate:
|
||||
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
return imagedate.astimezone(tz=tz)
|
||||
|
||||
# Photos <= 4 provides no way to get date of adjustment and will update
|
||||
# lastmodifieddate anytime photo database record is updated (e.g. adding tags)
|
||||
# only report lastmodified date for Photos <=4 if photo is edited;
|
||||
# even in this case, the date could be incorrect
|
||||
if self.hasadjustments or self._db._db_version > _PHOTOS_4_VERSION:
|
||||
imagedate = self._info["lastmodifieddate"]
|
||||
if imagedate:
|
||||
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
return imagedate.astimezone(tz=tz)
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -147,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
|
||||
|
||||
@@ -158,6 +178,8 @@ class PhotoInfo:
|
||||
self._info["directory"],
|
||||
self._info["filename"],
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
self._path = photopath
|
||||
return photopath
|
||||
|
||||
@@ -171,6 +193,8 @@ class PhotoInfo:
|
||||
self._info["directory"],
|
||||
self._info["filename"],
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
self._path = photopath
|
||||
return photopath
|
||||
|
||||
@@ -179,18 +203,15 @@ class PhotoInfo:
|
||||
""" absolute path on disk of the edited picture """
|
||||
""" None if photo has not been edited """
|
||||
|
||||
# TODO: break this code into a _path_edited_4 and _path_edited_5
|
||||
# version to simplify the big if/then; same for path_live_photo
|
||||
|
||||
try:
|
||||
return self._path_edited
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
self._path_edited = self._path_edited_4()
|
||||
return self._path_edited
|
||||
else:
|
||||
self._path_edited = self._path_edited_5()
|
||||
return self._path_edited
|
||||
|
||||
return self._path_edited
|
||||
|
||||
def _path_edited_5(self):
|
||||
""" return path_edited for Photos >= 5 """
|
||||
@@ -248,8 +269,6 @@ class PhotoInfo:
|
||||
# if self._info["isMissing"] == 1:
|
||||
# photopath = None # path would be meaningless until downloaded
|
||||
|
||||
# logging.debug(photopath)
|
||||
|
||||
return photopath
|
||||
|
||||
def _path_edited_4(self):
|
||||
@@ -282,7 +301,7 @@ class PhotoInfo:
|
||||
# could be elsewhere--I haven't figured out this logic yet
|
||||
# first see if it's in 00
|
||||
photopath = os.path.join(
|
||||
library, "resources", "media", "version", folder_id, "00", filename,
|
||||
library, "resources", "media", "version", folder_id, "00", filename
|
||||
)
|
||||
|
||||
if not os.path.isfile(photopath):
|
||||
@@ -841,7 +860,7 @@ class PhotoInfo:
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
replacement=replacement
|
||||
replacement=replacement,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -1026,6 +1045,7 @@ class PhotoInfo:
|
||||
|
||||
def json(self):
|
||||
""" Return JSON representation """
|
||||
|
||||
def default(o):
|
||||
if isinstance(o, (datetime.date, datetime.datetime)):
|
||||
return o.isoformat()
|
||||
|
||||
1215
osxphotos/photokit.py
Normal 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 """
|
||||
|
||||
@@ -18,17 +18,40 @@ 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
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
|
||||
PHOTO_VIDEO_TYPE_DEFAULTS = {"photo": "photo", "video": "video"}
|
||||
|
||||
MEDIA_TYPE_DEFAULTS = {
|
||||
"selfie": "selfie",
|
||||
"time_lapse": "time_lapse",
|
||||
"panorama": "panorama",
|
||||
"slow_mo": "slow_mo",
|
||||
"screenshot": "screenshot",
|
||||
"portrait": "portrait",
|
||||
"live_photo": "live_photo",
|
||||
"burst": "burst",
|
||||
"photo": "photo",
|
||||
"video": "video",
|
||||
}
|
||||
|
||||
# Permitted substitutions (each of these returns a single value or None)
|
||||
TEMPLATE_SUBSTITUTIONS = {
|
||||
"{name}": "Current filename of the photo",
|
||||
"{original_name}": "Photo's original filename when imported to Photos",
|
||||
"{title}": "Title of the photo",
|
||||
"{descr}": "Description of the photo",
|
||||
"{media_type}": (
|
||||
f"Special media type resolved in this precedence: {', '.join(t for t in MEDIA_TYPE_DEFAULTS)}. "
|
||||
"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}'",
|
||||
"{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",
|
||||
@@ -104,6 +127,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
|
||||
@@ -128,6 +155,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,
|
||||
@@ -144,7 +227,7 @@ class PhotoTemplate:
|
||||
Args:
|
||||
template: str template
|
||||
none_str: str to use default for None values, default is '_'
|
||||
path_sep: optional character to use as path separator, default is os.path.sep
|
||||
path_sep: optional string to use as path separator, default is os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
@@ -159,8 +242,6 @@ class PhotoTemplate:
|
||||
|
||||
if path_sep is None:
|
||||
path_sep = os.path.sep
|
||||
elif path_sep is not None and len(path_sep) != 1:
|
||||
raise ValueError(f"path_sep must be single character: {path_sep}")
|
||||
|
||||
if inplace_sep is None:
|
||||
inplace_sep = ","
|
||||
@@ -174,51 +255,21 @@ class PhotoTemplate:
|
||||
# there would be 6 possible renderings (2 albums x 3 persons)
|
||||
|
||||
# regex to find {template_field,optional_default} in strings
|
||||
# for explanation of regex see https://regex101.com/r/4JJg42/1
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-\%. ]+))?)(?=\}(?!\}))\}"
|
||||
regex = (
|
||||
r"(?<!\{)\{" # match { but not {{
|
||||
+ r"([^}]*\+)?" # group 1: optional DELIM+
|
||||
+ r"([^\\,}+\?]+)" # group 2: 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 }}
|
||||
)
|
||||
|
||||
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 == 4:
|
||||
try:
|
||||
val = get_func(matchobj.group(1), matchobj.group(3))
|
||||
except ValueError:
|
||||
return matchobj.group(0)
|
||||
|
||||
if val is None:
|
||||
val = (
|
||||
matchobj.group(3)
|
||||
if matchobj.group(3) 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)
|
||||
@@ -246,82 +297,37 @@ 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"(?<!\\)\{(" + field + r")(,(([\w\-\%. ]{0,})))?\}"
|
||||
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:
|
||||
if regex_multi.search(str_template):
|
||||
values = self.get_template_value_multi(
|
||||
field,
|
||||
path_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
replacement=replacement,
|
||||
)
|
||||
if expand_inplace:
|
||||
# instead of returning multiple strings, join values into a single string
|
||||
val = (
|
||||
inplace_sep.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 = []
|
||||
for rendered_str in rendered_strings:
|
||||
unmatched.extend(
|
||||
[
|
||||
no_match[0]
|
||||
no_match[1]
|
||||
for no_match in re.findall(regex, rendered_str)
|
||||
if no_match[0] not in unmatched
|
||||
if no_match[1] not in unmatched
|
||||
]
|
||||
)
|
||||
|
||||
@@ -338,14 +344,263 @@ 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, default, filename=False, dirname=False, replacement=":"
|
||||
self,
|
||||
field,
|
||||
default,
|
||||
bool_val=None,
|
||||
delim=None,
|
||||
path_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
replacement=":",
|
||||
):
|
||||
"""lookup value for template field (single-value template substitutions)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
default: the default value provided by the user
|
||||
bool_val: True value if expression is boolean
|
||||
delim: delimiter for expand in place
|
||||
path_sep: path separator for fields that are path-like
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
replacement: str, value to replace any illegal file path characters with; default = ":"
|
||||
@@ -372,6 +627,12 @@ class PhotoTemplate:
|
||||
value = self.photo.title
|
||||
elif field == "descr":
|
||||
value = self.photo.description
|
||||
elif field == "media_type":
|
||||
value = self.get_media_type(default)
|
||||
elif field == "photo_or_video":
|
||||
value = self.get_photo_video_type(default)
|
||||
elif field == "hdr":
|
||||
value = self.get_photo_hdr(default, bool_val)
|
||||
elif field == "created.date":
|
||||
value = DateTimeFormatter(self.photo.date).date
|
||||
elif field == "created.year":
|
||||
@@ -606,6 +867,7 @@ class PhotoTemplate:
|
||||
"""
|
||||
|
||||
""" return list of values for a multi-valued template field """
|
||||
values = []
|
||||
if field == "album":
|
||||
values = self.photo.albums
|
||||
elif field == "keyword":
|
||||
@@ -649,7 +911,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
|
||||
@@ -667,3 +929,66 @@ class PhotoTemplate:
|
||||
values = values or [None]
|
||||
return values
|
||||
|
||||
def get_photo_video_type(self, default):
|
||||
""" return media type, e.g. photo or video """
|
||||
default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS)
|
||||
if self.photo.isphoto:
|
||||
return default_dict["photo"]
|
||||
else:
|
||||
return default_dict["video"]
|
||||
|
||||
def get_media_type(self, default):
|
||||
""" return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type """
|
||||
default_dict = parse_default_kv(default, MEDIA_TYPE_DEFAULTS)
|
||||
p = self.photo
|
||||
if p.selfie:
|
||||
return default_dict["selfie"]
|
||||
elif p.time_lapse:
|
||||
return default_dict["time_lapse"]
|
||||
elif p.panorama:
|
||||
return default_dict["panorama"]
|
||||
elif p.slow_mo:
|
||||
return default_dict["slow_mo"]
|
||||
elif p.screenshot:
|
||||
return default_dict["screenshot"]
|
||||
elif p.portrait:
|
||||
return default_dict["portrait"]
|
||||
elif p.live_photo:
|
||||
return default_dict["live_photo"]
|
||||
elif p.burst:
|
||||
return default_dict["burst"]
|
||||
elif p.ismovie:
|
||||
return default_dict["video"]
|
||||
else:
|
||||
return default_dict["photo"]
|
||||
|
||||
def get_photo_hdr(self, default, bool_val):
|
||||
if self.photo.hdr:
|
||||
return bool_val
|
||||
else:
|
||||
return default
|
||||
|
||||
|
||||
def parse_default_kv(default, default_dict):
|
||||
""" parse a string in form key1=value1;key2=value2,... as used for some template fields
|
||||
|
||||
Args:
|
||||
default: str, in form 'photo=foto;video=vidéo'
|
||||
default_dict: dict, in form {"photo": "fotos", "video": "vidéos"} with default values
|
||||
|
||||
Returns:
|
||||
dict in form {"photo": "fotos", "video": "vidéos"}
|
||||
"""
|
||||
|
||||
default_dict_ = default_dict.copy()
|
||||
if default:
|
||||
defaults = default.split(";")
|
||||
for kv in defaults:
|
||||
try:
|
||||
k, v = kv.split("=")
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
default_dict_[k] = v
|
||||
except ValueError:
|
||||
pass
|
||||
return default_dict_
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
2
setup.py
@@ -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,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-10-09T16:14:42Z</date>
|
||||
<date>2020-11-01T02:34:49Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-10-10T05:21:03Z</date>
|
||||
<date>2020-11-01T02:34:49Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2020-10-04T23:43:17Z</date>
|
||||
<date>2020-11-01T02:34:46Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<key>SnapshotCompletedDate</key>
|
||||
<date>2019-07-27T13:16:43Z</date>
|
||||
<key>SnapshotLastValidated</key>
|
||||
<date>2020-10-10T05:22:36Z</date>
|
||||
<date>2020-11-01T02:34:46Z</date>
|
||||
<key>SnapshotTables</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>1797</integer>
|
||||
<integer>464</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 74 KiB |
114
tests/tempdiskimage.py
Normal 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}")
|
||||
@@ -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
|
||||
|
||||
|
||||
1133
tests/test_catalina_10_15_7.py
Normal file
@@ -17,6 +17,7 @@ PLACES_PHOTOS_DB_13 = "tests/Test-Places-High-Sierra-10.13.6.photoslibrary"
|
||||
PHOTOS_DB_15_4 = "tests/Test-10.15.4.photoslibrary"
|
||||
PHOTOS_DB_15_5 = "tests/Test-10.15.5.photoslibrary"
|
||||
PHOTOS_DB_15_6 = "tests/Test-10.15.6.photoslibrary"
|
||||
PHOTOS_DB_15_7 = "tests/Test-10.15.7.photoslibrary"
|
||||
PHOTOS_DB_TOUCH = PHOTOS_DB_15_6
|
||||
PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary"
|
||||
|
||||
@@ -65,6 +66,7 @@ CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"]
|
||||
CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"]
|
||||
|
||||
CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
|
||||
CLI_EXPORT_ORIGINAL_SUFFIX = "_original"
|
||||
|
||||
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
|
||||
"Pumkins1.jpg",
|
||||
@@ -77,6 +79,16 @@ CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
|
||||
"wedding_bearbeiten.jpeg",
|
||||
]
|
||||
|
||||
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
|
||||
"Pumkins1_original.jpg",
|
||||
"Pumkins2_original.jpg",
|
||||
"Pumpkins3_original.jpg",
|
||||
"St James Park_original.jpg",
|
||||
"St James Park_edited.jpeg",
|
||||
"Tulips_original.jpg",
|
||||
"wedding_original.jpg",
|
||||
"wedding_edited.jpeg",
|
||||
]
|
||||
|
||||
CLI_EXPORT_FILENAMES_CURRENT = [
|
||||
"1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg",
|
||||
@@ -324,6 +336,49 @@ CLI_EXIFTOOL = {
|
||||
}
|
||||
}
|
||||
|
||||
CLI_EXIFTOOL_QUICKTIME = {
|
||||
"35329C57-B963-48D6-BB75-6AFF9370CBBC": {
|
||||
"File:FileName": "Jellyfish.MOV",
|
||||
"XMP:Description": "Jellyfish Video",
|
||||
"XMP:Title": "Jellyfish",
|
||||
"XMP:TagsList": "Travel",
|
||||
"XMP:Subject": "Travel",
|
||||
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
|
||||
"QuickTime:CreationDate": "2020:01:05 22:13:13",
|
||||
"QuickTime:CreateDate": "2020:01:05 22:13:13",
|
||||
"QuickTime:ModifyDate": "2020:01:05 22:13:13",
|
||||
},
|
||||
"2CE332F2-D578-4769-AEFA-7631BB77AA41": {
|
||||
"File:FileName": "Jellyfish.mp4",
|
||||
"XMP:Description": "Jellyfish Video",
|
||||
"XMP:Title": "Jellyfish",
|
||||
"XMP:TagsList": "Travel",
|
||||
"XMP:Subject": "Travel",
|
||||
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
|
||||
"QuickTime:CreationDate": "2020:12:05 05:21:52",
|
||||
"QuickTime:CreateDate": "2020:12:05 05:21:52",
|
||||
"QuickTime:ModifyDate": "2020:12:05 05:21:52",
|
||||
},
|
||||
}
|
||||
|
||||
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = {
|
||||
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
|
||||
"File:FileName": "wedding.jpg",
|
||||
"EXIF:ImageDescription": "Bride Wedding day",
|
||||
"XMP:Description": "Bride Wedding day",
|
||||
"XMP:TagsList": "wedding",
|
||||
"IPTC:Keywords": "wedding",
|
||||
"XMP:PersonInImage": "Maria",
|
||||
"XMP:Subject": ["wedding", "Maria"],
|
||||
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||
"EXIF:CreateDate": "2019:04:15 14:40:24",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"IPTC:DigitalCreationDate": "2019:04:15",
|
||||
"IPTC:DateCreated": "2019:04:15",
|
||||
"EXIF:ModifyDate": "2019:04:15 14:40:24",
|
||||
}
|
||||
}
|
||||
|
||||
LABELS_JSON = {
|
||||
"labels": {
|
||||
"Plant": 7,
|
||||
@@ -820,7 +875,7 @@ def test_export_using_hardlinks_incompat_options():
|
||||
"-V",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert result.exit_code == 1
|
||||
assert "Incompatible export options" in result.output
|
||||
|
||||
|
||||
@@ -931,6 +986,80 @@ def test_export_exiftool():
|
||||
assert exif[key] == CLI_EXIFTOOL[uuid][key]
|
||||
|
||||
|
||||
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||
def test_export_exiftool_ignore_date_modified():
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import export
|
||||
from osxphotos.exiftool import ExifTool
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
for uuid in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED:
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_6),
|
||||
".",
|
||||
"-V",
|
||||
"--exiftool",
|
||||
"--ignore-date-modified",
|
||||
"--uuid",
|
||||
f"{uuid}",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
exif = ExifTool(
|
||||
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]["File:FileName"]
|
||||
).asdict()
|
||||
for key in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]:
|
||||
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
|
||||
|
||||
|
||||
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||
def test_export_exiftool_quicktime():
|
||||
""" test --exiftol correctly writes QuickTime tags """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import export
|
||||
from osxphotos.exiftool import ExifTool
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
for uuid in CLI_EXIFTOOL_QUICKTIME:
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"-V",
|
||||
"--exiftool",
|
||||
"--uuid",
|
||||
f"{uuid}",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert sorted(files) == sorted(
|
||||
[CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]]
|
||||
)
|
||||
|
||||
exif = ExifTool(CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]).asdict()
|
||||
for key in CLI_EXIFTOOL_QUICKTIME[uuid]:
|
||||
assert exif[key] == CLI_EXIFTOOL_QUICKTIME[uuid][key]
|
||||
|
||||
# clean up exported files to avoid name conflicts
|
||||
for filename in files:
|
||||
os.unlink(filename)
|
||||
|
||||
|
||||
def test_export_edited_suffix():
|
||||
""" test export with --edited-suffix """
|
||||
import glob
|
||||
@@ -958,6 +1087,33 @@ def test_export_edited_suffix():
|
||||
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX)
|
||||
|
||||
|
||||
def test_export_original_suffix():
|
||||
""" test export with --original-suffix """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"--original-suffix",
|
||||
CLI_EXPORT_ORIGINAL_SUFFIX,
|
||||
"-V",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
|
||||
reason="Skip if running in Github actions, no GPU.",
|
||||
@@ -1684,6 +1840,142 @@ def test_export_sidecar_templates():
|
||||
)
|
||||
|
||||
|
||||
def test_export_sidecar_update():
|
||||
""" test sidecar don't update if not changed and do update if changed """
|
||||
import datetime
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
from osxphotos.__main__ import cli
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"export",
|
||||
"--db",
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"--sidecar=json",
|
||||
"--sidecar=xmp",
|
||||
f"--uuid={CLI_EXPORT_UUID}",
|
||||
"-V",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Writing XMP sidecar" in result.output
|
||||
assert "Writing exiftool JSON sidecar" in result.output
|
||||
|
||||
# delete a sidecar file and run update
|
||||
fileutil = FileUtil()
|
||||
fileutil.unlink(CLI_EXPORT_SIDECAR_FILENAMES[1])
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"export",
|
||||
"--db",
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"--sidecar=json",
|
||||
"--sidecar=xmp",
|
||||
f"--uuid={CLI_EXPORT_UUID}",
|
||||
"-V",
|
||||
"--update",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Skipped up to date XMP sidecar" in result.output
|
||||
assert "Writing exiftool JSON sidecar" in result.output
|
||||
|
||||
# run update again, no sidecar files should update
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"export",
|
||||
"--db",
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"--sidecar=json",
|
||||
"--sidecar=xmp",
|
||||
f"--uuid={CLI_EXPORT_UUID}",
|
||||
"-V",
|
||||
"--update",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Skipped up to date XMP sidecar" in result.output
|
||||
assert "Skipped up to date exiftool JSON sidecar" in result.output
|
||||
|
||||
# touch a file and export again
|
||||
ts = datetime.datetime.now().timestamp() + 1000
|
||||
fileutil.utime(CLI_EXPORT_SIDECAR_FILENAMES[2], (ts, ts))
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"export",
|
||||
"--db",
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"--sidecar=json",
|
||||
"--sidecar=xmp",
|
||||
f"--uuid={CLI_EXPORT_UUID}",
|
||||
"-V",
|
||||
"--update",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Writing XMP sidecar" in result.output
|
||||
assert "Skipped up to date exiftool JSON sidecar" in result.output
|
||||
|
||||
# run update again, no sidecar files should update
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"export",
|
||||
"--db",
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"--sidecar=json",
|
||||
"--sidecar=xmp",
|
||||
f"--uuid={CLI_EXPORT_UUID}",
|
||||
"-V",
|
||||
"--update",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Skipped up to date XMP sidecar" in result.output
|
||||
assert "Skipped up to date exiftool JSON sidecar" in result.output
|
||||
|
||||
# run update again with updated metadata, forcing update
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"export",
|
||||
"--db",
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"--sidecar=json",
|
||||
"--sidecar=xmp",
|
||||
f"--uuid={CLI_EXPORT_UUID}",
|
||||
"-V",
|
||||
"--update",
|
||||
"--keyword-template",
|
||||
"foo",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Writing XMP sidecar" in result.output
|
||||
assert "Writing exiftool JSON sidecar" in result.output
|
||||
|
||||
|
||||
def test_export_live():
|
||||
import glob
|
||||
import os
|
||||
@@ -2632,16 +2924,20 @@ def test_export_sidecar_keyword_template():
|
||||
|
||||
json_expected = json.loads(
|
||||
"""
|
||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
||||
"EXIF:ImageDescription": "Girl holding pumpkin",
|
||||
"XMP:Description": "Girl holding pumpkin",
|
||||
"XMP:Title": "I found one!",
|
||||
"XMP:TagsList": ["Kids", "Multi Keyword", "Test Album", "Pumpkin Farm"],
|
||||
"IPTC:Keywords": ["Kids", "Multi Keyword", "Test Album", "Pumpkin Farm"],
|
||||
"XMP:PersonInImage": ["Katie"],
|
||||
"XMP:Subject": ["Kids", "Katie"],
|
||||
"EXIF:DateTimeOriginal": "2018:09:28 16:07:07",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00"}]"""
|
||||
[{"EXIF:ImageDescription": "Girl holding pumpkin",
|
||||
"XMP:Description": "Girl holding pumpkin",
|
||||
"XMP:Title": "I found one!",
|
||||
"XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
|
||||
"IPTC:Keywords": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
|
||||
"XMP:PersonInImage": ["Katie"],
|
||||
"XMP:Subject": ["Kids", "Katie"],
|
||||
"EXIF:DateTimeOriginal": "2018:09:28 16:07:07",
|
||||
"EXIF:CreateDate": "2018:09:28 16:07:07",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"IPTC:DateCreated": "2018:09:28",
|
||||
"IPTC:TimeCreated": "16:07:07-04:00",
|
||||
"EXIF:ModifyDate": "2018:09:28 16:07:07"}]
|
||||
"""
|
||||
)[0]
|
||||
|
||||
with open("Pumkins2.jpg.json", "r") as json_file:
|
||||
@@ -2687,7 +2983,7 @@ def test_export_update_basic():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 8 photos, updated EXIF data: 0 photos"
|
||||
"Processed: 7 photos, exported: 0, updated: 0, skipped: 8, updated EXIF data: 0, missing: 1, error: 0"
|
||||
in result.output
|
||||
)
|
||||
|
||||
@@ -2771,7 +3067,7 @@ def test_export_update_exiftool():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 8 photos, skipped: 0 photos, updated EXIF data: 8 photos"
|
||||
"Processed: 7 photos, exported: 0, updated: 8, skipped: 0, updated EXIF data: 8, missing: 1, error: 0"
|
||||
in result.output
|
||||
)
|
||||
|
||||
@@ -2781,7 +3077,7 @@ def test_export_update_exiftool():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 8 photos, updated EXIF data: 0 photos"
|
||||
"Processed: 7 photos, exported: 0, updated: 0, skipped: 8, updated EXIF data: 0, missing: 1, error: 0"
|
||||
in result.output
|
||||
)
|
||||
|
||||
@@ -2818,7 +3114,7 @@ def test_export_update_hardlink():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 8 photos, skipped: 0 photos, updated EXIF data: 0 photos"
|
||||
"Processed: 7 photos, exported: 0, updated: 8, skipped: 0, updated EXIF data: 0, missing: 1, error: 0"
|
||||
in result.output
|
||||
)
|
||||
assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path)
|
||||
@@ -2857,7 +3153,7 @@ def test_export_update_hardlink_exiftool():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 8 photos, skipped: 0 photos, updated EXIF data: 8 photos"
|
||||
"Processed: 7 photos, exported: 0, updated: 8, skipped: 0, updated EXIF data: 8, missing: 1, error: 0"
|
||||
in result.output
|
||||
)
|
||||
assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path)
|
||||
@@ -2895,7 +3191,7 @@ def test_export_update_edits():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 1 photo, updated: 1 photo, skipped: 6 photos, updated EXIF data: 0 photos"
|
||||
"Processed: 7 photos, exported: 1, updated: 1, skipped: 6, updated EXIF data: 0, missing: 1, error: 0"
|
||||
in result.output
|
||||
)
|
||||
|
||||
@@ -2931,7 +3227,7 @@ def test_export_update_no_db():
|
||||
# edited files will be re-exported because there won't be an edited signature
|
||||
# in the database
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 2 photos, skipped: 6 photos, updated EXIF data: 0 photos"
|
||||
"Processed: 7 photos, exported: 0, updated: 2, skipped: 6, updated EXIF data: 0, missing: 1, error: 0"
|
||||
in result.output
|
||||
)
|
||||
assert os.path.isfile(OSXPHOTOS_EXPORT_DB)
|
||||
@@ -2970,15 +3266,15 @@ def test_export_then_hardlink():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Exported: 8 photos" in result.output
|
||||
assert "Processed: 7 photos, exported: 8, missing: 1, error: 0" in result.output
|
||||
assert os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path)
|
||||
|
||||
|
||||
def test_export_dry_run():
|
||||
""" test export with dry-run flag """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
@@ -2990,9 +3286,9 @@ def test_export_dry_run():
|
||||
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--dry-run"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Exported: 8 photos" in result.output
|
||||
assert "Processed: 7 photos, exported: 8, missing: 1, error: 0" in result.output
|
||||
for filepath in CLI_EXPORT_FILENAMES:
|
||||
assert f"Exported {filepath}" in result.output
|
||||
assert re.search(r"Exported.*" + f"{filepath}", result.output)
|
||||
assert not os.path.isfile(filepath)
|
||||
|
||||
|
||||
@@ -3034,7 +3330,7 @@ def test_export_update_edits_dry_run():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 1 photo, updated: 1 photo, skipped: 6 photos, updated EXIF data: 0 photos"
|
||||
"Processed: 7 photos, exported: 1, updated: 1, skipped: 6, updated EXIF data: 0, missing: 1, error: 0"
|
||||
in result.output
|
||||
)
|
||||
|
||||
@@ -3044,10 +3340,10 @@ def test_export_update_edits_dry_run():
|
||||
|
||||
def test_export_directory_template_1_dry_run():
|
||||
""" test export using directory template with dry-run flag """
|
||||
import glob
|
||||
import locale
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
@@ -3069,10 +3365,10 @@ def test_export_directory_template_1_dry_run():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Exported: 8 photos" in result.output
|
||||
assert "exported: 8" in result.output
|
||||
workdir = os.getcwd()
|
||||
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1:
|
||||
assert f"Exported {filepath}" in result.output
|
||||
assert re.search(r"Exported.*" + f"{filepath}", result.output)
|
||||
assert not os.path.isfile(os.path.join(workdir, filepath))
|
||||
|
||||
|
||||
@@ -3105,7 +3401,8 @@ def test_export_touch_files():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert "Exported: 18 photos, touched date: 16 photos" in result.output
|
||||
assert "exported: 18" in result.output
|
||||
assert "touched date: 16" in result.output
|
||||
|
||||
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
|
||||
st = os.stat(fname)
|
||||
@@ -3137,7 +3434,7 @@ def test_export_touch_files_update():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert "Exported: 18 photos" in result.output
|
||||
assert "exported: 18" in result.output
|
||||
|
||||
assert not pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file()
|
||||
|
||||
@@ -3147,7 +3444,7 @@ def test_export_touch_files_update():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert "Exported: 18 photos" in result.output
|
||||
assert "exported: 18" in result.output
|
||||
|
||||
assert pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file()
|
||||
|
||||
@@ -3158,10 +3455,7 @@ def test_export_touch_files_update():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "skipped: 18" in result.output
|
||||
|
||||
# --update --touch-file --dry-run
|
||||
result = runner.invoke(
|
||||
@@ -3176,10 +3470,8 @@ def test_export_touch_files_update():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 16 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "skipped: 18" in result.output
|
||||
assert "touched date: 16" in result.output
|
||||
|
||||
for fname, mtime in zip(
|
||||
CLI_EXPORT_BY_DATE_NEED_TOUCH, CLI_EXPORT_BY_DATE_NEED_TOUCH_TIMES
|
||||
@@ -3199,10 +3491,8 @@ def test_export_touch_files_update():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 16 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "skipped: 18" in result.output
|
||||
assert "touched date: 16" in result.output
|
||||
|
||||
for fname, mtime in zip(
|
||||
CLI_EXPORT_BY_DATE_NEED_TOUCH, CLI_EXPORT_BY_DATE_NEED_TOUCH_TIMES
|
||||
@@ -3225,10 +3515,8 @@ def test_export_touch_files_update():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 1 photo, skipped: 17 photos, updated EXIF data: 0 photos, touched date: 1 photo"
|
||||
in result.output
|
||||
)
|
||||
assert "updated: 1, skipped: 17" in result.output
|
||||
assert "touched date: 1" in result.output
|
||||
|
||||
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
|
||||
st = os.stat(fname)
|
||||
@@ -3241,10 +3529,7 @@ def test_export_touch_files_update():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "skipped: 18" in result.output
|
||||
|
||||
|
||||
@pytest.mark.skip("TODO: This fails on some machines but not all")
|
||||
@@ -3274,7 +3559,7 @@ def test_export_touch_files_exiftool_update():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert "Exported: 18 photos" in result.output
|
||||
assert "exported: 18" in result.output
|
||||
|
||||
assert not pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file()
|
||||
|
||||
@@ -3284,7 +3569,7 @@ def test_export_touch_files_exiftool_update():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert "Exported: 18 photos" in result.output
|
||||
assert "exported: 18" in result.output
|
||||
|
||||
assert pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file()
|
||||
|
||||
@@ -3295,10 +3580,7 @@ def test_export_touch_files_exiftool_update():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "skipped: 18" in result.output
|
||||
|
||||
# --update --exiftool --dry-run
|
||||
result = runner.invoke(
|
||||
@@ -3314,10 +3596,8 @@ def test_export_touch_files_exiftool_update():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 18 photos, skipped: 0 photos, updated EXIF data: 18 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "updated: 18" in result.output
|
||||
assert "updated EXIF data: 18" in result.output
|
||||
|
||||
# --update --exiftool
|
||||
result = runner.invoke(
|
||||
@@ -3331,11 +3611,8 @@ def test_export_touch_files_exiftool_update():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 18 photos, skipped: 0 photos, updated EXIF data: 18 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "updated: 18" in result.output
|
||||
assert "updated EXIF data: 18" in result.output
|
||||
|
||||
# --update --touch-file --exiftool --dry-run
|
||||
result = runner.invoke(
|
||||
@@ -3351,10 +3628,8 @@ def test_export_touch_files_exiftool_update():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 18 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "skipped: 18" in result.output
|
||||
assert "touched date: 18" in result.output
|
||||
|
||||
# --update --touch-file --exiftool
|
||||
result = runner.invoke(
|
||||
@@ -3369,10 +3644,8 @@ def test_export_touch_files_exiftool_update():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 18 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "skipped: 18" in result.output
|
||||
assert "touched date: 18" in result.output
|
||||
|
||||
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
|
||||
st = os.stat(fname)
|
||||
@@ -3394,10 +3667,10 @@ def test_export_touch_files_exiftool_update():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 1 photo, skipped: 17 photos, updated EXIF data: 1 photo, touched date: 1 photo"
|
||||
in result.output
|
||||
)
|
||||
assert "updated: 1" in result.output
|
||||
assert "skipped: 17" in result.output
|
||||
assert "updated EXIF data: 1" in result.output
|
||||
assert "touched date: 1" in result.output
|
||||
|
||||
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
|
||||
st = os.stat(fname)
|
||||
@@ -3416,10 +3689,8 @@ def test_export_touch_files_exiftool_update():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 0 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "exported: 0" in result.output
|
||||
assert "skipped: 18" in result.output
|
||||
|
||||
# run update without --touch-file
|
||||
result = runner.invoke(
|
||||
@@ -3434,10 +3705,8 @@ def test_export_touch_files_exiftool_update():
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert (
|
||||
"Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos"
|
||||
in result.output
|
||||
)
|
||||
assert "exported: 0" in result.output
|
||||
assert "skipped: 18" in result.output
|
||||
|
||||
|
||||
def test_labels():
|
||||
@@ -3532,3 +3801,263 @@ def test_persons():
|
||||
|
||||
json_got = json.loads(result.output)
|
||||
assert json_got == PERSONS_JSON
|
||||
|
||||
|
||||
def test_export_report():
|
||||
""" test export with --report option """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "report.csv"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Writing export report" in result.output
|
||||
assert os.path.exists("report.csv")
|
||||
|
||||
|
||||
def test_export_report_not_a_file():
|
||||
""" test export with --report option and bad report value """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "."]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Aborted!" in result.output
|
||||
|
||||
|
||||
def test_export_as_hardlink_download_missing():
|
||||
""" test export with incompatible export options """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--download-missing",
|
||||
"--export-as-hardlink",
|
||||
".",
|
||||
],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Aborted!" in result.output
|
||||
|
||||
|
||||
def test_export_missing():
|
||||
""" test export with --missing """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"-V",
|
||||
"--missing",
|
||||
"--download-missing",
|
||||
".",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Exporting 2 photos" in result.output
|
||||
|
||||
|
||||
def test_export_missing_not_download_missing():
|
||||
""" test export with incompatible export options """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--missing", "."]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Aborted!" in result.output
|
||||
|
||||
|
||||
def test_export_cleanup():
|
||||
""" test export with --cleanup flag """
|
||||
import pathlib
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# create 2 files and a directory
|
||||
with open("delete_me.txt", "w") as fd:
|
||||
fd.write("delete me!")
|
||||
os.mkdir("./foo")
|
||||
with open("foo/delete_me_too.txt", "w") as fd:
|
||||
fd.write("delete me too!")
|
||||
|
||||
assert pathlib.Path("./delete_me.txt").is_file()
|
||||
# run cleanup with dry-run
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--update",
|
||||
"--cleanup",
|
||||
"--dry-run",
|
||||
],
|
||||
)
|
||||
assert "Deleted: 2 files, 0 directories" in result.output
|
||||
assert pathlib.Path("./delete_me.txt").is_file()
|
||||
assert pathlib.Path("./foo/delete_me_too.txt").is_file()
|
||||
|
||||
# run cleanup without dry-run
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--update", "--cleanup"],
|
||||
)
|
||||
assert "Deleted: 2 files, 1 directory" in result.output
|
||||
assert not pathlib.Path("./delete_me.txt").is_file()
|
||||
assert not pathlib.Path("./foo/delete_me_too.txt").is_file()
|
||||
|
||||
|
||||
def test_save_load_config():
|
||||
""" test --save-config, --load-config """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
# test save config file
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--sidecar",
|
||||
"XMP",
|
||||
"--touch-file",
|
||||
"--update",
|
||||
"--save-config",
|
||||
"config.toml",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Saving options to file" in result.output
|
||||
files = glob.glob("*")
|
||||
assert "config.toml" in files
|
||||
|
||||
# test load config file
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--load-config",
|
||||
"config.toml",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Loaded options from file" in result.output
|
||||
assert "Skipped up to date XMP sidecar" in result.output
|
||||
|
||||
# test overwrite existing config file
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--sidecar",
|
||||
"XMP",
|
||||
"--touch-file",
|
||||
"--not-live",
|
||||
"--update",
|
||||
"--save-config",
|
||||
"config.toml",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Saving options to file" in result.output
|
||||
files = glob.glob("*")
|
||||
assert "config.toml" in files
|
||||
|
||||
# test load config file with incompat command line option
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--load-config",
|
||||
"config.toml",
|
||||
"--live",
|
||||
],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Incompatible export options" in result.output
|
||||
|
||||
# test load config file with command line override
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, CLI_PHOTOS_DB),
|
||||
".",
|
||||
"-V",
|
||||
"--load-config",
|
||||
"config.toml",
|
||||
"--sidecar",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Writing exiftool JSON sidecar" in result.output
|
||||
assert "Writing XMP sidecar" not in result.output
|
||||
|
||||
86
tests/test_configoptions.py
Normal 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")])
|
||||
@@ -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)
|
||||
@@ -103,10 +103,70 @@ def test_setvalue_1():
|
||||
|
||||
exif = osxphotos.exiftool.ExifTool(tempfile)
|
||||
exif.setvalue("IPTC:Keywords", "test")
|
||||
assert not exif.error
|
||||
|
||||
exif._read_exif()
|
||||
assert exif.data["IPTC:Keywords"] == "test"
|
||||
|
||||
|
||||
def test_setvalue_error():
|
||||
# test setting illegal tag value generates error
|
||||
import os.path
|
||||
import tempfile
|
||||
import osxphotos.exiftool
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD))
|
||||
FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile)
|
||||
|
||||
exif = osxphotos.exiftool.ExifTool(tempfile)
|
||||
exif.setvalue("IPTC:Foo", "test")
|
||||
assert exif.error
|
||||
|
||||
|
||||
def test_setvalue_context_manager():
|
||||
# test setting a tag value as context manager
|
||||
import os.path
|
||||
import tempfile
|
||||
import osxphotos.exiftool
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD))
|
||||
FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile)
|
||||
|
||||
with osxphotos.exiftool.ExifTool(tempfile) as exif:
|
||||
exif.setvalue("IPTC:Keywords", "test1")
|
||||
exif.setvalue("IPTC:Keywords", "test2")
|
||||
exif.setvalue("XMP:Title", "title")
|
||||
exif.setvalue("XMP:Subject", "subject")
|
||||
|
||||
assert exif.error is None
|
||||
|
||||
exif2 = osxphotos.exiftool.ExifTool(tempfile)
|
||||
exif2._read_exif()
|
||||
assert sorted(exif2.data["IPTC:Keywords"]) == ["test1", "test2"]
|
||||
assert exif2.data["XMP:Title"] == "title"
|
||||
assert exif2.data["XMP:Subject"] == "subject"
|
||||
|
||||
|
||||
def test_setvalue_context_manager_error():
|
||||
# test setting a tag value as context manager when error generated
|
||||
import os.path
|
||||
import tempfile
|
||||
import osxphotos.exiftool
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD))
|
||||
FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile)
|
||||
|
||||
with osxphotos.exiftool.ExifTool(tempfile) as exif:
|
||||
exif.setvalue("Foo:Bar", "test1")
|
||||
assert exif.error
|
||||
|
||||
|
||||
def test_clear_value():
|
||||
# test clearing a tag value
|
||||
import os.path
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -67,18 +67,35 @@ XMP_FILENAME = "Pumkins1.jpg.xmp"
|
||||
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", '
|
||||
'"XMP:Description": "Bride Wedding day", '
|
||||
'"XMP:TagsList": ["wedding"], '
|
||||
'"IPTC:Keywords": ["wedding"], '
|
||||
'"XMP:PersonInImage": ["Maria"], '
|
||||
'"XMP:Subject": ["wedding", "Maria"], '
|
||||
'"EXIF:DateTimeOriginal": "2019:04:15 14:40:24", '
|
||||
'"EXIF:OffsetTimeOriginal": "-04:00", '
|
||||
'"EXIF:ModifyDate": "2019:07:27 17:33:28"}]'
|
||||
)
|
||||
EXIF_JSON_EXPECTED = """
|
||||
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||
"XMP:Description": "Bride Wedding day",
|
||||
"XMP:TagsList": ["wedding"],
|
||||
"IPTC:Keywords": ["wedding"],
|
||||
"XMP:PersonInImage": ["Maria"],
|
||||
"XMP:Subject": ["wedding", "Maria"],
|
||||
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||
"EXIF:CreateDate": "2019:04:15 14:40:24",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"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 = """
|
||||
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||
"XMP:Description": "Bride Wedding day",
|
||||
"XMP:TagsList": ["wedding"],
|
||||
"IPTC:Keywords": ["wedding"],
|
||||
"XMP:PersonInImage": ["Maria"],
|
||||
"XMP:Subject": ["wedding", "Maria"],
|
||||
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||
"EXIF:CreateDate": "2019:04:15 14:40:24",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"IPTC:DateCreated": "2019:04:15",
|
||||
"IPTC:TimeCreated": "14:40:24-04:00",
|
||||
"EXIF:ModifyDate": "2019:04:15 14:40:24"}]
|
||||
"""
|
||||
|
||||
|
||||
def test_export_1():
|
||||
@@ -397,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
|
||||
|
||||
@@ -489,6 +484,32 @@ def test_exiftool_json_sidecar():
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_exiftool_json_sidecar_ignore_date_modified():
|
||||
import osxphotos
|
||||
import json
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
|
||||
|
||||
json_expected = json.loads(EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED)[0]
|
||||
|
||||
json_got = photos[0]._exiftool_json_sidecar(ignore_date_modified=True)
|
||||
json_got = json.loads(json_got)[0]
|
||||
|
||||
# some gymnastics to account for different sort order in different pythons
|
||||
for k, v in json_got.items():
|
||||
if type(v) in (list, tuple):
|
||||
assert sorted(json_expected[k]) == sorted(v)
|
||||
else:
|
||||
assert json_expected[k] == v
|
||||
|
||||
for k, v in json_expected.items():
|
||||
if type(v) in (list, tuple):
|
||||
assert sorted(json_got[k]) == sorted(v)
|
||||
else:
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_exiftool_json_sidecar_keyword_template_long(caplog):
|
||||
import osxphotos
|
||||
from osxphotos._constants import _MAX_IPTC_KEYWORD_LEN
|
||||
@@ -499,15 +520,17 @@ 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"],
|
||||
"XMP:PersonInImage": ["Maria"],
|
||||
"XMP:Subject": ["wedding", "Maria"],
|
||||
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||
"EXIF:CreateDate": "2019:04:15 14:40:24",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"IPTC:DateCreated": "2019:04:15",
|
||||
"IPTC:TimeCreated": "14:40:24-04:00",
|
||||
"EXIF:ModifyDate": "2019:07:27 17:33:28"}]
|
||||
"""
|
||||
)[0]
|
||||
@@ -546,15 +569,17 @@ 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"],
|
||||
"XMP:PersonInImage": ["Maria"],
|
||||
"XMP:Subject": ["wedding", "Maria"],
|
||||
"XMP:Subject": ["wedding", "Maria"],
|
||||
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||
"EXIF:CreateDate": "2019:04:15 14:40:24",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"IPTC:DateCreated": "2019:04:15",
|
||||
"IPTC:TimeCreated": "14:40:24-04:00",
|
||||
"EXIF:ModifyDate": "2019:07:27 17:33:28"}]
|
||||
"""
|
||||
)[0]
|
||||
@@ -604,16 +629,19 @@ 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"],
|
||||
"IPTC:Keywords": ["Kids", "Suzy", "Katie"],
|
||||
"XMP:PersonInImage": ["Suzy", "Katie"],
|
||||
"XMP:Subject": ["Kids", "Suzy", "Katie"],
|
||||
"IPTC:Keywords": ["Kids", "Suzy", "Katie"],
|
||||
"XMP:PersonInImage": ["Suzy", "Katie"],
|
||||
"XMP:Subject": ["Kids", "Suzy", "Katie"],
|
||||
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00"}]
|
||||
"EXIF:CreateDate": "2018:09:28 15:35:49",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"IPTC:DateCreated": "2018:09:28",
|
||||
"IPTC:TimeCreated": "15:35:49-04:00",
|
||||
"EXIF:ModifyDate": "2018:09:28 15:35:49"}]
|
||||
"""
|
||||
)[0]
|
||||
|
||||
@@ -643,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"],
|
||||
@@ -652,7 +679,11 @@ def test_exiftool_json_sidecar_use_albums_keyword():
|
||||
"XMP:PersonInImage": ["Suzy", "Katie"],
|
||||
"XMP:Subject": ["Kids", "Suzy", "Katie"],
|
||||
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00"}]
|
||||
"EXIF:CreateDate": "2018:09:28 15:35:49",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"IPTC:DateCreated": "2018:09:28",
|
||||
"IPTC:TimeCreated": "15:35:49-04:00",
|
||||
"EXIF:ModifyDate": "2018:09:28 15:35:49"}]
|
||||
"""
|
||||
)[0]
|
||||
|
||||
@@ -685,7 +716,7 @@ def test_xmp_sidecar_is_valid(tmp_path):
|
||||
xmp_file = tmp_path / XMP_FILENAME
|
||||
assert xmp_file.is_file()
|
||||
exiftool = ExifTool(str(xmp_file))
|
||||
output = exiftool.run_commands("-validate", "-warning")
|
||||
output, _ = exiftool.run_commands("-validate", "-warning")
|
||||
assert output == b"[ExifTool] Validate : 0 0 0"
|
||||
|
||||
|
||||
@@ -970,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's Park</dc:title>
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
@@ -979,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's Park</rdf:li>
|
||||
</rdf:Seq>
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-10-13T09:18:12.501000-04:00</photoshop:DateCreated>
|
||||
@@ -996,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's Park</rdf:li>
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
</rdf:Description>
|
||||
@@ -1007,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>"""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -45,17 +45,22 @@ UUID_DICT = {
|
||||
"xmp": "8SOE9s0XQVGsuq4ONohTng",
|
||||
}
|
||||
|
||||
EXIF_JSON_EXPECTED = (
|
||||
'[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", '
|
||||
'"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"], "EXIF:GPSLatitude": 51.50357167, '
|
||||
'"EXIF:GPSLongitude": -0.1318055, "EXIF:GPSLatitudeRef": "N", '
|
||||
'"EXIF:GPSLongitudeRef": "W", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", '
|
||||
'"EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:12:01 11:43:45"}]'
|
||||
)
|
||||
EXIF_JSON_EXPECTED = """
|
||||
[{"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"],
|
||||
"EXIF:GPSLatitude": 51.50357167,
|
||||
"EXIF:GPSLongitude": -0.1318055,
|
||||
"EXIF:GPSLatitudeRef": "N",
|
||||
"EXIF:GPSLongitudeRef": "W",
|
||||
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
|
||||
"EXIF:CreateDate": "2018:10:13 09:18:12",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"IPTC:DateCreated": "2018:10:13",
|
||||
"IPTC:TimeCreated": "09:18:12-04:00",
|
||||
"EXIF:ModifyDate": "2019:12:01 11:43:45"}]
|
||||
"""
|
||||
|
||||
|
||||
def test_export_1():
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
""" Test basic methods for Mojave 10.14.6 """
|
||||
|
||||
import datetime
|
||||
from collections import namedtuple
|
||||
|
||||
import pytest
|
||||
|
||||
from collections import namedtuple
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
|
||||
PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
|
||||
@@ -535,6 +539,31 @@ def test_date_modified_invalid(photosdb):
|
||||
assert p.date_modified is None
|
||||
|
||||
|
||||
def test_date_modified(photosdb):
|
||||
""" Test date modified for photo that has been edited """
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
|
||||
p = photos[0]
|
||||
assert p.date_modified == datetime.datetime(
|
||||
2019,
|
||||
11,
|
||||
27,
|
||||
1,
|
||||
30,
|
||||
16,
|
||||
681150,
|
||||
tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=72000)),
|
||||
)
|
||||
|
||||
|
||||
def test_date_modified_none(photosdb):
|
||||
""" Test date modified for a photo that hasn't been edited """
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
|
||||
p = photos[0]
|
||||
assert p.date_modified is None
|
||||
|
||||
|
||||
def test_uti(photosdb):
|
||||
for uuid, utis in UUID_UTI_DICT.items():
|
||||
photo = photosdb.get_photo(uuid)
|
||||
|
||||
387
tests/test_photokit.py
Normal 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)
|
||||
@@ -1,14 +1,20 @@
|
||||
""" 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"
|
||||
)
|
||||
PHOTOS_DB_15_1 = "./tests/Test-10.15.1.photoslibrary/database/photos.db"
|
||||
PHOTOS_DB_15_4 = "./tests/Test-10.15.4.photoslibrary/database/photos.db"
|
||||
PHOTOS_DB_15_7 = "./tests/Test-10.15.7.photoslibrary/database/photos.db"
|
||||
PHOTOS_DB_14_6 = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
|
||||
|
||||
PHOTOS_DB_COMMENTS = "tests/Test-Cloud-10.15.6.photoslibrary"
|
||||
PHOTOS_DB_CLOUD = "./tests/Test-Cloud-10.15.6.photoslibrary/database/photos.db"
|
||||
|
||||
UUID_DICT = {
|
||||
"place_dc": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
|
||||
@@ -22,6 +28,65 @@ UUID_DICT = {
|
||||
"date_not_modified": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
|
||||
}
|
||||
|
||||
UUID_MEDIA_TYPE = {
|
||||
"photo": "C2BBC7A4-5333-46EE-BAF0-093E72111B39",
|
||||
"video": "45099D34-A414-464F-94A2-60D6823679C8",
|
||||
"selfie": "080525C4-1F05-48E5-A3F4-0C53127BB39C",
|
||||
"time_lapse": "4614086E-C797-4876-B3B9-3057E8D757C9",
|
||||
"panorama": "1C1C8F1F-826B-4A24-B1CB-56628946A834",
|
||||
"slow_mo": None,
|
||||
"screenshot": None,
|
||||
"portrait": "7CDA5F84-AA16-4D28-9AA6-A49E1DF8A332",
|
||||
"live_photo": "51F2BEF7-431A-4D31-8AC1-3284A57826AE",
|
||||
"burst": None,
|
||||
}
|
||||
|
||||
# multi keywords
|
||||
UUID_MULTI_KEYWORDS = "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"
|
||||
TEMPLATE_VALUES_MULTI_KEYWORDS = {
|
||||
"{keyword}": ["flowers", "wedding"],
|
||||
"{+keyword}": ["flowerswedding"],
|
||||
"{;+keyword}": ["flowers;wedding"],
|
||||
"{; +keyword}": ["flowers; wedding"],
|
||||
}
|
||||
|
||||
UUID_TITLE = "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"
|
||||
TEMPLATE_VALUES_TITLE = {
|
||||
"{title}": ["Tulips tied together at a flower shop"],
|
||||
"{+title}": ["Tulips tied together at a flower shop"],
|
||||
"{,+title}": ["Tulips tied together at a flower shop"],
|
||||
"{, +title}": ["Tulips tied together at a flower shop"],
|
||||
}
|
||||
|
||||
# Boolean type values that render to True
|
||||
UUID_BOOL_VALUES = {"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15"}
|
||||
|
||||
# Boolean type values that render to False
|
||||
UUID_BOOL_VALUES_NOT = {"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE"}
|
||||
|
||||
# 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",
|
||||
"{original_name}": "IMG_1064",
|
||||
@@ -267,7 +332,7 @@ def test_subst_default_val_2():
|
||||
|
||||
template = "{place.name.area_of_interest,}"
|
||||
rendered, _ = photo.render_template(template)
|
||||
assert rendered[0] == "_"
|
||||
assert rendered[0] == ""
|
||||
|
||||
|
||||
def test_subst_unknown_val():
|
||||
@@ -284,10 +349,6 @@ def test_subst_unknown_val():
|
||||
assert rendered[0] == "2020/{foo}"
|
||||
assert unknown == ["foo"]
|
||||
|
||||
template = "{place.name.area_of_interest,}"
|
||||
rendered, _ = photo.render_template(template)
|
||||
assert rendered[0] == "_"
|
||||
|
||||
|
||||
def test_subst_double_brace():
|
||||
""" Test substitution with double brace {{ which should be ignored """
|
||||
@@ -322,7 +383,7 @@ def test_subst_multi_1_1_2():
|
||||
# one album, one keyword, two persons
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
|
||||
|
||||
template = "{created.year}/{album}/{keyword}/{person}"
|
||||
@@ -336,16 +397,12 @@ def test_subst_multi_2_1_1():
|
||||
# 2 albums, 1 keyword, 1 person
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["2_1_1"]])[0]
|
||||
|
||||
template = "{created.year}/{album}/{keyword}/{person}"
|
||||
expected = [
|
||||
"2018/Pumpkin Farm/Kids/Katie",
|
||||
"2018/Test Album/Kids/Katie",
|
||||
"2018/Multi Keyword/Kids/Katie",
|
||||
]
|
||||
expected = ["2018/Pumpkin Farm/Kids/Katie", "2018/Test Album/Kids/Katie"]
|
||||
rendered, _ = photo.render_template(template)
|
||||
assert sorted(rendered) == sorted(expected)
|
||||
|
||||
@@ -355,7 +412,7 @@ def test_subst_multi_2_1_1_single():
|
||||
# 2 albums, 1 keyword, 1 person but only do keywords
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["2_1_1"]])[0]
|
||||
|
||||
@@ -370,7 +427,7 @@ def test_subst_multi_0_2_0():
|
||||
# 0 albums, 2 keywords, 0 persons
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
|
||||
|
||||
@@ -385,7 +442,7 @@ def test_subst_multi_0_2_0_single():
|
||||
# 0 albums, 2 keywords, 0 persons, but only do albums
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
|
||||
|
||||
@@ -400,7 +457,7 @@ def test_subst_multi_0_2_0_default_val():
|
||||
# 0 albums, 2 keywords, 0 persons, default vals provided
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
|
||||
|
||||
@@ -415,7 +472,7 @@ def test_subst_multi_0_2_0_default_val_unknown_val():
|
||||
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
|
||||
|
||||
@@ -436,7 +493,7 @@ def test_subst_multi_0_2_0_default_val_unknown_val_2():
|
||||
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
|
||||
|
||||
@@ -454,12 +511,35 @@ def test_subst_multi_folder_albums_1():
|
||||
""" Test substitutions for folder_album are correct """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
|
||||
# photo in an album in a folder
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_1"]])[0]
|
||||
template = "{folder_album}"
|
||||
expected = ["Folder1/SubFolder2/AlbumInFolder"]
|
||||
expected = [
|
||||
"2018-10 - Sponsion, Museum, Frühstück, Römermuseum",
|
||||
"2019-10/11 Paris Clermont",
|
||||
"Folder1/SubFolder2/AlbumInFolder",
|
||||
]
|
||||
rendered, unknown = photo.render_template(template)
|
||||
assert sorted(rendered) == sorted(expected)
|
||||
assert unknown == []
|
||||
|
||||
|
||||
def test_subst_multi_folder_albums_1_path_sep():
|
||||
""" Test substitutions for folder_album are correct with custom PATH_SEP """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
|
||||
# photo in an album in a folder
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_1"]])[0]
|
||||
template = "{folder_album(:)}"
|
||||
expected = [
|
||||
"2018-10 - Sponsion, Museum, Frühstück, Römermuseum",
|
||||
"2019-10/11 Paris Clermont",
|
||||
"Folder1:SubFolder2:AlbumInFolder",
|
||||
]
|
||||
rendered, unknown = photo.render_template(template)
|
||||
assert sorted(rendered) == sorted(expected)
|
||||
assert unknown == []
|
||||
@@ -469,7 +549,7 @@ def test_subst_multi_folder_albums_2():
|
||||
""" Test substitutions for folder_album are correct """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
|
||||
# photo in an album in a folder
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_no_folder"]])[0]
|
||||
@@ -480,6 +560,21 @@ def test_subst_multi_folder_albums_2():
|
||||
assert unknown == []
|
||||
|
||||
|
||||
def test_subst_multi_folder_albums_2_path_sep():
|
||||
""" Test substitutions for folder_album are correct with custom PATH_SEP """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
|
||||
# photo in an album in a folder
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_no_folder"]])[0]
|
||||
template = "{folder_album(:)}"
|
||||
expected = ["Pumpkin Farm", "Test Album"]
|
||||
rendered, unknown = photo.render_template(template)
|
||||
assert sorted(rendered) == sorted(expected)
|
||||
assert unknown == []
|
||||
|
||||
|
||||
def test_subst_multi_folder_albums_3():
|
||||
""" Test substitutions for folder_album on < Photos 5 """
|
||||
import osxphotos
|
||||
@@ -495,6 +590,21 @@ def test_subst_multi_folder_albums_3():
|
||||
assert unknown == []
|
||||
|
||||
|
||||
def test_subst_multi_folder_albums_3_path_sep():
|
||||
""" Test substitutions for folder_album on < Photos 5 with custom PATH_SEP """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_14_6)
|
||||
|
||||
# photo in an album in a folder
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
|
||||
template = "{folder_album(:)}"
|
||||
expected = ["Folder1:SubFolder2:AlbumInFolder", "Pumpkin Farm", "Test Album (1)"]
|
||||
rendered, unknown = photo.render_template(template)
|
||||
assert sorted(rendered) == sorted(expected)
|
||||
assert unknown == []
|
||||
|
||||
|
||||
def test_subst_strftime():
|
||||
""" Test that strftime substitutions are correct """
|
||||
import locale
|
||||
@@ -515,7 +625,7 @@ def test_subst_expand_inplace_1():
|
||||
""" Test that substitutions are correct when expand_inplace=True """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
|
||||
|
||||
@@ -529,7 +639,7 @@ def test_subst_expand_inplace_2():
|
||||
""" Test that substitutions are correct when expand_inplace=True """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
|
||||
|
||||
@@ -543,7 +653,7 @@ def test_subst_expand_inplace_3():
|
||||
""" Test that substitutions are correct when expand_inplace=True and inplace_sep specified"""
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
|
||||
|
||||
@@ -563,3 +673,109 @@ def test_comment():
|
||||
photo = photosdb.get_photo(uuid)
|
||||
comments = photo.render_template("{comment}")
|
||||
assert comments[0] == COMMENT_UUID_DICT[uuid]
|
||||
|
||||
|
||||
def test_media_type():
|
||||
""" test {media_type} template """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
|
||||
for field, uuid in UUID_MEDIA_TYPE.items():
|
||||
if uuid is not None:
|
||||
photo = photosdb.get_photo(uuid)
|
||||
rendered, _ = photo.render_template("{media_type}")
|
||||
assert rendered[0] == osxphotos.phototemplate.MEDIA_TYPE_DEFAULTS[field]
|
||||
|
||||
|
||||
def test_media_type_default():
|
||||
""" test {media_type,photo=foo} template style """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
|
||||
for field, uuid in UUID_MEDIA_TYPE.items():
|
||||
if uuid is not None:
|
||||
photo = photosdb.get_photo(uuid)
|
||||
rendered, _ = photo.render_template("{media_type," + f"{field}" + "=foo}")
|
||||
assert rendered[0] == "foo"
|
||||
|
||||
|
||||
def test_bool_values():
|
||||
""" test {bool?TRUE,FALSE} template values """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
|
||||
for field, uuid in UUID_BOOL_VALUES.items():
|
||||
if uuid is not None:
|
||||
photo = photosdb.get_photo(uuid)
|
||||
rendered, _ = photo.render_template("{" + f"{field}" + "?True,False}")
|
||||
assert rendered[0] == "True"
|
||||
|
||||
|
||||
def test_bool_values_not():
|
||||
""" test {bool?TRUE,FALSE} template values for FALSE values """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
|
||||
for field, uuid in UUID_BOOL_VALUES_NOT.items():
|
||||
if uuid is not None:
|
||||
photo = photosdb.get_photo(uuid)
|
||||
rendered, _ = photo.render_template("{" + f"{field}" + "?True,False}")
|
||||
assert rendered[0] == "False"
|
||||
|
||||
|
||||
def test_partial_match():
|
||||
""" test that template successfully rejects a field that is superset of valid field """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
|
||||
for uuid in COMMENT_UUID_DICT:
|
||||
photo = photosdb.get_photo(uuid)
|
||||
rendered, notmatched = photo.render_template("{keywords}")
|
||||
assert [rendered, notmatched] == [["{keywords}"], ["keywords"]]
|
||||
rendered, notmatched = photo.render_template("{keywords,}")
|
||||
assert [rendered, notmatched] == [["{keywords,}"], ["keywords"]]
|
||||
rendered, notmatched = photo.render_template("{keywords,foo}")
|
||||
assert [rendered, notmatched] == [["{keywords,foo}"], ["keywords"]]
|
||||
rendered, notmatched = photo.render_template("{,+keywords,foo}")
|
||||
assert [rendered, notmatched] == [["{,+keywords,foo}"], ["keywords"]]
|
||||
|
||||
|
||||
def test_expand_in_place_with_delim():
|
||||
""" Test that substitutions are correct when {DELIM+FIELD} format used """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
|
||||
|
||||
for template in TEMPLATE_VALUES_MULTI_KEYWORDS:
|
||||
rendered, _ = photo.render_template(template)
|
||||
assert sorted(rendered) == sorted(TEMPLATE_VALUES_MULTI_KEYWORDS[template])
|
||||
|
||||
|
||||
def test_expand_in_place_with_delim_single_value():
|
||||
""" Test that single-value substitutions are correct when {DELIM+FIELD} format used """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
|
||||
photo = photosdb.get_photo(UUID_TITLE)
|
||||
|
||||
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])
|
||||
|
||||