Compare commits

...

331 Commits

Author SHA1 Message Date
Rhet Turnbull
d0ec8620c7 Added py37 2020-08-08 22:04:36 -07:00
Rhet Turnbull
10156e34b5 Updated requirements.txt 2020-08-08 22:03:12 -07:00
Rhet Turnbull
a714ae0af0 Dropped py36 due to datetime.fromisoformat 2020-08-08 21:59:38 -07:00
Rhet Turnbull
fc416ea0b7 Fixed from_date and to_date to be timezone aware, closes #193 2020-08-08 21:03:34 -07:00
Rhet Turnbull
2628c1f2d2 Cleaned up test images 2020-08-08 08:22:28 -07:00
Rhet Turnbull
e482c3915a Added test for valid XMP file, closes #197 2020-08-01 07:58:00 -07:00
Rhet Turnbull
6baeae7ddd Test library updates 2020-08-01 06:55:51 -07:00
Rhet Turnbull
bea770b322 Added write_uuid_to_file.applescript to utils 2020-08-01 06:55:23 -07:00
Rhet Turnbull
840e9937be Added --uuid-from-file to CLI 2020-07-31 19:02:52 -07:00
Rhet Turnbull
002fce8e93 Updated README.md 2020-07-28 22:58:12 -07:00
Rhet Turnbull
ef32b1e9bc Test library updates 2020-07-27 15:31:02 -07:00
Rhet Turnbull
6f29cda99f Initial FaceInfo support for Issue #21 2020-07-27 06:20:04 -07:00
Rhet Turnbull
9fc4f76219 Updated Github Actions to run on PR 2020-07-24 19:03:01 -07:00
Rhet Turnbull
65b84ad345 Updated CHANGELOG.md 2020-07-23 07:20:30 -07:00
Rhet Turnbull
cf4dca10c0 Version bump for bug fix 2020-07-23 07:14:15 -07:00
Rhet Turnbull
27040d1604 Revert "Merge pull request #191 from RhetTbull/revert-190-Fix133"
This reverts commit b7f4b739de, reversing
changes made to da551036f9.
2020-07-23 07:04:42 -07:00
Rhet Turnbull
b91a9828fa Merge pull request #192 from PabloKohan/Fix133
Fix findfiles not to fail on missing/invalid dir
2020-07-23 06:55:47 -07:00
Pablo 'merKur' Kohan
8c10b61e90 Fix findfiles not to fail on missing/invalid dir
Was failing on --dry-run and tests.
Added unit-test.
2020-07-23 15:16:40 +03:00
Rhet Turnbull
b7f4b739de Merge pull request #191 from RhetTbull/revert-190-Fix133
Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)"
2020-07-22 22:18:19 -07:00
Rhet Turnbull
f8e62d8f5e Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)" 2020-07-22 22:13:39 -07:00
Rhet Turnbull
da551036f9 Merge pull request #190 from PabloKohan/Fix133
Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)
2020-07-22 21:59:44 -07:00
Pablo 'merKur' Kohan
d52b387a29 Fix FileExistsError when filename differs only in case and export-as-hardlink
When exporting with --export-as-hardlink (and without --overwrite), an
exception is thrown in os.link (FileExistsError: [Errno 17] File exists)

This can happen if filenames differ only in case (on a case-insensitive
filesystem), so the similar filename is not incremented.

This fix uses `findfiles` (to glob in a case-insensitive way) instead of `glob`.
2020-07-22 22:20:48 +03:00
Rhet Turnbull
927e25911e Updated CHANGELOG.md 2020-07-18 16:21:50 -07:00
Rhet Turnbull
6688d1ff64 Updated dependencies, now supports py36, py37, py38 2020-07-18 07:42:52 -07:00
Rhet Turnbull
3526881ec8 Update README.md 2020-07-18 06:54:29 -07:00
Rhet Turnbull
3f19276c5c Implemented PersonInfo, closes #181 2020-07-17 22:06:37 -07:00
Rhet Turnbull
091e7b8f2e Updated CHANGELOG.md 2020-07-06 10:41:18 -07:00
Rhet Turnbull
1ef518cc3e Bug fix for empty albums 2020-07-06 10:35:54 -07:00
Rhet Turnbull
a934b692ab Updated CHANGELOG.md 2020-07-06 10:16:18 -07:00
Rhet Turnbull
9d820a0557 AlbumInfo.photos now returns photos in album sort order 2020-07-06 10:06:11 -07:00
Rhet Turnbull
fcff8ec5f8 Refactored person processing to enable implementation of #181 2020-07-06 00:10:22 -07:00
Rhet Turnbull
dfcbfa725a Updated CHANGELOG.md 2020-07-04 10:17:25 -07:00
Rhet Turnbull
df75a05645 Bug fix for keywords, persons in deleted photos 2020-07-04 09:54:43 -07:00
Rhet Turnbull
80f5989e2c Updated CHANGELOG.md 2020-07-03 12:31:18 -07:00
Rhet Turnbull
8c3af0a4e4 Added height, width, orientation, filesize to json, str) 2020-07-03 12:28:26 -07:00
Rhet Turnbull
4523224276 Updated CHANGELOG.md 2020-07-03 12:04:20 -07:00
Rhet Turnbull
541c390b7b Added height, width, orientation, filesize, closes #163 2020-07-03 11:24:59 -07:00
Rhet Turnbull
6ab0ad7e86 Added GPS location to XMP sidecar, closes #175 2020-07-03 09:04:23 -07:00
Rhet Turnbull
e5755c6144 Updated CHANGELOG.md 2020-06-28 21:54:36 -07:00
Rhet Turnbull
7806e05673 Updated README.md 2020-06-28 21:53:50 -07:00
Rhet Turnbull
bb4bc8fd96 Added --description-template to CLI, closes #166 2020-06-28 20:10:38 -07:00
Rhet Turnbull
59507077ba Updated README.md 2020-06-28 13:50:12 -07:00
Rhet Turnbull
ff0328785f Added expand_inplace to PhotoTemplate.render 2020-06-28 13:46:35 -07:00
Rhet Turnbull
3693d65b82 Added --deleted, --deleted-only to CLI, closes #179 2020-06-28 10:02:36 -07:00
Rhet Turnbull
6a85bd215a Updated CHANGELOG.md 2020-06-27 19:21:27 -07:00
Rhet Turnbull
ab36264af0 Changed default to PhotosDB.photos(movies=True), closes #177 2020-06-27 12:57:46 -07:00
Rhet Turnbull
185483e1aa added intrash support for issue #179 2020-06-27 10:54:25 -07:00
Rhet Turnbull
c1d12047bd Removed pdf filter on process_database_4 2020-06-26 20:04:53 -07:00
Rhet Turnbull
46c87eeed5 Added test for issue #178 2020-06-23 22:12:32 -07:00
Rhet Turnbull
fd4c99032d Additional fix for issue #178 2020-06-23 12:21:46 -07:00
Rhet Turnbull
d6fee89fd9 version bump 2020-06-23 12:07:07 -07:00
Rhet Turnbull
b8618cf272 Bug fix for issue #178 2020-06-23 12:01:20 -07:00
Rhet Turnbull
6b7c5d07fd Updated CHANGELOG.md 2020-06-22 07:22:19 -07:00
Rhet Turnbull
bd5ba702aa Closes #174 2020-06-22 07:14:10 -07:00
Rhet Turnbull
c8d76a89e4 Added today to template system, closes #167 2020-06-21 21:58:18 -07:00
Rhet Turnbull
a8e996e660 Minor refactoring in photoinfo.py 2020-06-21 12:06:25 -07:00
Rhet Turnbull
c68a5ab39f Updated CHANGELOG.md 2020-06-21 09:01:15 -07:00
Rhet Turnbull
1ebf995833 Bug fix for issue #172 2020-06-21 08:42:19 -07:00
Rhet Turnbull
538bac7ade More PhotoInfo.albums refactoring, closes #169 2020-06-21 08:18:11 -07:00
Rhet Turnbull
32806c8459 Updated CHANGELOG.md 2020-06-20 17:44:18 -07:00
Rhet Turnbull
cfabd0dbea Refactored album code in photosdb to fix issue #169 2020-06-20 17:31:33 -07:00
Rhet Turnbull
a23259948c Updated CHANGELOG.md 2020-06-20 08:43:42 -07:00
Rhet Turnbull
1212fad4ad Fixed PhotoInfo.albums, album_info for issue #169 2020-06-20 08:36:03 -07:00
Rhet Turnbull
567abe3311 Updated CHANGELOG.md 2020-06-18 22:52:21 -07:00
Rhet Turnbull
5a832181f7 Fixed get_last_library_path and get_system_library_path to not raise KeyError 2020-06-18 22:16:11 -07:00
Rhet Turnbull
4da57a1cee Merge pull request #168 from dethi/thibault/fix-exception-when-SystemLibraryPath-is-not-present
Don't raise KeyError when SystemLibraryPath is absent
2020-06-18 21:38:53 -07:00
Thibault Deutsch
1fd0f96b14 Don't raise KeyError when SystemLibraryPath is absent 2020-06-18 23:43:55 +01:00
Rhet Turnbull
e98c3fe429 Added show() to photos_repl.py 2020-06-16 22:46:46 -07:00
Rhet Turnbull
d77e9747cd Added check for export db in directory branch, closes #164 2020-06-14 17:51:57 -07:00
Rhet Turnbull
43d28e78f3 Added OSXPhotosDB.get_db_connection() 2020-06-14 12:52:23 -07:00
Rhet Turnbull
00bc50490e Updated CHANGELOG.md 2020-06-14 08:44:55 -07:00
Rhet Turnbull
f8743c33bd Updated CHANGELOG.md 2020-06-14 08:41:36 -07:00
Rhet Turnbull
937da9e617 Added computed aesthetic scores, closes #141, closes #122 2020-06-14 08:09:37 -07:00
Rhet Turnbull
435868a0a7 Updated CHANGELOG.md 2020-06-13 19:46:55 -07:00
Rhet Turnbull
d9802247d9 Added --label to CLI, closes #157 2020-06-13 19:40:46 -07:00
Rhet Turnbull
f39a92a352 Updated CHANGELOG.md 2020-06-13 15:11:10 -07:00
Rhet Turnbull
40dc7d32f2 Extende --ignore-case to --person, --keyword, --album, closes #162 2020-06-13 15:06:27 -07:00
Rhet Turnbull
4cd6c8f617 Updated CHANGELOG.md 2020-06-13 11:32:48 -07:00
Rhet Turnbull
0004250e74 Updated README.md to document template system 2020-06-13 10:52:18 -07:00
Rhet Turnbull
868ee7737b Added hour, min, sec, strftime templates, closes #158 2020-06-13 10:32:04 -07:00
Rhet Turnbull
5387f8e2f9 Added hour, min, sec to template system, issue #158 2020-06-13 09:17:34 -07:00
Rhet Turnbull
73b499f405 Updated CHANGELOG.md 2020-06-13 09:04:23 -07:00
Rhet Turnbull
06fa1edcae Bug fix for issue #136 2020-06-13 08:52:23 -07:00
Rhet Turnbull
cf2615da62 Updated DatetimeFormatter to include hour/min/sec 2020-06-13 08:32:56 -07:00
Rhet Turnbull
4ba1982d74 Added test for issue #156 2020-06-09 23:03:30 -07:00
Rhet Turnbull
abd10b73e8 Updated help text for debug-dump 2020-06-07 14:32:49 -07:00
Rhet Turnbull
7cd7b51598 Added hidden debug-dump command to CLI 2020-06-07 14:17:08 -07:00
Rhet Turnbull
801dc62c4b Updated CHANGELOG.md 2020-06-07 08:29:48 -07:00
Rhet Turnbull
72f034ef85 Fix for bug in handling of deleted albums to address issue #156 2020-06-07 08:14:02 -07:00
Rhet Turnbull
cb993f2e5e Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-06-06 12:12:05 -07:00
Rhet Turnbull
2271d89355 Partial fix for #155 2020-06-06 12:11:50 -07:00
Rhet Turnbull
62d096b5a1 Partial fix for #155 2020-06-06 12:09:51 -07:00
Rhet Turnbull
5c7a0c3a24 Refactoring with sourceryAI 2020-06-01 21:06:09 -07:00
Rhet Turnbull
ec727cc556 Updated CHANGELOG.md 2020-05-31 11:31:24 -07:00
Rhet Turnbull
6c84827ec7 Added --filename to CLI, closes #89 2020-05-31 11:25:34 -07:00
Rhet Turnbull
d47fd46a21 Updated CHANGELOG.md 2020-05-31 06:32:06 -07:00
Rhet Turnbull
7707bc1625 Added --edited-suffix to CLI, closes #145 2020-05-30 18:33:52 -07:00
Rhet Turnbull
fc1bf08cfa Removed pyinstaller 2020-05-30 14:43:29 -07:00
Rhet Turnbull
f35ea70b72 More refactoring in PhotoTemplate 2020-05-30 14:42:08 -07:00
Rhet Turnbull
16f802bf71 Refactored template code out of PhotoInfo into PhotoTemplate 2020-05-30 13:14:07 -07:00
Rhet Turnbull
8d2d5a8a33 refactored render_template, closes #149 2020-05-30 11:27:44 -07:00
Rhet Turnbull
3a8bef1572 Added test for SearchInfo on 10.15.5 2020-05-29 21:40:29 -07:00
Rhet Turnbull
fd71b13c77 performance improvements for SearchInfo 2020-05-29 20:30:20 -07:00
Rhet Turnbull
2243395bff Added test for Photos 5 on 10.15.5 2020-05-28 21:10:45 -07:00
Rhet Turnbull
42b89d34f3 performance improvements for update and export_db 2020-05-27 20:52:05 -07:00
Rhet Turnbull
9b11cbf32b Fixed searchinfo so processing can continue if no search DB found 2020-05-25 17:50:28 -07:00
Rhet Turnbull
8c3bf040c6 Update test library 2020-05-25 17:47:25 -07:00
Rhet Turnbull
c0855c7702 Updated CHANGELOG.md 2020-05-25 10:54:48 -07:00
Rhet Turnbull
288b1abc1c Updated CHANGELOG.md 2020-05-25 10:50:54 -07:00
Rhet Turnbull
9eae66030e Added --dry-run option to CLI export, closes #91 2020-05-25 10:37:30 -07:00
Rhet Turnbull
46fdc94398 Catch exception in folder processing to address #148 2020-05-24 07:22:00 -07:00
Rhet Turnbull
09c7d18901 Added test for DateTimeFormatter.dow 2020-05-24 06:37:15 -07:00
Rhet Turnbull
5f071b9c3f Merge pull request #147 from grundsch/add_dow
added created.dow (day of week) to template
2020-05-24 06:17:40 -07:00
Stephane
8df6d2c707 added created.dow (day of week) to template 2020-05-24 14:20:25 +02:00
Rhet Turnbull
28935b0af9 added created.dd and modified.dd to template system, closes #135 2020-05-23 21:39:40 -07:00
Rhet Turnbull
af750dd2e3 Updated CHANGELOG.md 2020-05-23 17:47:47 -07:00
Rhet Turnbull
1d095d7284 Added try/except for bad datettime values 2020-05-23 17:40:57 -07:00
Rhet Turnbull
f67f239278 Merge pull request #146 from agprimatic/patch-1
Catch illegal timestamp value
2020-05-23 16:06:23 -07:00
Ag Primatic
441de711dc Catch illegal timestamp value
When using this on my Photos database, it threw a ValueError. I wrapped the fromtimestamp() call in a try block so that it would continue with the oldest date allowable.
2020-05-23 18:15:45 -04:00
Rhet Turnbull
1450b3ccac Updated CHANGELOG.md 2020-05-23 10:03:17 -07:00
Rhet Turnbull
c06c230a46 version bump 2020-05-23 09:37:10 -07:00
Rhet Turnbull
b1171e96cc Added --update to CLI export; reference issue #100 2020-05-23 09:34:04 -07:00
Rhet Turnbull
8c4fe40aa6 Added as_dict to PlaceInfo 2020-05-23 08:47:10 -07:00
Rhet Turnbull
f416418546 Test library update 2020-05-16 06:53:03 -07:00
Rhet Turnbull
8e9691d6d7 Made --exiftool and --export-as-hardlink incompatible in CLI to fix #132 2020-05-16 06:48:23 -07:00
Rhet Turnbull
cafa483cfc Updated CHANGELOG.md 2020-05-15 14:41:50 -07:00
Rhet Turnbull
11d368a69c Updated README.md 2020-05-15 14:41:22 -07:00
Rhet Turnbull
bd9d5a26f3 version bump 2020-05-15 14:12:40 -07:00
Rhet Turnbull
ec19636cbf move template.py to photoinfo 2020-05-15 14:06:27 -07:00
Rhet Turnbull
48e9c32add Revert "test library updates"
This reverts commit d125854f2a.
2020-05-15 14:05:36 -07:00
Rhet Turnbull
b455bd4c4a Added label and label_normalized to template system, closes #130 2020-05-15 13:57:12 -07:00
Rhet Turnbull
d125854f2a test library updates 2020-05-15 13:46:45 -07:00
Rhet Turnbull
e228cfab74 Updated CHANGELOG.md 2020-05-14 14:45:07 -07:00
Rhet Turnbull
85760dc4fe Update README.md 2020-05-14 14:43:50 -07:00
Rhet Turnbull
be07f90e5a Update README.md 2020-05-14 14:43:22 -07:00
Rhet Turnbull
a80dee401c Implemented PhotoInfo.exiftool 2020-05-14 12:55:17 -07:00
Rhet Turnbull
e67fce2871 Updated CHANGELOG.md 2020-05-14 06:46:12 -07:00
Rhet Turnbull
53304d7023 Added ExifInfo (Photos 5 only) 2020-05-13 22:42:33 -07:00
Rhet Turnbull
d1af14dbb4 Added as_dict to ExifTool 2020-05-11 05:07:19 -07:00
Rhet Turnbull
ca8f2b8d5c Added link to original work by @simonw 2020-05-10 22:05:21 -05:00
Rhet Turnbull
e3e5a20681 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-05-10 19:55:54 -07:00
Rhet Turnbull
98b3f63a92 Refactored photosdb and photoinfo to add SearchInfo and labels 2020-05-10 19:55:09 -07:00
Rhet Turnbull
c8128203f4 removed flake8 2020-05-10 11:09:35 -05:00
Rhet Turnbull
c061161605 Added pytest-mock 2020-05-10 11:06:45 -05:00
Rhet Turnbull
397db0d72f Updated a couple of tests to use pytest-mock 2020-05-10 09:00:56 -07:00
Rhet Turnbull
605d63aa4f Updated README.md to add link to wiki 2020-05-09 13:39:10 -05:00
Rhet Turnbull
ac9b6d52a3 Update README.md 2020-05-09 10:27:44 -05:00
Rhet Turnbull
57315d4449 Added additional test for --export-as-hardlink 2020-05-08 15:53:20 -07:00
Rhet Turnbull
49cfd0b93e Merge pull request #127 from britiscurious/master
fixed some minor findings...
2020-05-08 17:35:40 -05:00
britiscurious
b15f744aab Update cli.py
correction of the name of the shell script to build an executable
2020-05-09 00:06:04 +02:00
britiscurious
cc8b10e792 Update README.md
fixed minor typo
2020-05-09 00:03:58 +02:00
Rhet Turnbull
93835130c2 Update README.md to include GitHub Workflow status 2020-05-08 16:50:03 -05:00
Rhet Turnbull
df13683d11 Merge pull request #126 from britiscurious/master
added --export-as-hardlink option
2020-05-08 16:40:11 -05:00
britiscurious
b0ec6c6b36 added test for export using hardlinks, fixed a test that failed if users locale settings were different to en_US 2020-05-08 23:29:18 +02:00
britiscurious
cb124713d6 version increment after adding --export-as-hardlink option 2020-05-08 23:06:32 +02:00
britiscurious
97d3c69ade Update osxphotos/utils.py
Co-authored-by: Rhet Turnbull <rturnbull@gmail.com>
2020-05-08 22:56:43 +02:00
britiscurious
a8622b6b90 Update osxphotos/utils.py
Co-authored-by: Rhet Turnbull <rturnbull@gmail.com>
2020-05-08 22:56:24 +02:00
britiscurious
cceab62993 Update osxphotos/utils.py
Co-authored-by: Rhet Turnbull <rturnbull@gmail.com>
2020-05-08 22:56:12 +02:00
britiscurious
69356c0b57 Update osxphotos/utils.py
Co-authored-by: Rhet Turnbull <rturnbull@gmail.com>
2020-05-08 22:55:56 +02:00
britiscurious
5eb0876e33 added --export-as-hardlink option 2020-05-08 21:13:50 +02:00
Rhet Turnbull
7444b6d173 Updated test for 10.15.4 2020-05-02 07:39:34 -07:00
Rhet Turnbull
180450c1e7 Added test for folder_names on 10.15.4, closes #119 2020-05-02 07:33:15 -07:00
Rhet Turnbull
00e16611fc added CHANGELOG.md 2020-05-01 22:32:05 -07:00
Rhet Turnbull
65674f57bc added --keyword-template 2020-05-01 22:05:46 -07:00
Rhet Turnbull
7af1ccd4ed Fixed bug related to issue #119 2020-04-30 21:38:24 -07:00
Rhet Turnbull
1b6f661e6b test library updates 2020-04-30 13:02:11 -07:00
Rhet Turnbull
a57da2346b Bug fix for albums in Photos <= 4 to address issue #116 2020-04-28 18:20:26 -07:00
Rhet Turnbull
3fe03cd127 version bump for pypi 2020-04-28 07:54:22 -07:00
Rhet Turnbull
5cc98c338b Update README.md 2020-04-28 07:48:54 -07:00
Rhet Turnbull
1c9d4f282b Update README.md 2020-04-28 07:44:00 -07:00
Rhet Turnbull
1ceda15134 Fixed implementation of use_albums_as_keywords and use_persons_as_keywords, closes #115 2020-04-28 07:41:37 -07:00
Rhet Turnbull
a80071111f Updated README.md 2020-04-28 07:10:48 -07:00
Rhet Turnbull
072a8d795e Updated CHANGELOG.md 2020-04-27 23:16:31 -07:00
Rhet Turnbull
b35b071634 Added --album-keyword and --person-keyword to CLI, closes #61 2020-04-27 23:08:59 -07:00
Rhet Turnbull
56a000609f Updated tests/README.md 2020-04-26 16:31:24 -07:00
Rhet Turnbull
54d5d4b7ba Updated test libraries 2020-04-26 16:04:03 -07:00
Rhet Turnbull
38137a1351 Updated CHANGELOG.md 2020-04-26 16:03:26 -07:00
Rhet Turnbull
4b29a2e05f Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-04-26 15:57:51 -07:00
Rhet Turnbull
9be0f849b7 Updated test to avoid issue with GitHub workflow 2020-04-26 15:57:43 -07:00
Rhet Turnbull
ccb5f252d1 Update pythonpackage.yml to remove older pythons 2020-04-26 15:39:37 -07:00
Rhet Turnbull
d8a64c9573 Fixed locale bug in templates, closes #113 2020-04-26 15:20:28 -07:00
Rhet Turnbull
81d4e392c3 Updated CHANGELOG.md 2020-04-20 22:22:08 -07:00
Rhet Turnbull
85d2baac10 Updated setup.py and README with install instructions 2020-04-20 22:13:42 -07:00
Rhet Turnbull
8a768e62ce Still working on bpylist2 install error 2020-04-20 21:35:12 -07:00
Rhet Turnbull
1c8eb764f5 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-04-20 21:21:54 -07:00
Rhet Turnbull
8e4b88ad1f Updated setup.py to resolve issue with bpylist2 on python < 3.8 2020-04-20 21:21:47 -07:00
Rhet Turnbull
3f80f786a3 Update README.md to clarify install instructions 2020-04-20 08:01:09 -07:00
Rhet Turnbull
a337e79e13 added raw_is_original handling 2020-04-19 19:16:43 -07:00
Rhet Turnbull
ec68feec49 Removed warning from path_raw 2020-04-19 18:39:53 -07:00
Rhet Turnbull
9b9b54e590 Updated tests and test library with RAW images 2020-04-19 18:24:24 -07:00
Rhet Turnbull
22f1e8f2a6 Updated CHANGELOG.md 2020-04-19 00:04:47 -07:00
Rhet Turnbull
1867c1d747 added __len__ to PhotosDB, closes #44 2020-04-18 23:57:34 -07:00
Rhet Turnbull
87eb84fddd Updated use of _PHOTOS_4_VERSION, closes #106 2020-04-18 23:33:02 -07:00
Rhet Turnbull
15a3736b74 Fixed documentation error 2020-04-18 23:10:13 -07:00
Rhet Turnbull
cf28cb6452 Added cli.py for use with pyinstaller 2020-04-18 18:34:09 -07:00
Rhet Turnbull
f20fadcef7 Fixed some stray tabs 2020-04-18 13:38:37 -07:00
Rhet Turnbull
3bac106eb7 test library update 2020-04-18 12:24:03 -07:00
Rhet Turnbull
47d1c82c03 Added folder support for Photos <= 4, closes #93 2020-04-18 12:21:08 -07:00
Rhet Turnbull
6f281711e2 cleaned up SQL statements in _process_database4 2020-04-18 08:05:43 -07:00
Rhet Turnbull
4b30b3b426 Fixed suffix check on export to be case insensitive 2020-04-18 07:59:04 -07:00
Rhet Turnbull
1fa9583ea6 Updated CHANGELOG.md 2020-04-17 23:33:17 -07:00
Rhet Turnbull
235e1fb1a6 Updated README.md 2020-04-17 23:23:57 -07:00
Rhet Turnbull
36c2821a0f replaced CLI option --original-name with --current-name 2020-04-17 23:20:23 -07:00
Rhet Turnbull
ed425724a0 Changed default CLI behavior to export all photos 2020-04-17 22:52:11 -07:00
Rhet Turnbull
55daa31c71 Update README.md 2020-04-17 12:46:56 -07:00
Rhet Turnbull
b6ac9e1ea3 Updated test library for Sierra 2020-04-17 11:52:55 -07:00
Rhet Turnbull
9d151478d6 Initial support for RAW photos in Photos 4 to address issue #101 2020-04-17 10:44:15 -07:00
Rhet Turnbull
7d55844390 Added --export-raw to CLI export 2020-04-16 23:10:33 -07:00
Rhet Turnbull
f398e9116f Added --has-raw to CLI query and export 2020-04-16 16:46:29 -07:00
Rhet Turnbull
4fe8190b57 Added raw details to PhotoInfo json() and __str__() 2020-04-16 16:11:47 -07:00
Rhet Turnbull
7e42ebb240 Initial work on suppport for associated RAW images 2020-04-16 11:53:48 -07:00
Rhet Turnbull
edae116baa Test library update 2020-04-16 11:53:05 -07:00
Rhet Turnbull
d542cda17d Small fix to database version logic to look into issue #102 2020-04-15 14:09:47 -07:00
Rhet Turnbull
99b5b54c6d Update README.md 2020-04-15 11:38:57 -07:00
Rhet Turnbull
379feddcda Updated README.md to add Known Bugs section 2020-04-15 11:37:38 -07:00
Rhet Turnbull
24285f5dd2 Update README.md 2020-04-13 00:53:50 -07:00
Rhet Turnbull
3cb3ebb300 Updated CHANGELOG.md 2020-04-13 00:46:08 -07:00
Rhet Turnbull
16037f10fa Update README.md 2020-04-12 23:50:04 -07:00
Rhet Turnbull
ebd21491ac Updated examples to work with latest version 2020-04-12 20:57:37 -07:00
Rhet Turnbull
b7c7b9f066 Added {folder_album} to template and --folder to CLI 2020-04-12 14:53:53 -07:00
Rhet Turnbull
21e7020fec Test library update 2020-04-12 14:52:35 -07:00
Rhet Turnbull
952741d488 Updated CHANGELOG.md 2020-04-12 12:27:49 -07:00
Rhet Turnbull
9fef12ed37 Fixed bug with handling of deleted albums 2020-04-12 12:15:38 -07:00
Rhet Turnbull
97362fc0f1 Added additional tests for album_info 2020-04-12 09:22:14 -07:00
Rhet Turnbull
e09f0b40f1 Changed AlbumInfo and FolderInfo interface to maintain backwards compatibility with PhotosDB.albums 2020-04-12 09:01:16 -07:00
Rhet Turnbull
b749681c6d Updated CHANGELOG.md 2020-04-11 14:33:28 -07:00
Rhet Turnbull
8544667c72 Update README.md TOC 2020-04-11 14:20:05 -07:00
Rhet Turnbull
d6a22b765a Added tests and README for AlbumInfo and FolderInfo 2020-04-11 14:07:39 -07:00
Rhet Turnbull
96365728c2 Added albuminfo.py for AlbumInfo and FolderInfo classes 2020-04-11 10:28:50 -07:00
Rhet Turnbull
c01f713f00 Merge pull request #95 from jystervinou/patch-2
Update README.md
2020-04-11 06:53:58 -07:00
Jean-Yves Stervinou
1aa3838c38 Update README.md
just some typos fixes
- packge/package
- the copy then read can take => the copy read then can take
2020-04-11 11:34:17 +02:00
Rhet Turnbull
cde56e9d13 Updated CHANGELOG.md 2020-04-10 18:56:22 -07:00
Rhet Turnbull
1c9da5ed6f Bug fix for PhotosDB.photos() query 2020-04-10 18:50:58 -07:00
Rhet Turnbull
d74f7f499b Updated test library 2020-04-10 17:58:45 -07:00
Rhet Turnbull
c85bb02304 Updated CHANGELOG.md 2020-04-10 17:35:23 -07:00
Rhet Turnbull
3e5062684a Changed PhotosDB albums interface as prep for adding folders 2020-04-10 17:30:37 -07:00
Rhet Turnbull
626e460aab Update README.md 2020-04-06 07:24:20 -07:00
Rhet Turnbull
1820715849 Added test for 10.15.4 2020-04-05 22:57:05 -07:00
Rhet Turnbull
a6ca3f453c Updated CHANGELOG.md 2020-04-05 09:17:27 -07:00
Rhet Turnbull
ddaa66d19e Added --no-extended-attributes option to CLI, closes #85 2020-04-05 09:13:52 -07:00
Rhet Turnbull
6073acc9d3 Fixed CLI help for invalid topic, closes #76 2020-04-05 08:25:30 -07:00
Rhet Turnbull
bae0283441 Updated test library 2020-04-05 07:54:26 -07:00
Rhet Turnbull
507c4a3740 Added {album}, {keyword}, and {person} to template system 2020-04-04 13:58:54 -07:00
Rhet Turnbull
6a898886dd Updated render_filepath_template to support multiple values 2020-04-04 09:53:23 -07:00
Rhet Turnbull
01cd7fed6d Updated export example 2020-04-01 06:47:51 -07:00
Rhet Turnbull
e8273c9752 Added places, --place, --no-place to CLI, closes #87, #88 2020-03-31 20:39:13 -07:00
Rhet Turnbull
fd5e748dca Added places command to CLI 2020-03-29 23:03:12 -07:00
Rhet Turnbull
c02953ef5f Fixed typo in help text 2020-03-28 10:26:10 -07:00
Rhet Turnbull
daea30f162 Updated CHANGELOG.md 2020-03-28 10:21:10 -07:00
Rhet Turnbull
be2e16769d added {place.country_code} to template system 2020-03-28 10:18:58 -07:00
Rhet Turnbull
b0456dc8e6 Update TOC 2020-03-28 10:02:08 -07:00
Rhet Turnbull
c8bd8ea2f3 Test library update 2020-03-28 09:59:05 -07:00
Rhet Turnbull
67a9a9e21b Template system now supports default values 2020-03-28 09:57:48 -07:00
Rhet Turnbull
427c4c0bc4 Replaced template renderer with regex-based renderer 2020-03-28 07:58:50 -07:00
Rhet Turnbull
f0d200435a Fixed comment 2020-03-28 07:58:03 -07:00
Rhet Turnbull
49de3ecd2e test library updates 2020-03-28 07:24:45 -07:00
Rhet Turnbull
c06dd4233f Added detailed place data in PlaceInfo.names 2020-03-28 07:24:17 -07:00
Rhet Turnbull
fd638427d0 added missing import 2020-03-27 20:49:38 -07:00
Rhet Turnbull
6fb8fe8142 test library update 2020-03-25 17:56:59 -07:00
Rhet Turnbull
69cc6ce680 Updated place name processing for Photos 4 2020-03-25 17:56:39 -07:00
Rhet Turnbull
dfc31ff15f Type fix in help text 2020-03-23 19:24:44 -07:00
Rhet Turnbull
707544752e Removed template functions pending re-work of that code 2020-03-23 17:55:33 -07:00
Rhet Turnbull
564a5073f1 Updated README.md to document template system 2020-03-22 14:15:08 -07:00
Rhet Turnbull
d769dde358 version bump 2020-03-22 13:07:34 -07:00
Rhet Turnbull
d066435e3d Updated pathvalidate calls 2020-03-22 13:04:00 -07:00
Rhet Turnbull
8f0307fc24 Updated example 2020-03-22 12:55:24 -07:00
Rhet Turnbull
908fead8a2 Added export_by_album.py to examples 2020-03-22 11:37:25 -07:00
Rhet Turnbull
072e894e56 Updated CHANGELOG.md 2020-03-22 09:54:45 -07:00
Rhet Turnbull
47e57ee98e Updated dependencies 2020-03-22 09:54:10 -07:00
Rhet Turnbull
e90d9c6e11 Test library updates 2020-03-22 09:46:15 -07:00
Rhet Turnbull
2feb0999b3 Initial version of templating system for CLI 2020-03-22 09:45:56 -07:00
Rhet Turnbull
d26ea0dccc Fixed unnecessary warning exporting .JPG to .jpeg 2020-03-22 09:02:10 -07:00
Rhet Turnbull
aeae1e0b8a Fixed unnecessary warning exporting .JPG to .jpeg 2020-03-22 08:53:54 -07:00
Rhet Turnbull
128a35d6c0 Updated README.md dependencies and related projects 2020-03-21 19:22:56 -07:00
Rhet Turnbull
57d9163090 Started adding hooks for processing moments 2020-03-21 18:43:41 -07:00
Rhet Turnbull
a236ed42c1 Updated comments 2020-03-21 18:11:09 -07:00
Rhet Turnbull
ad58b03f2d Added __str__ to place 2020-03-21 17:38:30 -07:00
Rhet Turnbull
066215621d Updated requirements.txt 2020-03-21 15:42:12 -07:00
Rhet Turnbull
7f0558e08b Updated requirements.txt 2020-03-21 15:40:23 -07:00
Rhet Turnbull
4441d071b3 Added python 3.8 2020-03-21 14:29:47 -07:00
Rhet Turnbull
9da7ad6dcc Updated requirements.txt 2020-03-21 14:09:35 -07:00
Rhet Turnbull
91f71df07d version bump 2020-03-21 13:45:43 -07:00
Rhet Turnbull
9e314bfaf4 Updated requirements.txt 2020-03-21 13:41:11 -07:00
Rhet Turnbull
0948b24821 Updated requirements.txt 2020-03-21 13:39:14 -07:00
Rhet Turnbull
4b951826db Updated requirements.txt 2020-03-21 13:28:27 -07:00
Rhet Turnbull
cda5f44693 Fixed requirements.txt for bplist2 2020-03-21 12:51:27 -07:00
Rhet Turnbull
960487f296 still trying to debug github actions fail 2020-03-21 11:21:38 -07:00
Rhet Turnbull
6ab1511b4f Added pycodestyle needed by bpylist2 2020-03-21 11:15:39 -07:00
Rhet Turnbull
b8da9765b8 Updated CHANGELOG.md 2020-03-21 11:09:41 -07:00
Rhet Turnbull
21547a8eaa Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-03-21 11:05:49 -07:00
Rhet Turnbull
23b26ed130 fixed version of bpylist2 2020-03-21 11:05:39 -07:00
Rhet Turnbull
92e5bdd2e9 Update pythonpackage.yml 2020-03-21 10:48:49 -07:00
Rhet Turnbull
a723881dd3 Removed flake8 2020-03-21 10:45:47 -07:00
Rhet Turnbull
b338b34d50 Added PhotoInfo.place for reverse geolocation data 2020-03-21 10:39:42 -07:00
Rhet Turnbull
816b98e617 Updated CHANGELOG.md 2020-03-15 10:15:58 -07:00
Rhet Turnbull
39ffef502c Updated README.md 2020-03-15 10:13:41 -07:00
Rhet Turnbull
1e08a7449e test library update 2020-03-15 10:09:09 -07:00
Rhet Turnbull
0940f039d3 Lots of work on export code 2020-03-15 10:08:56 -07:00
Rhet Turnbull
c11afbaa6e Updated docs 2020-03-14 20:54:53 -07:00
Rhet Turnbull
940fc33f11 test library update 2020-03-14 20:11:04 -07:00
Rhet Turnbull
8542e1a97f Working on export edited bug for issue #78 2020-03-14 20:07:05 -07:00
Rhet Turnbull
dd20b8d8ac Fixed download-missing to only download when actually missing 2020-03-14 13:40:15 -07:00
Rhet Turnbull
765a3d27c5 fixed pylint warning 2020-03-14 12:15:35 -07:00
Rhet Turnbull
b68f4c2b8b removed OBE TODO 2020-03-14 12:06:50 -07:00
Rhet Turnbull
cc9220e076 Updated CHANGELOG.md 2020-03-14 12:01:38 -07:00
Rhet Turnbull
e99391a68e test library updates 2020-03-14 12:00:59 -07:00
Rhet Turnbull
783e097da3 version bump 2020-03-14 09:13:04 -07:00
Rhet Turnbull
279ab36929 Added MANIFEST.in 2020-03-14 09:10:05 -07:00
Rhet Turnbull
1f13ba837f Fixed bug in --download-missing related to burst images 2020-03-14 08:54:46 -07:00
Rhet Turnbull
dc87194eec Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-03-14 07:13:30 -07:00
Rhet Turnbull
d32774f495 Moved util scripts to utils 2020-03-14 07:13:17 -07:00
Rhet Turnbull
7da02991cf Moved util scripts to utils 2020-03-14 07:11:19 -07:00
Rhet Turnbull
6f413c64d7 removed activate from --download-missing-photos Applescript, closes #69 2020-03-14 06:58:24 -07:00
Rhet Turnbull
2d7d0b86e0 Test library updates 2020-03-14 06:43:14 -07:00
Rhet Turnbull
acb6b9e72f test library update 2020-03-13 20:36:51 -07:00
Rhet Turnbull
f1ade92e98 Added media type specials to json and string output, closes #68 2020-03-12 20:11:59 -07:00
Rhet Turnbull
a27ce33473 README.md update 2020-03-10 22:12:47 -07:00
Rhet Turnbull
2b7d84a4d1 Added query/export options for special media types 2020-03-09 22:17:49 -07:00
Rhet Turnbull
92b405a166 Updated CHANGELOG.md 2020-03-08 13:04:39 -07:00
Rhet Turnbull
15d7ad538d Added media type specials, closes #60 2020-03-08 12:52:44 -07:00
Rhet Turnbull
1f8fd6e929 Updated README.md 2020-03-07 14:56:46 -08:00
Rhet Turnbull
08a9793651 Updated CHANGELOG.md 2020-03-07 14:53:24 -08:00
Rhet Turnbull
2c8fc9789f Added check for exiftool in path 2020-03-07 14:50:16 -08:00
Rhet Turnbull
dbededcd0e Test database update 2020-03-07 14:37:29 -08:00
Rhet Turnbull
ef799610ae Added --exiftool to CLI export 2020-03-07 14:37:11 -08:00
Rhet Turnbull
8dea41961b Added exiftool 2020-03-07 09:50:30 -08:00
Rhet Turnbull
5799afbdc1 Updated TODO 2020-03-07 09:13:48 -08:00
Rhet Turnbull
9a0fc0db3e Updated test library 2020-03-07 09:13:09 -08:00
Rhet Turnbull
549170fa36 test library updates 2020-02-09 09:30:15 -08:00
Rhet Turnbull
dede640ef3 test library updates 2020-02-08 07:37:31 -08:00
Rhet Turnbull
2b3491bdc4 Updated CHANGELOG.md 2020-02-08 07:34:51 -08:00
1225 changed files with 27283 additions and 3411 deletions

View File

@@ -1,6 +1,6 @@
name: Python package
name: Tests
on: [push]
on: [push, pull_request]
jobs:
build:
@@ -9,7 +9,7 @@ jobs:
strategy:
max-parallel: 4
matrix:
python-version: [3.6, 3.7]
python-version: [3.7, 3.8]
steps:
- uses: actions/checkout@v1
@@ -21,8 +21,8 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint with flake8
run: |
# - name: Lint with flake8
# run: |
# pip install flake8
# stop the build if there are Python syntax errors or undefined names
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
@@ -31,4 +31,5 @@ jobs:
- name: Test with pytest
run: |
pip install pytest
pip install pytest-mock
python -m pytest tests/

View File

@@ -4,6 +4,524 @@ 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.30.13](https://github.com/RhetTbull/osxphotos/compare/v0.30.12...v0.30.13)
> 23 July 2020
- This reverts commit b7f4b739de978991def8ae2dca0f4e4b2881f56d, reversing [`#191`](https://github.com/RhetTbull/osxphotos/pull/191)
- Fix findfiles not to fail on missing/invalid dir [`#192`](https://github.com/RhetTbull/osxphotos/pull/192)
- Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)" [`#191`](https://github.com/RhetTbull/osxphotos/pull/191)
- Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133) [`#190`](https://github.com/RhetTbull/osxphotos/pull/190)
- Fix findfiles not to fail on missing/invalid dir [`8c10b61`](https://github.com/RhetTbull/osxphotos/commit/8c10b61e90abbcfdff472bad4bb760558c7b850c)
- Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)" [`f8e62d8`](https://github.com/RhetTbull/osxphotos/commit/f8e62d8f5ed26814f02383426237fd4c99a7ad04)
- Fix FileExistsError when filename differs only in case and export-as-hardlink [`d52b387`](https://github.com/RhetTbull/osxphotos/commit/d52b387a294e68ebf0580a202ea70b97205560ef)
- Version bump for bug fix [`cf4dca1`](https://github.com/RhetTbull/osxphotos/commit/cf4dca10c02d5f3f6132ab1572a698379b667e48)
#### [v0.30.12](https://github.com/RhetTbull/osxphotos/compare/v0.30.10...v0.30.12)
> 18 July 2020
- Implemented PersonInfo, closes #181 [`#181`](https://github.com/RhetTbull/osxphotos/issues/181)
- Updated dependencies, now supports py36, py37, py38 [`6688d1f`](https://github.com/RhetTbull/osxphotos/commit/6688d1ff6491f2e7e155946b265ef8b5d8929441)
- Update README.md [`3526881`](https://github.com/RhetTbull/osxphotos/commit/3526881ec872cc009b0d8936f366afcfff166d42)
#### [v0.30.10](https://github.com/RhetTbull/osxphotos/compare/v0.30.9...v0.30.10)
> 6 July 2020
- Bug fix for empty albums [`1ef518c`](https://github.com/RhetTbull/osxphotos/commit/1ef518cc3e9efbe9d4c16aa3d36c6dc6db86798e)
#### [v0.30.9](https://github.com/RhetTbull/osxphotos/compare/v0.30.7...v0.30.9)
> 6 July 2020
- Refactored person processing to enable implementation of #181 [`fcff8ec`](https://github.com/RhetTbull/osxphotos/commit/fcff8ec5f8286b28e7d8559b40b5808a7b59cc15)
- AlbumInfo.photos now returns photos in album sort order [`9d820a0`](https://github.com/RhetTbull/osxphotos/commit/9d820a0557944340d0c664a6c3497d138c6100d5)
#### [v0.30.7](https://github.com/RhetTbull/osxphotos/compare/v0.30.6...v0.30.7)
> 4 July 2020
- Bug fix for keywords, persons in deleted photos [`df75a05`](https://github.com/RhetTbull/osxphotos/commit/df75a05645a88b31daa411f960d99ade71efc908)
#### [v0.30.6](https://github.com/RhetTbull/osxphotos/compare/v0.30.5...v0.30.6)
> 3 July 2020
- Added height, width, orientation, filesize to json, str) [`8c3af0a`](https://github.com/RhetTbull/osxphotos/commit/8c3af0a4e4e49d9bbb33e809973d958334e44dca)
#### [v0.30.5](https://github.com/RhetTbull/osxphotos/compare/v0.30.4...v0.30.5)
> 3 July 2020
- Added height, width, orientation, filesize, closes #163 [`#163`](https://github.com/RhetTbull/osxphotos/issues/163)
#### [v0.30.4](https://github.com/RhetTbull/osxphotos/compare/v0.30.3...v0.30.4)
> 3 July 2020
- Added GPS location to XMP sidecar, closes #175 [`#175`](https://github.com/RhetTbull/osxphotos/issues/175)
- Updated README.md [`7806e05`](https://github.com/RhetTbull/osxphotos/commit/7806e05673775ded231e65f53f3a1d5095a4b4e1)
#### [v0.30.3](https://github.com/RhetTbull/osxphotos/compare/v0.30.2...v0.30.3)
> 29 June 2020
- Added --description-template to CLI, closes #166 [`#166`](https://github.com/RhetTbull/osxphotos/issues/166)
- Added expand_inplace to PhotoTemplate.render [`ff03287`](https://github.com/RhetTbull/osxphotos/commit/ff0328785f3ea14b1c8ae2b7d1a9b07e8aef0777)
- Updated README.md [`5950707`](https://github.com/RhetTbull/osxphotos/commit/59507077bafe39a17bc23babe6d6c52e1f502a53)
#### [v0.30.2](https://github.com/RhetTbull/osxphotos/compare/v0.30.1...v0.30.2)
> 28 June 2020
- Added --deleted, --deleted-only to CLI, closes #179 [`#179`](https://github.com/RhetTbull/osxphotos/issues/179)
#### [v0.30.1](https://github.com/RhetTbull/osxphotos/compare/v0.30.0...v0.30.1)
> 27 June 2020
- Changed default to PhotosDB.photos(movies=True), closes #177 [`#177`](https://github.com/RhetTbull/osxphotos/issues/177)
#### [v0.30.0](https://github.com/RhetTbull/osxphotos/compare/v0.29.30...v0.30.0)
> 27 June 2020
- added intrash support for issue #179 [`185483e`](https://github.com/RhetTbull/osxphotos/commit/185483e1aa9ed107402bfb178f264417e6926b46)
- Removed pdf filter on process_database_4 [`c1d1204`](https://github.com/RhetTbull/osxphotos/commit/c1d12047bde84740b96c8531110e7b2d2fe41f2e)
#### [v0.29.30](https://github.com/RhetTbull/osxphotos/compare/v0.29.29...v0.29.30)
> 24 June 2020
- Added test for issue #178 [`46c87ee`](https://github.com/RhetTbull/osxphotos/commit/46c87eeed56d5765317dec4992d2e16323c711ad)
- Additional fix for issue #178 [`fd4c990`](https://github.com/RhetTbull/osxphotos/commit/fd4c99032dbbedd6325aabacb0bc800b24ede413)
#### [v0.29.29](https://github.com/RhetTbull/osxphotos/compare/v0.29.28...v0.29.29)
> 23 June 2020
- version bump [`d6fee89`](https://github.com/RhetTbull/osxphotos/commit/d6fee89fd9dd07c4788562ed551d0a3f2b5d697d)
- Bug fix for issue #178 [`b8618cf`](https://github.com/RhetTbull/osxphotos/commit/b8618cf272efc174b7fa872f233b561bd9e7243e)
#### [v0.29.28](https://github.com/RhetTbull/osxphotos/compare/v0.29.26...v0.29.28)
> 22 June 2020
- Closes #174 [`#174`](https://github.com/RhetTbull/osxphotos/issues/174)
- Added today to template system, closes #167 [`#167`](https://github.com/RhetTbull/osxphotos/issues/167)
- Minor refactoring in photoinfo.py [`a8e996e`](https://github.com/RhetTbull/osxphotos/commit/a8e996e66072e94de93fd4ea78a456bc61831f52)
#### [v0.29.26](https://github.com/RhetTbull/osxphotos/compare/v0.29.25...v0.29.26)
> 21 June 2020
- Bug fix for issue #172 [`1ebf995`](https://github.com/RhetTbull/osxphotos/commit/1ebf99583397617f0d3a234c898beae1c14f5a63)
#### [v0.29.25](https://github.com/RhetTbull/osxphotos/compare/v0.29.24...v0.29.25)
> 21 June 2020
- More PhotoInfo.albums refactoring, closes #169 [`#169`](https://github.com/RhetTbull/osxphotos/issues/169)
#### [v0.29.24](https://github.com/RhetTbull/osxphotos/compare/v0.29.23...v0.29.24)
> 21 June 2020
- Refactored album code in photosdb to fix issue #169 [`cfabd0d`](https://github.com/RhetTbull/osxphotos/commit/cfabd0dbead62c8ab6a774899239e5da5bfe1203)
#### [v0.29.23](https://github.com/RhetTbull/osxphotos/compare/v0.29.22...v0.29.23)
> 20 June 2020
- Fixed PhotoInfo.albums, album_info for issue #169 [`1212fad`](https://github.com/RhetTbull/osxphotos/commit/1212fad4adde0b4c6b2887392eed829d8d96d61d)
#### [v0.29.22](https://github.com/RhetTbull/osxphotos/compare/v0.29.19...v0.29.22)
> 19 June 2020
- Don't raise KeyError when SystemLibraryPath is absent [`#168`](https://github.com/RhetTbull/osxphotos/pull/168)
- Added check for export db in directory branch, closes #164 [`#164`](https://github.com/RhetTbull/osxphotos/issues/164)
- Added OSXPhotosDB.get_db_connection() [`43d28e7`](https://github.com/RhetTbull/osxphotos/commit/43d28e78f394fa33f8d88f64b56b7dc7258cd454)
- Added show() to photos_repl.py [`e98c3fe`](https://github.com/RhetTbull/osxphotos/commit/e98c3fe42912ac16d13675bf14154981089d41ea)
- Fixed get_last_library_path and get_system_library_path to not raise KeyError [`5a83218`](https://github.com/RhetTbull/osxphotos/commit/5a832181f73e082927c80864f2063e554906b06b)
- Don't raise KeyError when SystemLibraryPath is absent [`1fd0f96`](https://github.com/RhetTbull/osxphotos/commit/1fd0f96b14f0bc38e47bddb4cae12e19406324fb)
#### [v0.29.19](https://github.com/RhetTbull/osxphotos/compare/v0.29.18...v0.29.19)
> 14 June 2020
- Added computed aesthetic scores, closes #141, closes #122 [`#141`](https://github.com/RhetTbull/osxphotos/issues/141) [`#122`](https://github.com/RhetTbull/osxphotos/issues/122)
#### [v0.29.18](https://github.com/RhetTbull/osxphotos/compare/v0.29.17...v0.29.18)
> 14 June 2020
- Added --label to CLI, closes #157 [`#157`](https://github.com/RhetTbull/osxphotos/issues/157)
#### [v0.29.17](https://github.com/RhetTbull/osxphotos/compare/v0.29.16...v0.29.17)
> 13 June 2020
- Extende --ignore-case to --person, --keyword, --album, closes #162 [`#162`](https://github.com/RhetTbull/osxphotos/issues/162)
- Updated README.md to document template system [`0004250`](https://github.com/RhetTbull/osxphotos/commit/0004250e74eacc19f7986742712225116530a67e)
#### [v0.29.16](https://github.com/RhetTbull/osxphotos/compare/v0.29.14...v0.29.16)
> 13 June 2020
- Added hour, min, sec, strftime templates, closes #158 [`#158`](https://github.com/RhetTbull/osxphotos/issues/158)
- Added hour, min, sec to template system, issue #158 [`5387f8e`](https://github.com/RhetTbull/osxphotos/commit/5387f8e2f970ff7fa1967ccad87b45a4f7e50d32)
#### [v0.29.14](https://github.com/RhetTbull/osxphotos/compare/v0.29.13...v0.29.14)
> 13 June 2020
- Updated DatetimeFormatter to include hour/min/sec [`cf2615d`](https://github.com/RhetTbull/osxphotos/commit/cf2615da62801f1fbde61c7905431963e121e2e9)
- Added test for issue #156 [`4ba1982`](https://github.com/RhetTbull/osxphotos/commit/4ba1982d745f0d532ead090177051d928465ed03)
- Bug fix for issue #136 [`06fa1ed`](https://github.com/RhetTbull/osxphotos/commit/06fa1edcae7139b543e17ec63810c37c18cc2780)
#### [v0.29.13](https://github.com/RhetTbull/osxphotos/compare/v0.29.12...v0.29.13)
> 7 June 2020
- Added hidden debug-dump command to CLI [`7cd7b51`](https://github.com/RhetTbull/osxphotos/commit/7cd7b5159845fce15d50a7bfc0ac50d122bee527)
#### [v0.29.12](https://github.com/RhetTbull/osxphotos/compare/v0.29.9...v0.29.12)
> 7 June 2020
- Fix for bug in handling of deleted albums to address issue #156 [`72f034e`](https://github.com/RhetTbull/osxphotos/commit/72f034ef85010544a158d8301b898b5d0d865b05)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`cb993f2`](https://github.com/RhetTbull/osxphotos/commit/cb993f2e5e2df7e0a15b3b2fdb92b65a8de56974)
- Refactoring with sourceryAI [`5c7a0c3`](https://github.com/RhetTbull/osxphotos/commit/5c7a0c3a246cd5fec329b4fd4979d2b77352f916)
#### [v0.29.9](https://github.com/RhetTbull/osxphotos/compare/v0.29.8...v0.29.9)
> 31 May 2020
- Added --filename to CLI, closes #89 [`#89`](https://github.com/RhetTbull/osxphotos/issues/89)
#### [v0.29.8](https://github.com/RhetTbull/osxphotos/compare/v0.29.5...v0.29.8)
> 31 May 2020
- Added --edited-suffix to CLI, closes #145 [`#145`](https://github.com/RhetTbull/osxphotos/issues/145)
- refactored render_template, closes #149 [`#149`](https://github.com/RhetTbull/osxphotos/issues/149)
- Added test for Photos 5 on 10.15.5 [`2243395`](https://github.com/RhetTbull/osxphotos/commit/2243395bff9e1cc379626cc5007e44e6e63b95e0)
- Refactored template code out of PhotoInfo into PhotoTemplate [`16f802b`](https://github.com/RhetTbull/osxphotos/commit/16f802bf717610e13712b8aa477d05d94b14d294)
- Added test for SearchInfo on 10.15.5 [`3a8bef1`](https://github.com/RhetTbull/osxphotos/commit/3a8bef1572e4d83b1e0a4b85c8f06e329cc7e8de)
- performance improvements for update and export_db [`42b89d3`](https://github.com/RhetTbull/osxphotos/commit/42b89d34f3d14818daefbd3bfabc1be9344d2e1a)
- More refactoring in PhotoTemplate [`f35ea70`](https://github.com/RhetTbull/osxphotos/commit/f35ea70b72e8c6743b1f6009466d2a15d40338ac)
#### [v0.29.5](https://github.com/RhetTbull/osxphotos/compare/v0.29.2...v0.29.5)
> 25 May 2020
- added created.dow (day of week) to template [`#147`](https://github.com/RhetTbull/osxphotos/pull/147)
- Added --dry-run option to CLI export, closes #91 [`#91`](https://github.com/RhetTbull/osxphotos/issues/91)
- added created.dd and modified.dd to template system, closes #135 [`#135`](https://github.com/RhetTbull/osxphotos/issues/135)
- Catch exception in folder processing to address #148 [`46fdc94`](https://github.com/RhetTbull/osxphotos/commit/46fdc94398c80b157048649434c7312074ce5c58)
- added created.dow (day of week) to template [`8df6d2c`](https://github.com/RhetTbull/osxphotos/commit/8df6d2c707caf4eb35696888282365a128b69569)
- Added test for DateTimeFormatter.dow [`09c7d18`](https://github.com/RhetTbull/osxphotos/commit/09c7d18901b61669d8b9242babd82eba6987c89a)
#### [v0.29.2](https://github.com/RhetTbull/osxphotos/compare/v0.29.1...v0.29.2)
> 24 May 2020
- Added try/except for bad datettime values [`1d095d7`](https://github.com/RhetTbull/osxphotos/commit/1d095d7284bae57037b8b200c8b3422835c611b2)
#### [v0.29.1](https://github.com/RhetTbull/osxphotos/compare/v0.29.0...v0.29.1)
> 23 May 2020
- Catch illegal timestamp value [`#146`](https://github.com/RhetTbull/osxphotos/pull/146)
- Catch illegal timestamp value [`441de71`](https://github.com/RhetTbull/osxphotos/commit/441de711dc664b244d599c81e3dd1bcd9b2e55a0)
#### [v0.29.0](https://github.com/RhetTbull/osxphotos/compare/v0.28.19...v0.29.0)
> 23 May 2020
- Made --exiftool and --export-as-hardlink incompatible in CLI to fix #132 [`#132`](https://github.com/RhetTbull/osxphotos/issues/132)
- Added --update to CLI export; reference issue #100 [`b1171e9`](https://github.com/RhetTbull/osxphotos/commit/b1171e96cc06362555725995bb311317eb163e49)
- Added as_dict to PlaceInfo [`8c4fe40`](https://github.com/RhetTbull/osxphotos/commit/8c4fe40aa6850f166e526cffaa088550884399af)
- Updated README.md [`11d368a`](https://github.com/RhetTbull/osxphotos/commit/11d368a69cbe67e909e64b020f0334fc09dd3ac4)
- version bump [`c06c230`](https://github.com/RhetTbull/osxphotos/commit/c06c230a469754691d11fff1034fb02daeeba649)
- Test library update [`f416418`](https://github.com/RhetTbull/osxphotos/commit/f416418546a12bc6c1bda13f6b712758584d06dc)
#### [v0.28.19](https://github.com/RhetTbull/osxphotos/compare/v0.28.18...v0.28.19)
> 15 May 2020
- Added label and label_normalized to template system, closes #130 [`#130`](https://github.com/RhetTbull/osxphotos/issues/130)
- Revert "test library updates" [`48e9c32`](https://github.com/RhetTbull/osxphotos/commit/48e9c32add549e66c3ef8c65f8821f5033b55b11)
- test library updates [`d125854`](https://github.com/RhetTbull/osxphotos/commit/d125854f2a04e37747af3e0796370a565c1c9bd0)
- version bump [`bd9d5a2`](https://github.com/RhetTbull/osxphotos/commit/bd9d5a26f3bfcbb33896a139fa86cdab46768103)
- Update README.md [`85760dc`](https://github.com/RhetTbull/osxphotos/commit/85760dc4fe2274d826ed80494fd4e66866398609)
- Update README.md [`be07f90`](https://github.com/RhetTbull/osxphotos/commit/be07f90e5a8179e452730ea654e4c9627b1f6ebc)
#### [v0.28.18](https://github.com/RhetTbull/osxphotos/compare/v0.28.17...v0.28.18)
> 14 May 2020
- Implemented PhotoInfo.exiftool [`a80dee4`](https://github.com/RhetTbull/osxphotos/commit/a80dee401c7eb959f6ad6d93a3272657ed28f521)
#### [v0.28.17](https://github.com/RhetTbull/osxphotos/compare/v0.28.15...v0.28.17)
> 14 May 2020
- Added ExifInfo (Photos 5 only) [`53304d7`](https://github.com/RhetTbull/osxphotos/commit/53304d702317d007056c1d12064503c3ec4ae6f6)
- Added as_dict to ExifTool [`d1af14d`](https://github.com/RhetTbull/osxphotos/commit/d1af14dbb4d441a62d352123774e51fa3538db97)
#### [v0.28.15](https://github.com/RhetTbull/osxphotos/compare/v0.28.13...v0.28.15)
> 11 May 2020
- fixed some minor findings... [`#127`](https://github.com/RhetTbull/osxphotos/pull/127)
- added --export-as-hardlink option [`#126`](https://github.com/RhetTbull/osxphotos/pull/126)
- Added test for folder_names on 10.15.4, closes #119 [`#119`](https://github.com/RhetTbull/osxphotos/issues/119)
- Refactored photosdb and photoinfo to add SearchInfo and labels [`98b3f63`](https://github.com/RhetTbull/osxphotos/commit/98b3f63a92aa2105f8fa97af992fc6fe2d78b973)
- added --export-as-hardlink option [`5eb0876`](https://github.com/RhetTbull/osxphotos/commit/5eb0876e331beb020431bb037dee75fb7ae61c85)
- Added additional test for --export-as-hardlink [`57315d4`](https://github.com/RhetTbull/osxphotos/commit/57315d44497fde977956f76f667470208f11aa2d)
- Updated a couple of tests to use pytest-mock [`397db0d`](https://github.com/RhetTbull/osxphotos/commit/397db0d72fb218669a9ecbff134fa9b392a14661)
- added test for export using hardlinks, fixed a test that failed if users locale settings were different to en_US [`b0ec6c6`](https://github.com/RhetTbull/osxphotos/commit/b0ec6c6b36d8cfe05723d47b210d9d7c5aabdfe5)
#### [v0.28.13](https://github.com/RhetTbull/osxphotos/compare/v0.28.10...v0.28.13)
> 2 May 2020
- added --keyword-template [`65674f5`](https://github.com/RhetTbull/osxphotos/commit/65674f57bc174c078e6c47f12ba3aaba87bfa3a4)
- Fixed bug related to issue #119 [`7af1ccd`](https://github.com/RhetTbull/osxphotos/commit/7af1ccd4ed22ea7f0f86973bfba7f108b6650291)
- test library updates [`1b6f661`](https://github.com/RhetTbull/osxphotos/commit/1b6f661e6b59c003d3b8cb35226ffb51469be508)
#### [v0.28.10](https://github.com/RhetTbull/osxphotos/compare/v0.28.8...v0.28.10)
> 29 April 2020
- Bug fix for albums in Photos &lt;= 4 to address issue #116 [`a57da23`](https://github.com/RhetTbull/osxphotos/commit/a57da2346b282d731ed41db600bfc5cbeb1a0992)
- version bump for pypi [`3fe03cd`](https://github.com/RhetTbull/osxphotos/commit/3fe03cd12752c2a7769007b6d934f1efe9f9c4d2)
#### [v0.28.8](https://github.com/RhetTbull/osxphotos/compare/v0.28.7...v0.28.8)
> 28 April 2020
- Fixed implementation of use_albums_as_keywords and use_persons_as_keywords, closes #115 [`#115`](https://github.com/RhetTbull/osxphotos/issues/115)
- Update README.md [`5cc98c3`](https://github.com/RhetTbull/osxphotos/commit/5cc98c338bcc19fd05bf293eb3afe24c07c8b380)
- Updated README.md [`a800711`](https://github.com/RhetTbull/osxphotos/commit/a80071111f810a1d7d6e2d735839e85499091ea4)
- Update README.md [`1c9d4f2`](https://github.com/RhetTbull/osxphotos/commit/1c9d4f282beea2ac12273c8d0f9453bad1255c2c)
#### [v0.28.7](https://github.com/RhetTbull/osxphotos/compare/v0.28.6...v0.28.7)
> 28 April 2020
- Added --album-keyword and --person-keyword to CLI, closes #61 [`#61`](https://github.com/RhetTbull/osxphotos/issues/61)
- Updated test libraries [`54d5d4b`](https://github.com/RhetTbull/osxphotos/commit/54d5d4b7ba99204f58e723231309ab6e306be28c)
- Updated tests/README.md [`56a0006`](https://github.com/RhetTbull/osxphotos/commit/56a000609f2f08d0f8800fec49cada2980c3bb9d)
#### [v0.28.6](https://github.com/RhetTbull/osxphotos/compare/v0.28.5...v0.28.6)
> 26 April 2020
- Fixed locale bug in templates, closes #113 [`#113`](https://github.com/RhetTbull/osxphotos/issues/113)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`4b29a2e`](https://github.com/RhetTbull/osxphotos/commit/4b29a2e05fd1dac821d80781ae01a148d3d9c523)
- Updated test to avoid issue with GitHub workflow [`9be0f84`](https://github.com/RhetTbull/osxphotos/commit/9be0f849b73061d053d30274ff3295b79c88f0b6)
- Update pythonpackage.yml to remove older pythons [`ccb5f25`](https://github.com/RhetTbull/osxphotos/commit/ccb5f252d14e9335ae04a2e338a6d527b80c9a93)
#### [v0.28.5](https://github.com/RhetTbull/osxphotos/compare/0.28.2...v0.28.5)
> 21 April 2020
- added __len__ to PhotosDB, closes #44 [`#44`](https://github.com/RhetTbull/osxphotos/issues/44)
- Updated use of _PHOTOS_4_VERSION, closes #106 [`#106`](https://github.com/RhetTbull/osxphotos/issues/106)
- Updated tests and test library with RAW images [`9b9b54e`](https://github.com/RhetTbull/osxphotos/commit/9b9b54e590e43ae49fb3ae41d493a1f8faec4181)
- Updated setup.py to resolve issue with bpylist2 on python &lt; 3.8 [`8e4b88a`](https://github.com/RhetTbull/osxphotos/commit/8e4b88ad1fc18438f941e045bfc8aeac878914f9)
- Added cli.py for use with pyinstaller [`cf28cb6`](https://github.com/RhetTbull/osxphotos/commit/cf28cb6452de17f2ef8d80435386e8d5a1aabd34)
- added raw_is_original handling [`a337e79`](https://github.com/RhetTbull/osxphotos/commit/a337e79e13802b4824c2f088ce9db1c027d6f3c5)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`1c8eb76`](https://github.com/RhetTbull/osxphotos/commit/1c8eb764f53c3cc8b541667c858e462793ad8d1f)
#### [0.28.2](https://github.com/RhetTbull/osxphotos/compare/v0.28.1...0.28.2)
> 18 April 2020
- Added folder support for Photos &lt;= 4, closes #93 [`#93`](https://github.com/RhetTbull/osxphotos/issues/93)
- cleaned up SQL statements in _process_database4 [`6f28171`](https://github.com/RhetTbull/osxphotos/commit/6f281711e2001a63ffad076d7b9835272d5d09da)
- Fixed suffix check on export to be case insensitive [`4b30b3b`](https://github.com/RhetTbull/osxphotos/commit/4b30b3b4260e2c7409e18825e5b626efe646db16)
- test library update [`3bac106`](https://github.com/RhetTbull/osxphotos/commit/3bac106eb7a180e9e39643a89087d92bf2a437d0)
#### [v0.28.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.4...v0.28.1)
> 18 April 2020
- Initial work on suppport for associated RAW images [`7e42ebb`](https://github.com/RhetTbull/osxphotos/commit/7e42ebb2402d45cd5d20bdd55bddddaa9db4679f)
- Initial support for RAW photos in Photos 4 to address issue #101 [`9d15147`](https://github.com/RhetTbull/osxphotos/commit/9d151478d610291b8d482aafae3d445dfd391fca)
- replaced CLI option --original-name with --current-name [`36c2821`](https://github.com/RhetTbull/osxphotos/commit/36c2821a0fa62eaaa54cf1edc2d9c6da98155354)
#### [v0.27.4](https://github.com/RhetTbull/osxphotos/compare/v0.27.3...v0.27.4)
> 12 April 2020
- Added {folder_album} to template and --folder to CLI [`b7c7b9f`](https://github.com/RhetTbull/osxphotos/commit/b7c7b9f0664e69c743bdd8a228ad2936cf6b7600)
- Test library update [`21e7020`](https://github.com/RhetTbull/osxphotos/commit/21e7020fec406b0f3926d7adc8a1451bfe77e75a)
#### [v0.27.3](https://github.com/RhetTbull/osxphotos/compare/v0.27.1...v0.27.3)
> 12 April 2020
- Added additional tests for album_info [`97362fc`](https://github.com/RhetTbull/osxphotos/commit/97362fc0f13b2867abc013f4ba97ae60b0700894)
- Fixed bug with handling of deleted albums [`9fef12e`](https://github.com/RhetTbull/osxphotos/commit/9fef12ed37634a7bdb11232976b4b2ddccd1a7cb)
#### [v0.27.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.0...v0.27.1)
> 12 April 2020
- Changed AlbumInfo and FolderInfo interface to maintain backwards compatibility with PhotosDB.albums [`e09f0b4`](https://github.com/RhetTbull/osxphotos/commit/e09f0b40f1671d70ee399cdc519492b04fac8adc)
#### [v0.27.0](https://github.com/RhetTbull/osxphotos/compare/v0.26.1...v0.27.0)
> 11 April 2020
- Update README.md [`#95`](https://github.com/RhetTbull/osxphotos/pull/95)
- Added tests and README for AlbumInfo and FolderInfo [`d6a22b7`](https://github.com/RhetTbull/osxphotos/commit/d6a22b765ab17f6ef1ba8c50b77946f090979968)
- Added albuminfo.py for AlbumInfo and FolderInfo classes [`9636572`](https://github.com/RhetTbull/osxphotos/commit/96365728c2ff42abfb6828872ffac53b4c3c8024)
- Update README.md TOC [`8544667`](https://github.com/RhetTbull/osxphotos/commit/8544667c729ea0d7fe39671d909e09cda519e250)
- Update README.md [`1aa3838`](https://github.com/RhetTbull/osxphotos/commit/1aa3838c3866a18084ffe822de02df0eda464d71)
#### [v0.26.1](https://github.com/RhetTbull/osxphotos/compare/v0.26.0...v0.26.1)
> 11 April 2020
- Bug fix for PhotosDB.photos() query [`1c9da5e`](https://github.com/RhetTbull/osxphotos/commit/1c9da5ed6ffa21f0577906b65b7da08951725d1f)
- Updated test library [`d74f7f4`](https://github.com/RhetTbull/osxphotos/commit/d74f7f499bf59f37ec81cfa9d49cbbf3aafb5961)
#### [v0.26.0](https://github.com/RhetTbull/osxphotos/compare/v0.25.1...v0.26.0)
> 11 April 2020
- Added test for 10.15.4 [`1820715`](https://github.com/RhetTbull/osxphotos/commit/182071584904d001a9b199eef5febfb79e00696e)
- Changed PhotosDB albums interface as prep for adding folders [`3e50626`](https://github.com/RhetTbull/osxphotos/commit/3e5062684ab6d706d91d4abeb4e3b0ca47867b70)
- Update README.md [`626e460`](https://github.com/RhetTbull/osxphotos/commit/626e460aabb97b30af87cea2ec4f93e5fb925bec)
#### [v0.25.1](https://github.com/RhetTbull/osxphotos/compare/v0.25.0...v0.25.1)
> 5 April 2020
- Added --no-extended-attributes option to CLI, closes #85 [`#85`](https://github.com/RhetTbull/osxphotos/issues/85)
- Fixed CLI help for invalid topic, closes #76 [`#76`](https://github.com/RhetTbull/osxphotos/issues/76)
- Updated test library [`bae0283`](https://github.com/RhetTbull/osxphotos/commit/bae0283441f04d71aa78dbd1cf014f376ef1f91a)
#### [v0.25.0](https://github.com/RhetTbull/osxphotos/compare/v0.24.2...v0.25.0)
> 4 April 2020
- Added places, --place, --no-place to CLI, closes #87, #88 [`#87`](https://github.com/RhetTbull/osxphotos/issues/87)
- Updated render_filepath_template to support multiple values [`6a89888`](https://github.com/RhetTbull/osxphotos/commit/6a898886ddadc9d5bc9dbad6ee7365270dd0a26d)
- Added {album}, {keyword}, and {person} to template system [`507c4a3`](https://github.com/RhetTbull/osxphotos/commit/507c4a374014f999ca19789bce0df0c14332e021)
- Added places command to CLI [`fd5e748`](https://github.com/RhetTbull/osxphotos/commit/fd5e748dca759ea1c3a7329d447f363afe8418b7)
- Updated export example [`01cd7fe`](https://github.com/RhetTbull/osxphotos/commit/01cd7fed6d7fc0c61c171a05319c211eb0a9f7c1)
- Fixed typo in help text [`c02953e`](https://github.com/RhetTbull/osxphotos/commit/c02953ef5fe1aee219e0557bfd8c3322f1900a81)
#### [v0.24.2](https://github.com/RhetTbull/osxphotos/compare/v0.24.1...v0.24.2)
> 28 March 2020
- added {place.country_code} to template system [`be2e167`](https://github.com/RhetTbull/osxphotos/commit/be2e16769d5d2c75af6d7792f1311f5a65c3bc67)
#### [v0.24.1](https://github.com/RhetTbull/osxphotos/compare/v0.23.4...v0.24.1)
> 28 March 2020
- Added detailed place data in PlaceInfo.names [`c06dd42`](https://github.com/RhetTbull/osxphotos/commit/c06dd4233f917f068c087f5604013d371b0a826a)
- Template system now supports default values [`67a9a9e`](https://github.com/RhetTbull/osxphotos/commit/67a9a9e21bd05d01a3202b0a1279487f5d04c9d9)
- Replaced template renderer with regex-based renderer [`427c4c0`](https://github.com/RhetTbull/osxphotos/commit/427c4c0bc49f671477866d30eee74834c67d7bc5)
#### [v0.23.4](https://github.com/RhetTbull/osxphotos/compare/v0.23.3...v0.23.4)
> 22 March 2020
- Added export_by_album.py to examples [`908fead`](https://github.com/RhetTbull/osxphotos/commit/908fead8a2fbcef3b4a387f34d83d88c507c5939)
- Updated pathvalidate calls [`d066435`](https://github.com/RhetTbull/osxphotos/commit/d066435e3df4062be6a0a3d5fa7308f293e764d5)
- Updated example [`8f0307f`](https://github.com/RhetTbull/osxphotos/commit/8f0307fc24345ca0e87017ac76791c9bbe8db25e)
#### [v0.23.3](https://github.com/RhetTbull/osxphotos/compare/v0.23.1...v0.23.3)
> 22 March 2020
- Initial version of templating system for CLI [`2feb099`](https://github.com/RhetTbull/osxphotos/commit/2feb0999b3f9ffd9a24e37238f780239a027aa49)
- Added __str__ to place [`ad58b03`](https://github.com/RhetTbull/osxphotos/commit/ad58b03f2d31daf33849b141570dd0fb5e0a262e)
- Test library updates [`e90d9c6`](https://github.com/RhetTbull/osxphotos/commit/e90d9c6e11fce7a4e4aa348dcc5f57420c0b6c44)
#### [v0.23.1](https://github.com/RhetTbull/osxphotos/compare/v0.23.0...v0.23.1)
> 21 March 2020
- Fixed requirements.txt for bplist2 [`cda5f44`](https://github.com/RhetTbull/osxphotos/commit/cda5f446933ea2272409d1f153e2a7811626ada6)
- Updated requirements.txt [`9da7ad6`](https://github.com/RhetTbull/osxphotos/commit/9da7ad6dcc021fdafe358d74e1c52f69dc49ade8)
- still trying to debug github actions fail [`960487f`](https://github.com/RhetTbull/osxphotos/commit/960487f2961f97f6b24d253472dcedf74dfc7797)
#### [v0.23.0](https://github.com/RhetTbull/osxphotos/compare/v0.22.23...v0.23.0)
> 21 March 2020
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`21547a8`](https://github.com/RhetTbull/osxphotos/commit/21547a8eaad117b11bc5e4dddf95436a8244e9ba)
- Added PhotoInfo.place for reverse geolocation data [`b338b34`](https://github.com/RhetTbull/osxphotos/commit/b338b34d5055a7621e4ebe4fbbae12227d77af6d)
- Update pythonpackage.yml [`92e5bdd`](https://github.com/RhetTbull/osxphotos/commit/92e5bdd2e986e5de2a710abf60ba0dc99c6a6730)
#### [v0.22.23](https://github.com/RhetTbull/osxphotos/compare/v0.22.21...v0.22.23)
> 15 March 2020
- Lots of work on export code [`0940f03`](https://github.com/RhetTbull/osxphotos/commit/0940f039d3e628dc4f25c69bf27ce413807d3f71)
- test library update [`1e08a74`](https://github.com/RhetTbull/osxphotos/commit/1e08a7449e69965a37373dadabb37c993d93fc69)
#### [v0.22.21](https://github.com/RhetTbull/osxphotos/compare/v0.22.17...v0.22.21)
> 15 March 2020
- Working on export edited bug for issue #78 [`8542e1a`](https://github.com/RhetTbull/osxphotos/commit/8542e1a97f6b640f287b37af9e50fd05f964ec4d)
- Fixed download-missing to only download when actually missing [`dd20b8d`](https://github.com/RhetTbull/osxphotos/commit/dd20b8d8ac3b16d3b72a26b97dcc620b11e3a7c0)
- test library updates [`e99391a`](https://github.com/RhetTbull/osxphotos/commit/e99391a68e844adb63edde3efb921cffa3928aeb)
#### [v0.22.17](https://github.com/RhetTbull/osxphotos/compare/v0.22.16...v0.22.17)
> 14 March 2020
- Added MANIFEST.in [`279ab36`](https://github.com/RhetTbull/osxphotos/commit/279ab369295cfe1c778b38e212248271e4fc659e)
- version bump [`783e097`](https://github.com/RhetTbull/osxphotos/commit/783e097da35a210a2aa5c75865a8599541b9da0b)
#### [v0.22.16](https://github.com/RhetTbull/osxphotos/compare/v0.22.13...v0.22.16)
> 14 March 2020
- removed activate from --download-missing-photos Applescript, closes #69 [`#69`](https://github.com/RhetTbull/osxphotos/issues/69)
- Added media type specials to json and string output, closes #68 [`#68`](https://github.com/RhetTbull/osxphotos/issues/68)
- Fixed bug in --download-missing related to burst images [`1f13ba8`](https://github.com/RhetTbull/osxphotos/commit/1f13ba837fe36ff4eeb48cca02f5312a88a0a765)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`dc87194`](https://github.com/RhetTbull/osxphotos/commit/dc87194eec252461d0cc0891b9ede4157125e828)
- Added query/export options for special media types [`2b7d84a`](https://github.com/RhetTbull/osxphotos/commit/2b7d84a4d103982ad874d875bafbc34d654d539a)
- README.md update [`a27ce33`](https://github.com/RhetTbull/osxphotos/commit/a27ce33473df3260dfb7ed26e28295cbf87d1e78)
- Test library updates [`2d7d0b8`](https://github.com/RhetTbull/osxphotos/commit/2d7d0b86e0008cae043e314937504f36ad882990)
#### [v0.22.13](https://github.com/RhetTbull/osxphotos/compare/v0.22.12...v0.22.13)
> 8 March 2020
- Added media type specials, closes #60 [`#60`](https://github.com/RhetTbull/osxphotos/issues/60)
- Updated README.md [`1f8fd6e`](https://github.com/RhetTbull/osxphotos/commit/1f8fd6e929cc0edd3dd2f222416454d26955bf2a)
#### [v0.22.12](https://github.com/RhetTbull/osxphotos/compare/0.22.10...v0.22.12)
> 7 March 2020
- Added exiftool [`8dea419`](https://github.com/RhetTbull/osxphotos/commit/8dea41961bad285be7058a68e5f7199e5cfb740e)
- Added --exiftool to CLI export [`ef79961`](https://github.com/RhetTbull/osxphotos/commit/ef799610aea67b703a7d056b7eee227534ba78a5)
- Updated test library [`9a0fc0d`](https://github.com/RhetTbull/osxphotos/commit/9a0fc0db3e79359610fd0f124a97b03fcf97d8a7)
#### [0.22.10](https://github.com/RhetTbull/osxphotos/compare/v0.22.9...0.22.10)
> 8 February 2020
- Fixed bug in --download-missing to fix issue #64 [`c654e3d`](https://github.com/RhetTbull/osxphotos/commit/c654e3dc61283382b37b6892dab1516ec517143a)
- removed commented out code [`69addc3`](https://github.com/RhetTbull/osxphotos/commit/69addc34649f992c6a4a0e0e334754a72530f0ba)
- Cleaned up comments and unneeded test code [`e3c40bc`](https://github.com/RhetTbull/osxphotos/commit/e3c40bcbaaf3560d53091cf46ed851d90ff82cfa)
#### [v0.22.9](https://github.com/RhetTbull/osxphotos/compare/v0.22.7...v0.22.9)
> 1 February 2020
@@ -14,7 +532,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Test library updates [`6e563e2`](https://github.com/RhetTbull/osxphotos/commit/6e563e214c569ba7838f7464de9258c3bba5db23)
- Removed _tmp_file code that's no longer needed [`27994c9`](https://github.com/RhetTbull/osxphotos/commit/27994c9fd372303833a5794f1de9815f425c762e)
- Updated photos_repl.py [`fdf636a`](https://github.com/RhetTbull/osxphotos/commit/fdf636ac8864ebb2cc324b1f9d3c6c82ee3910f9)
- Updated CHANGELOG.md [`f910124`](https://github.com/RhetTbull/osxphotos/commit/f910124fe1fbf75d44c09c79607374bf000733a1)
- Added PhotosDB() behavior to open last library if no args passed but also added cautionary note to README [`46d3c7d`](https://github.com/RhetTbull/osxphotos/commit/46d3c7dbdaf848d5c340ce8a362ff296a36c552d)
#### [v0.22.7](https://github.com/RhetTbull/osxphotos/compare/v0.22.4...v0.22.7)
@@ -28,7 +546,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added date_modified to PhotoInfo [`67b0ae0`](https://github.com/RhetTbull/osxphotos/commit/67b0ae0bf679815372d415c3064e21d46a5b8718)
- Added date_modified to PhotoInfo [`4d36b3b`](https://github.com/RhetTbull/osxphotos/commit/4d36b3b31f3e0e74d9d111b6b691771e19f94086)
- Updated CLI options with more descriptive metavar names [`e79cb92`](https://github.com/RhetTbull/osxphotos/commit/e79cb92693758c984dc789d5fa5d2e87e381e921)
- CLI now looks for photos library to use if non specified by user [`50b7e69`](https://github.com/RhetTbull/osxphotos/commit/50b7e6920a694aa45f478d1131868525c9147919)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`898d3af`](https://github.com/RhetTbull/osxphotos/commit/898d3afc0892546ece6c3d675208dea216e20633)
#### [v0.22.4](https://github.com/RhetTbull/osxphotos/compare/v0.22.0...v0.22.4)
@@ -40,7 +558,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Implement from_date and to_date in PhotosDB as well as query and export command. Some refactoring of CLI as well. [`cfa2b4a`](https://github.com/RhetTbull/osxphotos/commit/cfa2b4a828facf0aff5bc19f777457ad776c4a05)
- Refactored _query. Still hairy, but less so. [`b9dee49`](https://github.com/RhetTbull/osxphotos/commit/b9dee4995c6d89fadb3d2482374b7098f2ab5ed9)
- Updated README.md [`0aff83f`](https://github.com/RhetTbull/osxphotos/commit/0aff83ff21c20e293c0b75bacf2863090a0fb725)
- Started adding tests for CLI [`f0b18c3`](https://github.com/RhetTbull/osxphotos/commit/f0b18c3d29b2141d348be0495013c51c072c6251)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`7150956`](https://github.com/RhetTbull/osxphotos/commit/7150956a488677d402a6d43443d04c4b11dc7be0)
#### [v0.22.0](https://github.com/RhetTbull/osxphotos/compare/v0.21.5...v0.22.0)
@@ -48,7 +566,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Refactored PhotosDB and CLI to require explicity passing the database to avoid non-deterministic behavior when last database can't be found. This may break existing code. [`ede56ff`](https://github.com/RhetTbull/osxphotos/commit/ede56ffc31cf98811b3d4d16e22406ac0eae0315)
- Changed get_system_library_path to return None if could not get system library [`646ea4f`](https://github.com/RhetTbull/osxphotos/commit/646ea4f24ca1119b27280af1445e31adcd0690f0)
- Updated CHANGELOG.md [`bd20388`](https://github.com/RhetTbull/osxphotos/commit/bd20388778dfa645277029601c63fc9835b7a406)
- Fix to setup to specify versions of required packages [`de05323`](https://github.com/RhetTbull/osxphotos/commit/de05323a153fe49723b39e48b9038c1fb9535a72)
#### [v0.21.5](https://github.com/RhetTbull/osxphotos/compare/v0.21.0...v0.21.5)
@@ -63,8 +581,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
> 4 January 2020
- Added live photo support for both Photos 4 & 5 [`d5eaff0`](https://github.com/RhetTbull/osxphotos/commit/d5eaff02f2a29a9d105ab72e9a9aeffbc9a3425b)
- Added support for burst photos; added export-bursts to CLI [`593983a`](https://github.com/RhetTbull/osxphotos/commit/593983a09940e67fb9347bf345cfd7289465fa0a)
- Added live-photo option to CLI query and export [`6f6d37c`](https://github.com/RhetTbull/osxphotos/commit/6f6d37ceacf71a52a2c0216f0ad75afee244946a)
- Initial support for live photos (Photos 5 only) [`1a89a18`](https://github.com/RhetTbull/osxphotos/commit/1a89a18a011a25616d7a18fb9bf1270b0b206fb4)
#### [v0.20.0](https://github.com/RhetTbull/osxphotos/compare/v0.19.0...v0.20.0)
@@ -78,9 +596,9 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
> 29 December 2019
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`51843fb`](https://github.com/RhetTbull/osxphotos/commit/51843fb46d6ce69456400271c97aa642466d5719)
- Added support for movies for Photos 5; fixed bugs in ismissing and path [`6f4d129`](https://github.com/RhetTbull/osxphotos/commit/6f4d129f07046c4a34d3d6cf6854c8514a594781)
- Added support for movies for Photos 5; fixed bugs in ismissing and path [`b030966`](https://github.com/RhetTbull/osxphotos/commit/b030966051af93be380ff967ac047bf566e5d817)
- Initial support for movies [`dbe363e`](https://github.com/RhetTbull/osxphotos/commit/dbe363e4d754253a0405fb1df045677e8780d630)
#### [v0.18.0](https://github.com/RhetTbull/osxphotos/compare/v0.15.1...v0.18.0)

2
MANIFEST.in Normal file
View File

@@ -0,0 +1,2 @@
include README.md
include osxphotos/templates/*

1236
README.md

File diff suppressed because it is too large Load Diff

19
cli.py Normal file
View File

@@ -0,0 +1,19 @@
""" stand alone command line script for use with pyinstaller
To build this into an executable:
- install pyinstaller:
python3 -m pip install pyinstaller
- then use make_cli_exe.sh to run pyinstaller or execute the following command:
pyinstaller --onefile --hidden-import="pkg_resources.py2_warn" --name osxphotos --add-data osxphotos/templates/xmp_sidecar.mako:osxphotos/templates cli.py
Resulting executable will be in "dist/osxphotos"
Note: This is *not* the cli that "python3 -m pip install osxphotos" or "python setup.py install" would install;
it's merely a wrapper around __main__.py to allow pyinstaller to work
"""
from osxphotos.__main__ import cli
if __name__ == "__main__":
cli()

View File

@@ -0,0 +1,86 @@
""" Export all photos to specified directory using album names as folders
If file has been edited, also export the edited version,
otherwise, export the original version
This will result in duplicate photos if photo is in more than album """
import os.path
import pathlib
import sys
import click
from pathvalidate import is_valid_filepath, sanitize_filepath
import osxphotos
@click.command()
@click.argument("export_path", type=click.Path(exists=True))
@click.option(
"--default-album",
help="Default folder for photos with no album. Defaults to 'unfiled'",
default="unfiled",
)
@click.option(
"--library-path",
help="Path to Photos library, default to last used library",
default=None,
)
@click.option(
"--edited",
help="Also export edited versions of photos (default is originals only)",
is_flag=True,
default=False,
)
def export(export_path, default_album, library_path, edited):
""" Export all photos, organized by album """
export_path = os.path.expanduser(export_path)
library_path = os.path.expanduser(library_path) if library_path else None
if library_path is not None:
photosdb = osxphotos.PhotosDB(library_path)
else:
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos()
for p in photos:
if not p.ismissing:
albums = p.albums
if not albums:
albums = [default_album]
for album in albums:
click.echo(f"exporting {p.original_filename} in album {album}")
# make sure no invalid characters in destination path (could be in album name)
album_name = sanitize_filepath(album, platform="auto")
# create destination folder, if necessary, based on album name
dest_dir = os.path.join(export_path, album_name)
# verify path is a valid path
if not is_valid_filepath(dest_dir, platform="auto"):
sys.exit(f"Invalid filepath {dest_dir}")
# create destination dir if needed
if not os.path.isdir(dest_dir):
os.makedirs(dest_dir)
filename = p.original_filename
# export the photo but only if --edited, photo has adjustments, and
# path_edited is not None (can be None if edited photo is missing)
if edited and p.hasadjustments and p.path_edited:
# export edited version
# use original filename with _edited appended but make sure suffix is
# same as edited file
edited_filename = f"{pathlib.Path(filename).stem}_edited{pathlib.Path(p.path_edited).suffix}"
exported = p.export(dest_dir, edited_filename, edited=True)
click.echo(f"Exported {edited_filename} to {exported}")
# export unedited version
exported = p.export(dest_dir, filename)
click.echo(f"Exported {filename} to {exported}")
else:
click.echo(f"Skipping missing photo: {p.original_filename}")
if __name__ == "__main__":
export() # pylint: disable=no-value-for-parameter

83
examples/export_faces.py Normal file
View File

@@ -0,0 +1,83 @@
""" Export all photos that contain a detected face and draw rectangles around each face
photos with no persons/detected faces will not be export
This shows how to use the FaceInfo class and is useful for validating that FaceInfo is
correctly handling faces.
To use this, you'll need to install Pillow:
python3 -m pip install Pillow
"""
import os
import click
from PIL import Image, ImageDraw
import osxphotos
@click.command()
@click.argument("export-path", type=click.Path(exists=True))
@click.option(
"--uuid",
metavar="UUID",
help="Limit export to optional UUID(s)",
required=False,
multiple=True,
)
@click.option(
"--library-path",
metavar="PATH",
help="Path to Photos library, default to last used library",
default=None,
)
def export(export_path, library_path, uuid):
""" export photos to export_path and draw faces """
library_path = os.path.expanduser(library_path) if library_path else None
if library_path is not None:
photosdb = osxphotos.PhotosDB(library_path)
else:
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos(uuid=uuid) if uuid else photosdb.photos(movies=False)
for p in photos:
if p.person_info and not p.ismissing:
# has persons and not missing
if "heic" in p.filename.lower():
print(f"skipping heic image {p.filename}")
continue
print(f"exporting photo {p.original_filename}, uuid = {p.uuid}")
export = p.export(export_path, p.original_filename, edited=p.hasadjustments)
if export:
im = Image.open(export[0])
draw = ImageDraw.Draw(im)
for face in p.face_info:
coords = face.face_rect()
draw.rectangle(coords, width=3)
draw.ellipse(get_circle_points(face.center, 3), width=1)
draw.text(face.mouth, "M", fill=(255, 255, 255, 255))
draw.text(face.left_eye, "L", fill=(255, 255, 255, 255))
draw.text(face.right_eye, "R", fill=(255, 255, 255, 255))
im.save(export[0])
else:
print(f"no photos exported for {p.uuid}")
def get_circle_points(xy, radius):
""" Returns tuples of (x0, y0), (x1, y1) for a circle centered at x, y with radius
Arguments:
xy: tuple of x, y coordinates
radius: radius of circle to draw
Returns:
[(x0, y0), (x1, y1)] for bounding box of circle centered at x, y
"""
x, y = xy
x0, y0 = x - radius, y - radius
x1, y1 = x + radius, y + radius
return [(x0, y0), (x1, y1)]
if __name__ == "__main__":
export() # pylint: disable=no-value-for-parameter

View File

@@ -7,6 +7,7 @@
# If you run this using python from command line, do so with -i flag:
# python3 -i examples/photos_repl.py
import os
import sys
import time
@@ -17,14 +18,27 @@ import osxphotos
from osxphotos.__main__ import get_photos_db, _list_libraries
def show(photo):
""" open image with default image viewer
Note: This is for debugging only -- it will actually open any filetype which could
be very, very bad.
Args:
photo: PhotoInfo object or a path to a photo on disk
"""
photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo
if not os.path.isfile(photopath):
return f"'{photopath}' does not appear to be a valid photo path"
os.system(f"open '{photopath}'")
def main():
db = None
if len(sys.argv) > 1:
db = sys.argv[1]
else:
db = get_photos_db()
db = sys.argv[1] if len(sys.argv) > 1 else get_photos_db()
if db:
print("loading database")
tic = time.perf_counter()
@@ -44,5 +58,6 @@ if __name__ == "__main__":
print("getting photos")
tic = time.perf_counter()
photos = photosdb.photos(images=True, movies=True)
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
toc = time.perf_counter()
print(f"found {len(photos)} photos in {toc-tic} seconds")

8
make_cli_exe.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
# This will build an stand-alone executable called 'osxphotos' in your ./dist directory
# using pyinstaller
# 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

View File

@@ -3,14 +3,9 @@ import logging
from ._version import __version__
from .photoinfo import PhotoInfo
from .photosdb import PhotosDB
from .utils import _set_debug, _debug, _get_logger
from .phototemplate import PhotoTemplate
from .utils import _debug, _get_logger, _set_debug
# TODO: find edited photos: see https://github.com/orangeturtle739/photos-export/blob/master/extract_photos.py
# TODO: Add test for imageTimeZoneOffsetSeconds = None
# TODO: Fix command line so multiple --keyword, etc. are AND (instead of OR as they are in .photos())
# Or fix the help text to match behavior
# TODO: Add test for __str__ and to_json
# TODO: fix docstrings
# TODO: Add special albums and magic albums
# TODO: cleanup os.path and pathlib code (import pathlib and also from pathlib import Path)

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,12 @@ import os.path
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
# versions later than this have a different database structure
_PHOTOS_5_VERSION = "6000"
# only version 3 - 4 have RKVersion.selfPortrait
_PHOTOS_3_VERSION = "3301"
# versions 5.0 and later have a different database structure
_PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.5
# which major version operating systems have been tested
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
@@ -22,6 +26,9 @@ _TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
# Photos 5 has persons who are empty string if unidentified face
_UNKNOWN_PERSON = "_UNKNOWN_"
# photos with no reverse geolocation info (place)
_UNKNOWN_PLACE = "_UNKNOWN_"
_EXIF_TOOL_URL = "https://exiftool.org/"
# Where are shared iCloud photos located?
@@ -34,3 +41,25 @@ _MOVIE_TYPE = 1
# Name of XMP template file
_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
_XMP_TEMPLATE_NAME = "xmp_sidecar.mako"
# Constants used for processing folders and albums
_PHOTOS_5_ALBUM_KIND = 2 # normal user album
_PHOTOS_5_SHARED_ALBUM_KIND = 1505 # shared album
_PHOTOS_5_FOLDER_KIND = 4000 # user folder
_PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"
# EXIF related constants
# max keyword length for IPTC:Keyword, reference
# https://www.iptc.org/std/photometadata/documentation/userguide/
_MAX_IPTC_KEYWORD_LEN = 64
# Sentinel value for detecting if a template in keyword_template doesn't match
# If anyone has a keyword matching this, then too bad...
_OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
SEARCH_CATEGORY_LABEL = 2024

520
osxphotos/_export_db.py Normal file
View File

@@ -0,0 +1,520 @@
""" Helper class for managing a database used by
PhotoInfo.export for tracking state of exports and updates
"""
import datetime
import logging
import os
import pathlib
import sqlite3
import sys
from abc import ABC, abstractmethod
from io import StringIO
from sqlite3 import Error
from ._version import __version__
OSXPHOTOS_EXPORTDB_VERSION = "1.0"
class ExportDB_ABC(ABC):
""" abstract base class for ExportDB """
@abstractmethod
def get_uuid_for_file(self, filename):
pass
@abstractmethod
def set_uuid_for_file(self, filename, uuid):
pass
@abstractmethod
def set_stat_orig_for_file(self, filename, stats):
pass
@abstractmethod
def get_stat_orig_for_file(self, filename):
pass
@abstractmethod
def set_stat_exif_for_file(self, filename, stats):
pass
@abstractmethod
def get_stat_exif_for_file(self, filename):
pass
@abstractmethod
def get_info_for_uuid(self, uuid):
pass
@abstractmethod
def set_info_for_uuid(self, uuid, info):
pass
@abstractmethod
def get_exifdata_for_file(self, uuid):
pass
@abstractmethod
def set_exifdata_for_file(self, uuid, exifdata):
pass
@abstractmethod
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
pass
class ExportDBNoOp(ExportDB_ABC):
""" An ExportDB with NoOp methods """
def get_uuid_for_file(self, filename):
pass
def set_uuid_for_file(self, filename, uuid):
pass
def set_stat_orig_for_file(self, filename, stats):
pass
def get_stat_orig_for_file(self, filename):
pass
def set_stat_exif_for_file(self, filename, stats):
pass
def get_stat_exif_for_file(self, filename):
pass
def get_info_for_uuid(self, uuid):
pass
def set_info_for_uuid(self, uuid, info):
pass
def get_exifdata_for_file(self, uuid):
pass
def set_exifdata_for_file(self, uuid, exifdata):
pass
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
pass
class ExportDB(ExportDB_ABC):
""" Interface to sqlite3 database used to store state information for osxphotos export command """
def __init__(self, dbfile):
""" dbfile: path to osxphotos export database file """
self._dbfile = dbfile
# _path is parent of the database
# all files referenced by get_/set_uuid_for_file will be converted to
# relative paths to this parent _path
# this allows the entire export tree to be moved to a new disk/location
# whilst preserving the UUID to filename mappping
self._path = pathlib.Path(dbfile).parent
self._conn = self._open_export_db(dbfile)
self._insert_run_info()
def get_uuid_for_file(self, filename):
""" query database for filename and return UUID
returns None if filename not found in database
"""
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
logging.debug(f"get_uuid: {filename}")
conn = self._conn
try:
c = conn.cursor()
c.execute(
f"SELECT uuid FROM files WHERE filepath_normalized = ?", (filename,)
)
results = c.fetchone()
uuid = results[0] if results else None
except Error as e:
logging.warning(e)
uuid = None
logging.debug(f"get_uuid: {uuid}")
return uuid
def set_uuid_for_file(self, filename, uuid):
""" set UUID of filename to uuid in the database """
filename = str(pathlib.Path(filename).relative_to(self._path))
filename_normalized = filename.lower()
logging.debug(f"set_uuid: {filename} {uuid}")
conn = self._conn
try:
c = conn.cursor()
c.execute(
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
(filename, filename_normalized, uuid),
)
conn.commit()
except Error as e:
logging.warning(e)
def set_stat_orig_for_file(self, filename, stats):
""" set stat info for filename
filename: filename to set the stat info for
stat: a tuple of length 3: mode, size, mtime """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
if len(stats) != 3:
raise ValueError(f"expected 3 elements for stat, got {len(stats)}")
logging.debug(f"set_stat_orig_for_file: {filename} {stats}")
conn = self._conn
try:
c = conn.cursor()
c.execute(
"UPDATE files "
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
+ "WHERE filepath_normalized = ?;",
(*stats, filename),
)
conn.commit()
except Error as e:
logging.warning(e)
def get_stat_orig_for_file(self, filename):
""" get stat info for filename
returns: tuple of (mode, size, mtime)
"""
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"SELECT orig_mode, orig_size, orig_mtime FROM files WHERE filepath_normalized = ?",
(filename,),
)
results = c.fetchone()
stats = results[0:3] if results else None
except Error as e:
logging.warning(e)
stats = (None, None, None)
logging.debug(f"get_stat_orig_for_file: {stats}")
return stats
def set_stat_exif_for_file(self, filename, stats):
""" set stat info for filename (after exiftool has updated it)
filename: filename to set the stat info for
stat: a tuple of length 3: mode, size, mtime """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
if len(stats) != 3:
raise ValueError(f"expected 3 elements for stat, got {len(stats)}")
logging.debug(f"set_stat_exif_for_file: {filename} {stats}")
conn = self._conn
try:
c = conn.cursor()
c.execute(
"UPDATE files "
+ "SET exif_mode = ?, exif_size = ?, exif_mtime = ? "
+ "WHERE filepath_normalized = ?;",
(*stats, filename),
)
conn.commit()
except Error as e:
logging.warning(e)
def get_stat_exif_for_file(self, filename):
""" get stat info for filename (after exiftool has updated it)
returns: tuple of (mode, size, mtime)
"""
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"SELECT exif_mode, exif_size, exif_mtime FROM files WHERE filepath_normalized = ?",
(filename,),
)
results = c.fetchone()
stats = results[0:3] if results else None
except Error as e:
logging.warning(e)
stats = (None, None, None)
logging.debug(f"get_stat_exif_for_file: {stats}")
return stats
def get_info_for_uuid(self, uuid):
""" returns the info JSON struct for a UUID """
conn = self._conn
try:
c = conn.cursor()
c.execute("SELECT json_info FROM info WHERE uuid = ?", (uuid,))
results = c.fetchone()
info = results[0] if results else None
except Error as e:
logging.warning(e)
info = None
logging.debug(f"get_info: {uuid}, {info}")
return info
def set_info_for_uuid(self, uuid, info):
""" sets the info JSON struct for a UUID """
conn = self._conn
try:
c = conn.cursor()
c.execute(
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
(uuid, info),
)
conn.commit()
except Error as e:
logging.warning(e)
logging.debug(f"set_info: {uuid}, {info}")
def get_exifdata_for_file(self, filename):
""" returns the exifdata JSON struct for a file """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"SELECT json_exifdata FROM exifdata WHERE filepath_normalized = ?",
(filename,),
)
results = c.fetchone()
exifdata = results[0] if results else None
except Error as e:
logging.warning(e)
exifdata = None
logging.debug(f"get_exifdata: {filename}, {exifdata}")
return exifdata
def set_exifdata_for_file(self, filename, exifdata):
""" sets the exifdata JSON struct 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 exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
(filename, exifdata),
)
conn.commit()
except Error as e:
logging.warning(e)
logging.debug(f"set_exifdata: {filename}, {exifdata}")
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
""" sets all the data for file and uuid at once
"""
filename = str(pathlib.Path(filename).relative_to(self._path))
filename_normalized = filename.lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
(filename, filename_normalized, uuid),
)
c.execute(
"UPDATE files "
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
+ "WHERE filepath_normalized = ?;",
(*orig_stat, filename_normalized),
)
c.execute(
"UPDATE files "
+ "SET exif_mode = ?, exif_size = ?, exif_mtime = ? "
+ "WHERE filepath_normalized = ?;",
(*exif_stat, filename_normalized),
)
c.execute(
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
(uuid, info_json),
)
c.execute(
"INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
(filename_normalized, exif_json),
)
conn.commit()
except Error as e:
logging.warning(e)
def close(self):
""" close the database connection """
try:
self._conn.close()
except Error as e:
logging.warning(e)
def _open_export_db(self, dbfile):
""" open export database and return a db connection
if dbfile does not exist, will create and initialize the database
returns: connection to the database
"""
if not os.path.isfile(dbfile):
logging.debug(f"dbfile {dbfile} doesn't exist, creating it")
conn = self._get_db_connection(dbfile)
if conn:
self._create_db_tables(conn)
else:
raise Exception("Error getting connection to database {dbfile}")
else:
logging.debug(f"dbfile {dbfile} exists, opening it")
conn = self._get_db_connection(dbfile)
return conn
def _get_db_connection(self, dbfile):
""" return db connection to dbname """
try:
conn = sqlite3.connect(dbfile)
except Error as e:
logging.warning(e)
conn = None
return conn
def _create_db_tables(self, conn):
""" create (if not already created) the necessary db tables for the export database
conn: sqlite3 db connection
"""
sql_commands = {
"sql_version_table": """ CREATE TABLE IF NOT EXISTS version (
id INTEGER PRIMARY KEY,
osxphotos TEXT,
exportdb TEXT
); """,
"sql_files_table": """ CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY,
filepath TEXT NOT NULL,
filepath_normalized TEXT NOT NULL,
uuid TEXT,
orig_mode INTEGER,
orig_size INTEGER,
orig_mtime REAL,
exif_mode INTEGER,
exif_size INTEGER,
exif_mtime REAL
); """,
"sql_runs_table": """ CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
datetime TEXT,
python_path TEXT,
script_name TEXT,
args TEXT,
cwd TEXT
); """,
"sql_info_table": """ CREATE TABLE IF NOT EXISTS info (
id INTEGER PRIMARY KEY,
uuid text NOT NULL,
json_info JSON
); """,
"sql_exifdata_table": """ CREATE TABLE IF NOT EXISTS exifdata (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
json_exifdata JSON
); """,
"sql_files_idx": """ CREATE UNIQUE INDEX idx_files_filepath_normalized on files (filepath_normalized); """,
"sql_info_idx": """ CREATE UNIQUE INDEX idx_info_uuid on info (uuid); """,
"sql_exifdata_idx": """ CREATE UNIQUE INDEX idx_exifdata_filename on exifdata (filepath_normalized); """,
}
try:
c = conn.cursor()
for cmd in sql_commands.values():
c.execute(cmd)
c.execute(
"INSERT INTO version(osxphotos, exportdb) VALUES (?, ?);",
(__version__, OSXPHOTOS_EXPORTDB_VERSION),
)
conn.commit()
except Error as e:
logging.warning(e)
def __del__(self):
""" ensure the database connection is closed """
if self._conn:
try:
self._conn.close()
except Error as e:
logging.warning(e)
def _insert_run_info(self):
dt = datetime.datetime.utcnow().isoformat()
python_path = sys.executable
cmd = sys.argv[0]
args = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else ""
cwd = os.getcwd()
conn = self._conn
try:
c = conn.cursor()
c.execute(
f"INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)",
(dt, python_path, cmd, args, cwd),
)
conn.commit()
except Error as e:
logging.warning(e)
class ExportDBInMemory(ExportDB):
""" In memory version of ExportDB
Copies the on-disk database into memory so it may be operated on without
modifying the on-disk verison
"""
def init(self, dbfile):
self._dbfile = dbfile
# _path is parent of the database
# all files referenced by get_/set_uuid_for_file will be converted to
# relative paths to this parent _path
# this allows the entire export tree to be moved to a new disk/location
# whilst preserving the UUID to filename mappping
self._path = pathlib.Path(dbfile).parent
self._conn = self._open_export_db(dbfile)
self._insert_run_info()
def _open_export_db(self, dbfile):
""" open export database and return a db connection
if dbfile does not exist, will create and initialize the database
returns: connection to the database
"""
if not os.path.isfile(dbfile):
logging.debug(f"dbfile {dbfile} doesn't exist, creating in memory version")
conn = self._get_db_connection()
if conn:
self._create_db_tables(conn)
else:
raise Exception("Error getting connection to in-memory database")
else:
logging.debug(f"dbfile {dbfile} exists, opening it and copying to memory")
try:
conn = sqlite3.connect(dbfile)
except Error as e:
logging.warning(e)
raise e
tempfile = StringIO()
for line in conn.iterdump():
tempfile.write("%s\n" % line)
conn.close()
tempfile.seek(0)
# Create a database in memory and import from tempfile
conn = sqlite3.connect(":memory:")
conn.cursor().executescript(tempfile.read())
conn.commit()
return conn
def _get_db_connection(self):
""" return db connection to in memory database """
try:
conn = sqlite3.connect(":memory:")
except Error as e:
logging.warning(e)
conn = None
return conn

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.22.10"
__version__ = "0.31.2"

218
osxphotos/albuminfo.py Normal file
View File

@@ -0,0 +1,218 @@
"""
AlbumInfo and FolderInfo classes for dealing with albums and folders
AlbumInfo class
Represents a single Album in the Photos library and provides access to the album's attributes
PhotosDB.albums() returns a list of AlbumInfo objects
FolderInfo class
Represents a single Folder in the Photos library and provides access to the folders attributes
PhotosDB.folders() returns a list of FolderInfo objects
"""
import logging
from ._constants import (
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_TOP_LEVEL_ALBUM,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_FOLDER_KIND,
)
class AlbumInfo:
"""
Info about a specific Album, contains all the details about the album
including folders, photos, etc.
"""
def __init__(self, db=None, uuid=None):
self._uuid = uuid
self._db = db
self._title = self._db._dbalbum_details[uuid]["title"]
@property
def title(self):
""" return title / name of album """
return self._title
@property
def uuid(self):
""" return uuid of album """
return self._uuid
@property
def photos(self):
""" return list of photos contained in album """
try:
return self._photos
except AttributeError:
if self.uuid in self._db._dbalbums_album:
uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid])
self._photos = self._db.photos(uuid=uuid)
# PhotosDB.photos does not preserve order when passing in list of uuids
# so need to build photo list one a time
# sort uuids by sort order
sorted_uuid = sorted(zip(sort_order, uuid))
self._photos = [
self._db.photos(uuid=[uuid])[0] for _, uuid in sorted_uuid
]
else:
self._photos = []
return self._photos
@property
def folder_names(self):
""" return hierarchical list of folders the album is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2", ...]
returns empty list if album is not in any folders """
try:
return self._folder_names
except AttributeError:
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
return self._folder_names
@property
def folder_list(self):
""" return hierarchical list of folders the album is contained in
as list of FolderInfo objects in form
["Top level folder", "sub folder 1", "sub folder 2", ...]
returns empty list if album is not in any folders """
try:
return self._folders
except AttributeError:
self._folders = self._db._album_folder_hierarchy_folderinfo(self._uuid)
return self._folders
@property
def parent(self):
""" returns FolderInfo object for parent folder or None if no parent (e.g. top-level album) """
try:
return self._parent
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
parent_uuid = self._db._dbalbum_details[self._uuid]["folderUuid"]
self._parent = (
FolderInfo(db=self._db, uuid=parent_uuid)
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
else None
)
else:
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
self._parent = (
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
if parent_pk != self._db._folder_root_pk
else None
)
return self._parent
def __len__(self):
""" return number of photos contained in album """
return len(self.photos)
class FolderInfo:
"""
Info about a specific folder, contains all the details about the folder
including folders, albums, etc
"""
def __init__(self, db=None, uuid=None):
self._uuid = uuid
self._db = db
if self._db._db_version <= _PHOTOS_4_VERSION:
self._pk = None
self._title = self._db._dbfolder_details[uuid]["name"]
else:
self._pk = self._db._dbalbum_details[uuid]["pk"]
self._title = self._db._dbalbum_details[uuid]["title"]
@property
def title(self):
""" return title / name of folder"""
return self._title
@property
def uuid(self):
""" return uuid of folder """
return self._uuid
@property
def album_info(self):
""" return list of albums (as AlbumInfo objects) contained in the folder """
try:
return self._albums
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
albums = [
AlbumInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["albumSubclass"] == _PHOTOS_4_ALBUM_KIND
and detail["folderUuid"] == self._uuid
]
else:
albums = [
AlbumInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["kind"] == _PHOTOS_5_ALBUM_KIND
and detail["parentfolder"] == self._pk
]
self._albums = albums
return self._albums
@property
def parent(self):
""" returns FolderInfo object for parent or None if no parent (e.g. top-level folder) """
try:
return self._parent
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
parent_uuid = self._db._dbfolder_details[self._uuid]["parentFolderUuid"]
self._parent = (
FolderInfo(db=self._db, uuid=parent_uuid)
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
else None
)
else:
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
self._parent = (
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
if parent_pk != self._db._folder_root_pk
else None
)
return self._parent
@property
def subfolders(self):
""" return list of folders (as FolderInfo objects) contained in the folder """
try:
return self._folders
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
folders = [
FolderInfo(db=self._db, uuid=folder)
for folder, detail in self._db._dbfolder_details.items()
if not detail["intrash"]
and not detail["isMagic"]
and detail["parentFolderUuid"] == self._uuid
]
else:
folders = [
FolderInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["kind"] == _PHOTOS_5_FOLDER_KIND
and detail["parentfolder"] == self._pk
]
self._folders = folders
return self._folders
def __len__(self):
""" returns count of folders + albums contained in the folder """
return len(self.subfolders) + len(self.album_info)

View File

@@ -0,0 +1,70 @@
""" Simple formatting of datetime.datetime objects """
import datetime
class DateTimeFormatter:
""" provides property access to formatted datetime.datetime strftime values """
def __init__(self, dt: datetime.datetime):
self.dt = dt
@property
def date(self):
""" ISO date in form 2020-03-22 """
return self.dt.date().isoformat()
@property
def year(self):
""" 4 digit year """
return f"{self.dt.year}"
@property
def yy(self):
""" 2 digit year """
return f"{self.dt.strftime('%y')}"
@property
def mm(self):
""" 2 digit month """
return f"{self.dt.strftime('%m')}"
@property
def month(self):
""" Month as locale's full name """
return f"{self.dt.strftime('%B')}"
@property
def mon(self):
""" Month as locale's abbreviated name """
return f"{self.dt.strftime('%b')}"
@property
def dd(self):
""" 2-digit day of the month """
return f"{self.dt.strftime('%d')}"
@property
def dow(self):
""" Day of week as locale's name """
return f"{self.dt.strftime('%A')}"
@property
def doy(self):
""" Julian day of year starting from 001 """
return f"{self.dt.strftime('%j')}"
@property
def hour(self):
""" 2-digit hour """
return f"{self.dt.strftime('%H')}"
@property
def min(self):
""" 2-digit minute """
return f"{self.dt.strftime('%M')}"
@property
def sec(self):
""" 2-digit second """
return f"{self.dt.strftime('%S')}"

View File

@@ -0,0 +1,57 @@
""" datetime utilities """
import datetime
def get_local_tz():
""" return local timezone as datetime.timezone tzinfo """
local_tz = (
datetime.datetime.now(datetime.timezone(datetime.timedelta(0)))
.astimezone()
.tzinfo
)
return local_tz
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)}")
dt_new = dt.replace(tzinfo=None)
return dt_new
def datetime_has_tz(dt):
""" return True if datetime dt has tzinfo else False
dt: datetime.datetime
returns True if dt is timezone aware, else False """
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 True
return False
def datetime_naive_to_local(dt):
""" convert naive (timezone unaware) datetime.datetime
to aware timezone in local timezone
dt: datetime.datetime without timezone
returns: datetime.datetime with local timezone """
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.tizinfo.utcoffset(dt)}"
)
dt_local = dt.replace(tzinfo=get_local_tz())
return dt_local

251
osxphotos/exiftool.py Normal file
View File

@@ -0,0 +1,251 @@
""" Yet another simple exiftool wrapper
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
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 """
import json
import logging
import os
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}"
EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
@lru_cache(maxsize=1)
def get_exiftool_path():
""" return path of exiftool, cache result """
result = subprocess.run(["which", "exiftool"], stdout=subprocess.PIPE)
exiftool_path = result.stdout.decode("utf-8")
if _debug():
logging.debug("exiftool path = %s" % (exiftool_path))
if exiftool_path:
return exiftool_path.rstrip()
else:
raise FileNotFoundError(
"Could not find exiftool. Please download and install from "
"https://exiftool.org/"
)
class _ExifToolProc:
""" Runs exiftool in a subprocess via Popen
Creates a singleton object """
def __new__(cls, *args, **kwargs):
""" create new object or return instance of already created singleton """
if not hasattr(cls, "instance") or not cls.instance:
cls.instance = super().__new__(cls)
return cls.instance
def __init__(self, exiftool=None):
""" construct _ExifToolProc singleton object or return instance of already created object
exiftool: optional path to exiftool binary (if not provided, will search path to find it) """
if hasattr(self, "_process_running") and self._process_running:
# already running
if exiftool is not None:
logging.warning(
f"exiftool subprocess already running, "
f"ignoring exiftool={exiftool}"
)
return
self._exiftool = exiftool if exiftool else get_exiftool_path()
self._process_running = False
self._start_proc()
@property
def process(self):
""" return the exiftool subprocess """
if self._process_running:
return self._process
else:
raise ValueError("exiftool process is not running")
@property
def pid(self):
""" return process id (PID) of the exiftool process """
return self._process.pid
@property
def exiftool(self):
""" return path to exiftool process """
return self._exiftool
def _start_proc(self):
""" start exiftool in batch mode """
if self._process_running:
logging.warning("exiftool already running: {self._process}")
return
# open exiftool process
self._process = subprocess.Popen(
[
self._exiftool,
"-stay_open", # keep process open in batch mode
"True", # -stay_open=True, keep process open in batch mode
"-@", # read command-line arguments from file
"-", # 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)
"-G", # print group name for each tag
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
self._process_running = True
def _stop_proc(self):
""" stop the exiftool process if it's running, otherwise, do nothing """
if not self._process_running:
logging.warning("exiftool process is not running")
return
self._process.stdin.write(b"-stay_open\n")
self._process.stdin.write(b"False\n")
self._process.stdin.flush()
try:
self._process.communicate(timeout=5)
except subprocess.TimeoutExpired:
logging.warning(
f"exiftool pid {self._process.pid} did not exit, killing it"
)
self._process.kill()
self._process.communicate()
del self._process
self._process_running = False
def __del__(self):
self._stop_proc()
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 """
self.file = filepath
self.overwrite = overwrite
self.data = {}
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 """
if value is None:
value = ""
command = [f"-{tag}={value}"]
if self.overwrite:
command.append("-overwrite_original")
self.run_commands(*command)
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
"""
if not values:
raise ValueError("Must pass at least one value")
command = []
for value in values:
if value is None:
raise ValueError("Can't add None value to tag")
command.append(f"-{tag}+={value}")
if self.overwrite:
command.append("-overwrite_original")
if command:
self.run_commands(*command)
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 """
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")
filename = os.fsencode(self.file) if not no_file else b""
command_str = (
b"\n".join([c.encode("utf-8") for c in commands])
+ b"\n"
+ filename
+ b"\n"
+ 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""
while EXIFTOOL_STAYOPEN_EOF not in str(output):
output += self._process.stdout.readline().strip()
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN]
@property
def pid(self):
""" return process id (PID) of the exiftool process """
return self._process.pid
@property
def version(self):
""" returns exiftool version """
ver = self.run_commands("-ver", no_file=True)
return ver.decode("utf-8")
def as_dict(self):
""" return dictionary of all EXIF tags and values from exiftool
returns empty dict if no tags
"""
json_str = self.run_commands("-json")
if json_str:
exifdict = json.loads(json_str)
return exifdict[0]
else:
return dict()
def json(self):
""" returns JSON string containing all EXIF tags and values from exiftool """
return self.run_commands("-json")
def _read_exif(self):
""" read exif data from file """
data = self.as_dict()
self.data = {k: v for k, v in data.items()}
def __str__(self):
return f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"

178
osxphotos/fileutil.py Normal file
View File

@@ -0,0 +1,178 @@
""" FileUtil class with methods for copy, hardlink, unlink, etc. """
import logging
import os
import pathlib
import stat
import subprocess
import sys
from abc import ABC, abstractmethod
class FileUtilABC(ABC):
""" Abstract base class for FileUtil """
@classmethod
@abstractmethod
def hardlink(cls, src, dest):
pass
@classmethod
@abstractmethod
def copy(cls, src, dest, norsrc=False):
pass
@classmethod
@abstractmethod
def unlink(cls, dest):
pass
@classmethod
@abstractmethod
def cmp_sig(cls, file1, file2):
pass
@classmethod
@abstractmethod
def file_sig(cls, file1):
pass
class FileUtilMacOS(FileUtilABC):
""" Various file utilities """
@classmethod
def hardlink(cls, src, dest):
""" Hardlinks a file from src path to dest path
src: source path as string
dest: destination path as string
Raises exception if linking fails or either path is None """
if src is None or dest is None:
raise ValueError("src and dest must not be None", src, dest)
if not os.path.isfile(src):
raise FileNotFoundError("src file does not appear to exist", src)
# if error on copy, subprocess will raise CalledProcessError
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):
""" Copies a file from src path to dest path
src: source path as string
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 """
if src is None or dest is None:
raise ValueError("src and dest must not be None", src, dest)
if not os.path.isfile(src):
raise FileNotFoundError("src file does not appear to exist", src)
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
@classmethod
def unlink(cls, filepath):
""" unlink filepath; if it's pathlib.Path, use Path.unlink, otherwise use os.unlink """
if isinstance(filepath, pathlib.Path):
filepath.unlink()
else:
os.unlink(filepath)
@classmethod
def cmp_sig(cls, f1, s2):
"""Compare file f1 to signature s2.
Arguments:
f1 -- File name
s2 -- stats as returned by sig
Return value:
True if the files are the same, False otherwise.
"""
if not s2:
return False
s1 = cls._sig(os.stat(f1))
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
return False
return s1 == s2
@classmethod
def file_sig(cls, f1):
""" return os.stat signature for file f1 """
return cls._sig(os.stat(f1))
@staticmethod
def _sig(st):
return (stat.S_IFMT(st.st_mode), st.st_size, st.st_mtime)
class FileUtil(FileUtilMacOS):
""" Various file utilities """
pass
class FileUtilNoOp(FileUtil):
""" No-Op implementation of FileUtil for testing / dry-run mode
all methods with exception of cmp_sig and file_cmp are no-op
cmp_sig functions as FileUtil.cmp_sig does
file_cmp returns mock data
"""
@staticmethod
def noop(*args):
pass
verbose = noop
def __new__(cls, verbose=None):
if verbose:
if callable(verbose):
cls.verbose = verbose
else:
raise ValueError(f"verbose {verbose} not callable")
return super(FileUtilNoOp, cls).__new__(cls)
@classmethod
def hardlink(cls, src, dest):
cls.verbose(f"hardlink: {src} {dest}")
@classmethod
def copy(cls, src, dest, norsrc=False):
cls.verbose(f"copy: {src} {dest}")
@classmethod
def unlink(cls, dest):
cls.verbose(f"unlink: {dest}")
@classmethod
def file_sig(cls, file1):
cls.verbose(f"file_sig: {file1}")
return (42, 42, 42)

408
osxphotos/personinfo.py Normal file
View File

@@ -0,0 +1,408 @@
""" PhotoInfo and FaceInfo classes to expose info about persons and faces in the Photos library """
import json
import logging
import math
class PersonInfo:
""" Info about a person in the Photos library
"""
def __init__(self, db=None, pk=None):
""" Creates a new PersonInfo instance
Arguments:
db: instance of PhotosDB object
pk: primary key value of person to initialize PersonInfo with
Returns:
PersonInfo instance
"""
self._db = db
self._pk = pk
person = self._db._dbpersons_pk[pk]
self.uuid = person["uuid"]
self.name = person["fullname"]
self.display_name = person["displayname"]
self.keyface = person["keyface"]
self.facecount = person["facecount"]
@property
def keyphoto(self):
try:
return self._keyphoto
except AttributeError:
person = self._db._dbpersons_pk[self._pk]
if person["photo_uuid"]:
try:
key_photo = self._db.get_photo(person["photo_uuid"])
except IndexError:
key_photo = None
else:
key_photo = None
self._keyphoto = key_photo
return self._keyphoto
@property
def photos(self):
""" Returns list of PhotoInfo objects associated with this person """
return self._db.photos_by_uuid(self._db._dbfaces_pk[self._pk])
@property
def face_info(self):
""" Returns a list of FaceInfo objects associated with this person sorted by quality score
Highest quality face is result[0] and lowest quality face is result[n]
"""
try:
faces = self._db._db_faceinfo_person[self._pk]
return sorted(
[FaceInfo(db=self._db, pk=face) for face in faces],
key=lambda face: face.quality,
reverse=True,
)
except KeyError:
# no faces
return []
def json(self):
""" Returns JSON representation of class instance """
keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None
person = {
"uuid": self.uuid,
"name": self.name,
"displayname": self.display_name,
"keyface": self.keyface,
"facecount": self.facecount,
"keyphoto": keyphoto,
}
return json.dumps(person)
def __str__(self):
return f"PersonInfo(name={self.name}, display_name={self.display_name}, uuid={self.uuid}, facecount={self.facecount})"
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
return all(
getattr(self, field) == getattr(other, field) for field in ["_db", "_pk"]
)
def __ne__(self, other):
return not self.__eq__(other)
class FaceInfo:
""" Info about a face in the Photos library
"""
def __init__(self, db=None, pk=None):
""" Creates a new FaceInfo instance
Arguments:
db: instance of PhotosDB object
pk: primary key value of face to init the object with
Returns:
FaceInfo instance
"""
self._db = db
self._pk = pk
face = self._db._db_faceinfo_pk[pk]
self._info = face
self.uuid = face["uuid"]
self.name = face["fullname"]
self.asset_uuid = face["asset_uuid"]
self._person_pk = face["person"]
self.center_x = face["centerx"]
self.center_y = face["centery"]
self.mouth_x = face["mouthx"]
self.mouth_y = face["mouthy"]
self.left_eye_x = face["lefteyex"]
self.left_eye_y = face["lefteyey"]
self.right_eye_x = face["righteyex"]
self.right_eye_y = face["righteyey"]
self.size = face["size"]
self.quality = face["quality"]
self.source_width = face["sourcewidth"]
self.source_height = face["sourceheight"]
self.has_smile = face["has_smile"]
self.left_eye_closed = face["left_eye_closed"]
self.right_eye_closed = face["right_eye_closed"]
self.manual = face["manual"]
self.face_type = face["facetype"]
self.age_type = face["agetype"]
self.bald_type = face["baldtype"]
self.eye_makeup_type = face["eyemakeuptype"]
self.eye_state = face["eyestate"]
self.facial_hair_type = face["facialhairtype"]
self.gender_type = face["gendertype"]
self.glasses_type = face["glassestype"]
self.hair_color_type = face["haircolortype"]
self.intrash = face["intrash"]
self.lip_makeup_type = face["lipmakeuptype"]
self.smile_type = face["smiletype"]
@property
def center(self):
""" Coordinates, in PIL format, for center of face
Returns:
tuple of coordinates in form (x, y)
"""
return self._make_point((self.center_x, self.center_y))
@property
def size_pixels(self):
""" Size of face in pixels (centered around center_x, center_y)
Returns:
size, in int pixels, of a circle drawn around the center of the face
"""
photo = self.photo
size_reference = photo.width if photo.width > photo.height else photo.height
return self.size * size_reference
@property
def mouth(self):
""" Coordinates, in PIL format, for mouth position
Returns:
tuple of coordinates in form (x, y)
"""
return self._make_point_with_rotation((self.mouth_x, self.mouth_y))
@property
def left_eye(self):
""" Coordinates, in PIL format, for left eye position
Returns:
tuple of coordinates in form (x, y)
"""
return self._make_point_with_rotation((self.left_eye_x, self.left_eye_y))
@property
def right_eye(self):
""" Coordinates, in PIL format, for right eye position
Returns:
tuple of coordinates in form (x, y)
"""
return self._make_point_with_rotation((self.right_eye_x, self.right_eye_y))
@property
def person_info(self):
""" PersonInfo instance for person associated with this face """
try:
return self._person
except AttributeError:
self._person = PersonInfo(db=self._db, pk=self._person_pk)
return self._person
@property
def photo(self):
""" PhotoInfo instance associated with this face """
try:
return self._photo
except AttributeError:
self._photo = self._db.get_photo(self.asset_uuid)
if self._photo is None:
logging.warning(f"Could not get photo for uuid: {self.asset_uuid}")
return self._photo
def face_rect(self):
""" Get face rectangle coordinates for current version of the associated image
If image has been edited, rectangle applies to edited version, otherwise original version
Coordinates in format and reference frame used by PIL
Returns:
list [(x0, x1), (y0, y1)] of coordinates in reference frame used by PIL
"""
photo = self.photo
size_reference = photo.width if photo.width > photo.height else photo.height
radius = (self.size / 2) * size_reference
x, y = self._make_point((self.center_x, self.center_y))
x0, y0 = x - radius, y - radius
x1, y1 = x + radius, y + radius
return [(x0, y0), (x1, y1)]
def roll_pitch_yaw(self):
""" Roll, pitch, yaw of face in radians as tuple """
info = self._info
roll = 0 if info["roll"] is None else info["roll"]
pitch = 0 if info["pitch"] is None else info["pitch"]
yaw = 0 if info["yaw"] is None else info["yaw"]
return (roll, pitch, yaw)
@property
def roll(self):
""" Return roll angle in radians of the face region """
roll, _, _ = self.roll_pitch_yaw()
return roll
@property
def pitch(self):
""" Return pitch angle in radians of the face region """
_, pitch, _ = self.roll_pitch_yaw()
return pitch
@property
def yaw(self):
""" Return yaw angle in radians of the face region """
_, _, yaw = self.roll_pitch_yaw()
return yaw
def _make_point(self, xy):
""" Translate an (x, y) tuple based on image orientation
and convert to image coordinates
Arguments:
xy: tuple of (x, y) coordinates for point to translate
in format used by Photos (percent of height/width)
Returns:
(x, y) tuple of translated coordinates in pixels in PIL format/reference frame
"""
# Reference: https://github.com/neilpa/phace/blob/7594776480505d0c389688a42099c94ac5d34f3f/cmd/phace/draw.go#L79-L94
orientation = self.photo.orientation
x, y = xy
dx = self.photo.width
dy = self.photo.height
if orientation in [1, 2]:
y = 1.0 - y
elif orientation in [3, 4]:
x = 1.0 - x
elif orientation in [5, 6]:
x, y = 1.0 - y, 1.0 - x
dx, dy = dy, dx
elif orientation in [7, 8]:
x, y = y, x
dx, dy = dy, dx
else:
logging.warning(f"Unhandled orientation: {orientation}")
return (int(x * dx), int(y * dy))
def _make_point_with_rotation(self, xy):
""" Translate an (x, y) tuple based on image orientation and rotation
and convert to image coordinates
Arguments:
xy: tuple of (x, y) coordinates for point to translate
in format used by Photos (percent of height/width)
Returns:
(x, y) tuple of translated coordinates in pixels in PIL format/reference frame
"""
# convert to image coordinates
x, y = self._make_point(xy)
# rotate about center
xmid, ymid = self.center
roll, _, _ = self.roll_pitch_yaw()
xr, yr = rotate_image_point(x, y, xmid, ymid, roll)
return (int(xr), int(yr))
def asdict(self):
""" Returns dict representation of class instance """
roll, pitch, yaw = self.roll_pitch_yaw()
return {
"_pk": self._pk,
"uuid": self.uuid,
"name": self.name,
"asset_uuid": self.asset_uuid,
"_person_pk": self._person_pk,
"center_x": self.center_x,
"center_y": self.center_y,
"center": self.center,
"mouth_x": self.mouth_x,
"mouth_y": self.mouth_y,
"mouth": self.mouth,
"left_eye_x": self.left_eye_x,
"left_eye_y": self.left_eye_y,
"left_eye": self.left_eye,
"right_eye_x": self.right_eye_x,
"right_eye_y": self.right_eye_y,
"right_eye": self.right_eye,
"size": self.size,
"face_rect": self.face_rect(),
"roll": roll,
"pitch": pitch,
"yaw": yaw,
"quality": self.quality,
"source_width": self.source_width,
"source_height": self.source_height,
"has_smile": self.has_smile,
"left_eye_closed": self.left_eye_closed,
"right_eye_closed": self.right_eye_closed,
"manual": self.manual,
"face_type": self.face_type,
"age_type": self.age_type,
"bald_type": self.bald_type,
"eye_makeup_type": self.eye_makeup_type,
"eye_state": self.eye_state,
"facial_hair_type": self.facial_hair_type,
"gender_type": self.gender_type,
"glasses_type": self.glasses_type,
"hair_color_type": self.hair_color_type,
"intrash": self.intrash,
"lip_makeup_type": self.lip_makeup_type,
"smile_type": self.smile_type,
}
def json(self):
""" Return JSON representation of FaceInfo instance """
return json.dumps(self.asdict())
def __str__(self):
return f"FaceInfo(uuid={self.uuid}, center_x={self.center_x}, center_y = {self.center_y}, size={self.size}, person={self.name}, asset_uuid={self.asset_uuid})"
def __repr__(self):
return f"FaceInfo(db={self._db}, pk={self._pk})"
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
return all(
getattr(self, field) == getattr(other, field) for field in ["_db", "_pk"]
)
def __ne__(self, other):
return not self.__eq__(other)
def rotate_image_point(x, y, xmid, ymid, angle):
""" rotate image point about xm, ym by angle in radians
Arguments:
x: x coordinate of point to rotate
y: y coordinate of point to rotate
xmid: x coordinate of center point to rotate about
ymid: y coordinate of center point to rotate about
angle: angle in radians about which to coordinate,
counter-clockwise is positive
Returns:
tuple of rotated points (xr, yr)
"""
# translate point relative to the mid point
x = x - xmid
y = y - ymid
# rotate by angle and translate back
# the photo coordinate system is downwards y is positive so
# need to adjust the rotation accordingly
cos_angle = math.cos(angle)
sin_angle = math.sin(angle)
xr = x * cos_angle + y * sin_angle + xmid
yr = -x * sin_angle + y * cos_angle + ymid
return (xr, yr)

View File

@@ -0,0 +1,10 @@
"""
PhotoInfo class
Represents a single photo in the Photos library and provides access to the photo's attributes
PhotosDB.photos() returns a list of PhotoInfo objects
"""
from ._photoinfo_exifinfo import ExifInfo
from ._photoinfo_export import ExportResults
from ._photoinfo_scoreinfo import ScoreInfo
from .photoinfo import PhotoInfo

View File

@@ -0,0 +1,94 @@
""" PhotoInfo methods to expose EXIF info from the library """
import logging
from dataclasses import dataclass
from .._constants import _PHOTOS_4_VERSION
@dataclass(frozen=True)
class ExifInfo:
""" EXIF info associated with a photo from the Photos library """
flash_fired: bool
iso: int
metering_mode: int
sample_rate: int
track_format: int
white_balance: int
aperture: float
bit_rate: float
duration: float
exposure_bias: float
focal_length: float
fps: float
latitude: float
longitude: float
shutter_speed: float
camera_make: str
camera_model: str
codec: str
lens_model: str
@property
def exif_info(self):
""" Returns an ExifInfo object with the EXIF data for photo
Note: the returned EXIF data is the data Photos stores in the database on import;
ExifInfo does not provide access to the EXIF info in the actual image file
Some or all of the fields may be None
Only valid for Photos 5; on earlier database returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"exif_info not implemented for this database version")
return None
try:
exif = self._db._db_exifinfo_uuid[self.uuid]
exif_info = ExifInfo(
iso=exif["ZISO"],
flash_fired=True if exif["ZFLASHFIRED"] == 1 else False,
metering_mode=exif["ZMETERINGMODE"],
sample_rate=exif["ZSAMPLERATE"],
track_format=exif["ZTRACKFORMAT"],
white_balance=exif["ZWHITEBALANCE"],
aperture=exif["ZAPERTURE"],
bit_rate=exif["ZBITRATE"],
duration=exif["ZDURATION"],
exposure_bias=exif["ZEXPOSUREBIAS"],
focal_length=exif["ZFOCALLENGTH"],
fps=exif["ZFPS"],
latitude=exif["ZLATITUDE"],
longitude=exif["ZLONGITUDE"],
shutter_speed=exif["ZSHUTTERSPEED"],
camera_make=exif["ZCAMERAMAKE"],
camera_model=exif["ZCAMERAMODEL"],
codec=exif["ZCODEC"],
lens_model=exif["ZLENSMODEL"],
)
except KeyError:
logging.debug(f"Could not find exif record for uuid {self.uuid}")
exif_info = ExifInfo(
iso=None,
flash_fired=None,
metering_mode=None,
sample_rate=None,
track_format=None,
white_balance=None,
aperture=None,
bit_rate=None,
duration=None,
exposure_bias=None,
focal_length=None,
fps=None,
latitude=None,
longitude=None,
shutter_speed=None,
camera_make=None,
camera_model=None,
codec=None,
lens_model=None,
)
return exif_info

View File

@@ -0,0 +1,35 @@
""" Implementation for PhotoInfo.exiftool property which returns ExifTool object for a photo """
import logging
import os
from ..exiftool import ExifTool, get_exiftool_path
@property
def exiftool(self):
""" Returns an ExifTool object for the photo
requires that exiftool (https://exiftool.org/) be installed
If exiftool not installed, logs warning and returns None
If photo path is missing, returns None
"""
try:
# return the memoized instance if it exists
return self._exiftool
except AttributeError:
try:
exiftool_path = get_exiftool_path()
if self.path is not None and os.path.isfile(self.path):
exiftool = ExifTool(self.path)
else:
exiftool = None
logging.debug(f"exiftool: missing path {self.uuid}")
except FileNotFoundError:
# get_exiftool_path raises FileNotFoundError if exiftool not found
exiftool = None
logging.warning(
f"exiftool not in path; download and install from https://exiftool.org/"
)
self._exiftool = exiftool
return self._exiftool

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
""" PhotoInfo methods to expose computed score info from the library """
import logging
from dataclasses import dataclass
from .._constants import _PHOTOS_4_VERSION
@dataclass(frozen=True)
class ScoreInfo:
""" Computed photo score info associated with a photo from the Photos library """
overall: float
curation: float
promotion: float
highlight_visibility: float
behavioral: float
failure: float
harmonious_color: float
immersiveness: float
interaction: float
interesting_subject: float
intrusive_object_presence: float
lively_color: float
low_light: float
noise: float
pleasant_camera_tilt: float
pleasant_composition: float
pleasant_lighting: float
pleasant_pattern: float
pleasant_perspective: float
pleasant_post_processing: float
pleasant_reflection: float
pleasant_symmetry: float
sharply_focused_subject: float
tastefully_blurred: float
well_chosen_subject: float
well_framed_subject: float
well_timed_shot: float
@property
def score(self):
""" Computed score information for a photo
Returns:
ScoreInfo instance
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"score not implemented for this database version")
return None
try:
return self._scoreinfo # pylint: disable=access-member-before-definition
except AttributeError:
try:
scores = self._db._db_scoreinfo_uuid[self.uuid]
self._scoreinfo = ScoreInfo(
overall=scores["overall_aesthetic"],
curation=scores["curation"],
promotion=scores["promotion"],
highlight_visibility=scores["highlight_visibility"],
behavioral=scores["behavioral"],
failure=scores["failure"],
harmonious_color=scores["harmonious_color"],
immersiveness=scores["immersiveness"],
interaction=scores["interaction"],
interesting_subject=scores["interesting_subject"],
intrusive_object_presence=scores["intrusive_object_presence"],
lively_color=scores["lively_color"],
low_light=scores["low_light"],
noise=scores["noise"],
pleasant_camera_tilt=scores["pleasant_camera_tilt"],
pleasant_composition=scores["pleasant_composition"],
pleasant_lighting=scores["pleasant_lighting"],
pleasant_pattern=scores["pleasant_pattern"],
pleasant_perspective=scores["pleasant_perspective"],
pleasant_post_processing=scores["pleasant_post_processing"],
pleasant_reflection=scores["pleasant_reflection"],
pleasant_symmetry=scores["pleasant_symmetry"],
sharply_focused_subject=scores["sharply_focused_subject"],
tastefully_blurred=scores["tastefully_blurred"],
well_chosen_subject=scores["well_chosen_subject"],
well_framed_subject=scores["well_framed_subject"],
well_timed_shot=scores["well_timed_shot"],
)
return self._scoreinfo
except KeyError:
self._scoreinfo = ScoreInfo(
overall=0.0,
curation=0.0,
promotion=0.0,
highlight_visibility=0.0,
behavioral=0.0,
failure=0.0,
harmonious_color=0.0,
immersiveness=0.0,
interaction=0.0,
interesting_subject=0.0,
intrusive_object_presence=0.0,
lively_color=0.0,
low_light=0.0,
noise=0.0,
pleasant_camera_tilt=0.0,
pleasant_composition=0.0,
pleasant_lighting=0.0,
pleasant_pattern=0.0,
pleasant_perspective=0.0,
pleasant_post_processing=0.0,
pleasant_reflection=0.0,
pleasant_symmetry=0.0,
sharply_focused_subject=0.0,
tastefully_blurred=0.0,
well_chosen_subject=0.0,
well_framed_subject=0.0,
well_timed_shot=0.0,
)
return self._scoreinfo

View File

@@ -0,0 +1,93 @@
""" Methods and class for PhotoInfo exposing SearchInfo data such as labels
Adds the following properties to PhotoInfo (valid only for Photos 5):
search_info: returns a SearchInfo object
labels: returns list of labels
labels_normalized: returns list of normalized labels
"""
from .._constants import _PHOTOS_4_VERSION, SEARCH_CATEGORY_LABEL
@property
def search_info(self):
""" returns SearchInfo object for photo
only valid on Photos 5, on older libraries, returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
# memoize SearchInfo object
try:
return self._search_info
except AttributeError:
self._search_info = SearchInfo(self)
return self._search_info
@property
def labels(self):
""" returns list of labels applied to photo by Photos image categorization
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info.labels
@property
def labels_normalized(self):
""" returns normalized list of labels applied to photo by Photos image categorization
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info.labels_normalized
class SearchInfo:
""" Info about search terms such as machine learning labels that Photos knows about a photo """
def __init__(self, photo):
""" photo: PhotoInfo object """
if photo._db._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError(
f"search info not implemented for this database version"
)
self._photo = photo
self.uuid = photo.uuid
try:
# get search info for this UUID
# there might not be any search info data (e.g. if Photo was missing or photoanalysisd not run yet)
self._db_searchinfo = photo._db._db_searchinfo_uuid[self.uuid]
except KeyError:
self._db_searchinfo = None
@property
def labels(self):
""" return list of labels associated with Photo """
if self._db_searchinfo:
labels = [
rec["content_string"]
for rec in self._db_searchinfo
if rec["category"] == SEARCH_CATEGORY_LABEL
]
else:
labels = []
return labels
@property
def labels_normalized(self):
""" return list of normalized labels associated with Photo """
if self._db_searchinfo:
labels = [
rec["normalized_string"]
for rec in self._db_searchinfo
if rec["category"] == SEARCH_CATEGORY_LABEL
]
else:
labels = []
return labels

View File

@@ -4,34 +4,35 @@ Represents a single photo in the Photos library and provides access to the photo
PhotosDB.photos() returns a list of PhotoInfo objects
"""
import dataclasses
import glob
import json
import logging
import os
import os.path
import pathlib
import re
import subprocess
import sys
from datetime import timedelta, timezone
from pprint import pformat
import yaml
from mako.template import Template
from ._constants import (
from .._constants import (
_MOVIE_TYPE,
_PHOTO_TYPE,
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_SHARED_ALBUM_KIND,
_PHOTOS_5_SHARED_PHOTO_PATH,
_PHOTOS_5_VERSION,
_TEMPLATE_DIR,
_XMP_TEMPLATE_NAME,
)
from .utils import (
_copy_file,
_export_photo_uuid_applescript,
_get_resource_loc,
dd_to_dms_str,
)
from ..albuminfo import AlbumInfo
from ..personinfo import FaceInfo, PersonInfo
from ..phototemplate import PhotoTemplate
from ..placeinfo import PlaceInfo4, PlaceInfo5
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
class PhotoInfo:
@@ -40,6 +41,27 @@ class PhotoInfo:
including keywords, persons, albums, uuid, path, etc.
"""
# import additional methods
from ._photoinfo_searchinfo import (
search_info,
labels,
labels_normalized,
SearchInfo,
)
from ._photoinfo_exifinfo import exif_info, ExifInfo
from ._photoinfo_exiftool import exiftool
from ._photoinfo_export import (
export,
export2,
_export_photo,
_exiftool_json_sidecar,
_write_exif_data,
_write_sidecar,
_xmp_sidecar,
ExportResults,
)
from ._photoinfo_scoreinfo import score, ScoreInfo
def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid
self._info = info
@@ -48,7 +70,13 @@ class PhotoInfo:
@property
def filename(self):
""" filename of the picture """
return self._info["filename"]
# sourcery off
if self.has_raw and self.raw_original:
# return name of the RAW file
# TODO: not yet implemented
return self._info["filename"]
else:
return self._info["filename"]
@property
def original_filename(self):
@@ -59,13 +87,8 @@ class PhotoInfo:
@property
def date(self):
""" image creation date as timezone aware datetime object """
imagedate = self._info["imageDate"]
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
imagedate_utc = imagedate.astimezone(tz=tz)
return imagedate_utc
return self._info["imageDate"]
@property
def date_modified(self):
""" image modification date as timezone aware datetime object
@@ -75,8 +98,7 @@ class PhotoInfo:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
imagedate_utc = imagedate.astimezone(tz=tz)
return imagedate_utc
return imagedate.astimezone(tz=tz)
else:
return None
@@ -93,7 +115,7 @@ class PhotoInfo:
if self._info["isMissing"] == 1:
return photopath # path would be meaningless until downloaded
if self._db._db_version < _PHOTOS_5_VERSION:
if self._db._db_version <= _PHOTOS_4_VERSION:
vol = self._info["volume"]
if vol is not None:
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
@@ -114,8 +136,6 @@ class PhotoInfo:
)
return photopath
# if self._info["masterFingerprint"]:
# if masterFingerprint is not null, path appears to be valid
if self._info["directory"].startswith("/"):
photopath = os.path.join(self._info["directory"], self._info["filename"])
else:
@@ -124,13 +144,6 @@ class PhotoInfo:
)
return photopath
# if all else fails, photopath = None
# photopath = None
# logging.debug(
# f"WARNING: photopath None, masterFingerprint null, not shared {pformat(self._info)}"
# )
# return photopath
@property
def path_edited(self):
""" absolute path on disk of the edited picture """
@@ -141,7 +154,7 @@ class PhotoInfo:
photopath = None
if self._db._db_version < _PHOTOS_5_VERSION:
if self._db._db_version <= _PHOTOS_4_VERSION:
if self._info["hasAdjustments"]:
edit_id = self._info["edit_resource_id"]
if edit_id is not None:
@@ -241,7 +254,77 @@ class PhotoInfo:
# if self._info["isMissing"] == 1:
# photopath = None # path would be meaningless until downloaded
logging.debug(photopath)
# logging.debug(photopath)
return photopath
@property
def path_raw(self):
""" absolute path of associated RAW image or None if there is not one """
# In Photos 5, raw is in same folder as original but with _4.ext
# Unless "Copy Items to the Photos Library" is not checked
# then RAW image is not renamed but has same name is jpeg buth with raw extension
# Current implementation uses findfiles to find images with the correct raw UTI extension
# in same folder as the original and with same stem as original in form: original_stem*.raw_ext
# TODO: I don't like this -- would prefer a more deterministic approach but until I have more
# data on how Photos stores and retrieves RAW images, this seems to be working
if self._info["isMissing"] == 1:
return None # path would be meaningless until downloaded
if not self.has_raw:
return None # no raw image to get path for
# if self._info["shared"]:
# # shared photo
# photopath = os.path.join(
# self._db._library_path,
# _PHOTOS_5_SHARED_PHOTO_PATH,
# self._info["directory"],
# self._info["filename"],
# )
# return photopath
if self._db._db_version <= _PHOTOS_4_VERSION:
vol = self._info["raw_info"]["volume"]
if vol is not None:
photopath = os.path.join(
"/Volumes", vol, self._info["raw_info"]["imagePath"]
)
else:
photopath = os.path.join(
self._db._masters_path, self._info["raw_info"]["imagePath"]
)
if not os.path.isfile(photopath):
logging.debug(
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
filestem = pathlib.Path(self._info["filename"]).stem
raw_ext = get_preferred_uti_extension(self._info["UTI_raw"])
if self._info["directory"].startswith("/"):
filepath = self._info["directory"]
else:
filepath = os.path.join(self._db._masters_path, self._info["directory"])
glob_str = f"{filestem}*.{raw_ext}"
raw_file = findfiles(glob_str, filepath)
if len(raw_file) != 1:
# Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library
# that are missing do not always trigger is_missing = True as happens
# in earlier version so it's possible for this check to fail, if so, return None
logging.debug(f"Error getting path to RAW file: {filepath}/{glob_str}")
photopath = None
else:
photopath = os.path.join(filepath, raw_file[0])
if not os.path.isfile(photopath):
logging.debug(
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
return photopath
@@ -253,15 +336,56 @@ class PhotoInfo:
@property
def persons(self):
""" list of persons in picture """
return self._info["persons"]
return [self._db._dbpersons_pk[pk]["fullname"] for pk in self._info["persons"]]
@property
def person_info(self):
""" list of PersonInfo objects for person in picture """
try:
return self._personinfo
except AttributeError:
self._personinfo = [
PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"]
]
return self._personinfo
@property
def face_info(self):
""" list of FaceInfo objects for faces in picture """
try:
return self._faceinfo
except AttributeError:
try:
faces = self._db._db_faceinfo_uuid[self._uuid]
self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
except KeyError:
# no faces
self._faceinfo = []
return self._faceinfo
@property
def albums(self):
""" list of albums picture is contained in """
albums = []
for album in self._info["albums"]:
albums.append(self._db._dbalbum_details[album]["title"])
return albums
try:
return self._albums
except AttributeError:
album_uuids = self._get_album_uuids()
self._albums = list(
{self._db._dbalbum_details[album]["title"] for album in album_uuids}
)
return self._albums
@property
def album_info(self):
""" list of AlbumInfo objects representing albums the photos is contained in """
try:
return self._album_info
except AttributeError:
album_uuids = self._get_album_uuids()
self._album_info = [
AlbumInfo(db=self._db, uuid=album) for album in album_uuids
]
return self._album_info
@property
def keywords(self):
@@ -315,6 +439,11 @@ class PhotoInfo:
""" True if picture is hidden """
return True if self._info["hidden"] == 1 else False
@property
def intrash(self):
""" True if picture is in trash ('Recently Deleted' folder)"""
return self._info["intrash"]
@property
def location(self):
""" returns (latitude, longitude) as float in degrees or None """
@@ -324,7 +453,7 @@ class PhotoInfo:
def shared(self):
""" returns True if photos is in a shared iCloud album otherwise false
Only valid on Photos 5; returns None on older versions """
if self._db._db_version >= _PHOTOS_5_VERSION:
if self._db._db_version > _PHOTOS_4_VERSION:
return self._info["shared"]
else:
return None
@@ -336,6 +465,14 @@ class PhotoInfo:
"""
return self._info["UTI"]
@property
def uti_raw(self):
""" Returns Uniform Type Identifier (UTI) for the RAW image if there is one
for example: com.canon.cr2-raw-image
Returns None if no associated RAW image
"""
return self._info["UTI_raw"]
@property
def ismovie(self):
""" Returns True if file is a movie, otherwise False
@@ -361,7 +498,7 @@ class PhotoInfo:
""" Returns True if photo is a cloud asset (in an iCloud library),
otherwise False
"""
if self._db._db_version < _PHOTOS_5_VERSION:
if self._db._db_version <= _PHOTOS_4_VERSION:
return (
True
if self._info["cloudLibraryState"] is not None
@@ -383,12 +520,11 @@ class PhotoInfo:
self is not included in the returned list """
if self._info["burst"]:
burst_uuid = self._info["burstUUID"]
burst_photos = [
return [
PhotoInfo(db=self._db, uuid=u, info=self._db._dbphotos[u])
for u in self._db._dbphotos_burst[burst_uuid]
if u != self._uuid
]
return burst_photos
else:
return []
@@ -404,7 +540,7 @@ class PhotoInfo:
If photo is missing, returns None """
photopath = None
if self._db._db_version < _PHOTOS_5_VERSION:
if self._db._db_version <= _PHOTOS_4_VERSION:
if self.live_photo and not self.ismissing:
live_model_id = self._info["live_model_id"]
if live_model_id == None:
@@ -453,315 +589,147 @@ class PhotoInfo:
return photopath
def export(
@property
def panorama(self):
""" Returns True if photo is a panorama, otherwise False """
return self._info["panorama"]
@property
def slow_mo(self):
""" Returns True if photo is a slow motion video, otherwise False """
return self._info["slow_mo"]
@property
def time_lapse(self):
""" Returns True if photo is a time lapse video, otherwise False """
return self._info["time_lapse"]
@property
def hdr(self):
""" Returns True if photo is an HDR photo, otherwise False """
return self._info["hdr"]
@property
def screenshot(self):
""" Returns True if photo is an HDR photo, otherwise False """
return self._info["screenshot"]
@property
def portrait(self):
""" Returns True if photo is a portrait, otherwise False """
return self._info["portrait"]
@property
def selfie(self):
""" Returns True if photo is a selfie (front facing camera), otherwise False """
return self._info["selfie"]
@property
def place(self):
""" Returns PlaceInfo object containing reverse geolocation info """
# implementation note: doesn't create the PlaceInfo object until requested
# then memoizes the object in self._place to avoid recreating the object
if self._db._db_version <= _PHOTOS_4_VERSION:
try:
return self._place # pylint: disable=access-member-before-definition
except AttributeError:
if self._info["placeNames"]:
self._place = PlaceInfo4(
self._info["placeNames"], self._info["countryCode"]
)
else:
self._place = None
return self._place
else:
try:
return self._place # pylint: disable=access-member-before-definition
except AttributeError:
if self._info["reverse_geolocation"]:
self._place = PlaceInfo5(self._info["reverse_geolocation"])
else:
self._place = None
return self._place
@property
def has_raw(self):
""" returns True if photo has an associated RAW image, otherwise False """
return self._info["has_raw"]
@property
def raw_original(self):
""" returns True if associated RAW image and the RAW image is selected in Photos
via "Use RAW as Original "
otherwise returns False """
return self._info["raw_is_original"]
@property
def height(self):
""" returns height of the current photo version in pixels """
return self._info["height"]
@property
def width(self):
""" returns width of the current photo version in pixels """
return self._info["width"]
@property
def orientation(self):
""" returns EXIF orientation of the current photo version as int """
return self._info["orientation"]
@property
def original_height(self):
""" returns height of the original photo version in pixels """
return self._info["original_height"]
@property
def original_width(self):
""" returns width of the original photo version in pixels """
return self._info["original_width"]
@property
def original_orientation(self):
""" returns EXIF orientation of the original photo version as int """
return self._info["original_orientation"]
@property
def original_filesize(self):
""" returns filesize of original photo in bytes as int """
return self._info["original_filesize"]
def render_template(
self,
dest,
*filename,
edited=False,
live_photo=False,
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_xmp=False,
use_photos_export=False,
timeout=120,
template_str,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
):
""" export photo
dest: must be valid destination path (or exception raised)
filename: (optional): name of picture; if not provided, will use current filename
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
sidecar filename will be dest/filename.json
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export
returns the full path to the exported file """
"""Renders a template string for PhotoInfo instance using PhotoTemplate
# TODO: add this docs:
# ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done )
# check arguments and get destination path and filename (if provided)
if filename and len(filename) > 2:
raise TypeError(
"Too many positional arguments. Should be at most two: destination, filename."
)
else:
# verify destination is a valid path
if dest is None:
raise ValueError("Destination must not be None")
elif not os.path.isdir(dest):
raise FileNotFoundError("Invalid path passed to export")
if filename and len(filename) == 1:
# second arg is filename of picture
filename = filename[0]
else:
# no filename provided so use the default
# if edited file requested, use filename but add _edited
# need to use file extension from edited file as Photos saves a jpeg once edited
if edited and not use_photos_export:
# verify we have a valid path_edited and use that to get filename
if not self.path_edited:
raise FileNotFoundError(
"edited=True but path_edited is none; hasadjustments: "
f" {self.hasadjustments}"
)
edited_name = pathlib.Path(self.path_edited).name
edited_suffix = pathlib.Path(edited_name).suffix
filename = (
pathlib.Path(self.filename).stem + "_edited" + edited_suffix
)
else:
filename = self.filename
# check destination path
dest = pathlib.Path(dest)
filename = pathlib.Path(filename)
dest = dest / filename
# check to see if file exists and if so, add (1), (2), etc until we find one that works
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
# e.g. exporting sidecar for file1.png and file1.jpeg
# if file1.png exists and exporting file1.jpeg,
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
if increment and not overwrite:
count = 1
glob_str = str(dest.parent / f"{dest.stem}*")
dest_files = glob.glob(glob_str)
dest_files = [pathlib.Path(f).stem for f in dest_files]
dest_new = dest.stem
while dest_new in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not overwrite and not increment:
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
)
if not use_photos_export:
# find the source file on disk and export
# get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
if edited:
if not self.hasadjustments:
logging.warning(
"Attempting to export edited photo but hasadjustments=False"
)
if self.path_edited is not None:
src = self.path_edited
else:
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments}"
)
else:
if self.ismissing:
logging.warning(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is None:
logging.warning(
f"Attempting to export photo but path is None: ismissing = {self.ismissing}"
)
raise FileNotFoundError("Cannot export photo if path is None")
else:
src = self.path
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, increment={increment}, dest exists: {dest.exists()}"
)
# copy the file, _copy_file uses ditto to preserve Mac extended attributes
_copy_file(src, dest)
# copy live photo associated .mov if requested
if live_photo and self.live_photo:
live_name = dest.parent / f"{dest.stem}.mov"
src_live = self.path_live_photo
if src_live is not None:
logging.debug(
f"Exporting live photo video of {filename} as {live_name.name}"
)
_copy_file(src_live, str(live_name))
else:
logging.warning(f"Skipping missing live movie for {filename}")
else:
# use_photo_export
exported = None
# export live_photo .mov file?
live_photo = True if live_photo and self.live_photo else False
if edited:
# exported edited version and not original
if filename:
# use filename stem provided
filestem = dest.stem
else:
# didn't get passed a filename, add _edited
filestem = f"{dest.stem}_edited"
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=False,
edited=True,
live_photo=live_photo,
timeout=timeout,
)
else:
# export original version and not edited
filestem = dest.stem
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=True,
edited=False,
live_photo=live_photo,
timeout=timeout,
)
if exported is None:
logging.warning(f"Error exporting photo {self.uuid} to {dest}")
if sidecar_json:
logging.debug("writing exiftool_json_sidecar")
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.json")
sidecar_str = self._exiftool_json_sidecar()
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
raise e
if sidecar_xmp:
logging.debug("writing xmp_sidecar")
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.xmp")
sidecar_str = self._xmp_sidecar()
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
raise e
return str(dest)
def _exiftool_json_sidecar(self):
""" return json string of EXIF details in exiftool sidecar format
Does not include all the EXIF fields as those are likely already in the image
Exports the following:
FileName
ImageDescription
Description
Title
TagsList
Keywords
Subject
PersonInImage
GPSLatitude, GPSLongitude
GPSPosition
GPSLatitudeRef, GPSLongitudeRef
DateTimeOriginal
OffsetTimeOriginal
ModifyDate """
exif = {}
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
exif["FileName"] = self.filename
if self.description:
exif["ImageDescription"] = self.description
exif["Description"] = self.description
if self.title:
exif["Title"] = self.title
if self.keywords:
exif["TagsList"] = exif["Keywords"] = list(self.keywords)
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
exif["Subject"] = list(self.keywords)
if self.persons:
exif["PersonInImage"] = self.persons
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
if "Subject" in exif:
exif["Subject"].extend(self.persons)
else:
exif["Subject"] = self.persons
# if self.favorite():
# exif["Rating"] = 5
(lat, lon) = self.location
if lat is not None and lon is not None:
lat_str, lon_str = dd_to_dms_str(lat, lon)
exif["GPSLatitude"] = lat_str
exif["GPSLongitude"] = lon_str
exif["GPSPosition"] = f"{lat_str}, {lon_str}"
lat_ref = "North" if lat >= 0 else "South"
lon_ref = "East" if lon >= 0 else "West"
exif["GPSLatitudeRef"] = lat_ref
exif["GPSLongitudeRef"] = lon_ref
# process date/time and timezone offset
date = self.date
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["DateTimeOriginal"] = datetimeoriginal
exif["OffsetTimeOriginal"] = offsettime
if self.date_modified is not None:
exif["ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
json_str = json.dumps([exif])
return json_str
def _xmp_sidecar(self):
""" returns string for XMP sidecar """
# TODO: add additional fields to XMP file?
xmp_template = Template(
filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME)
Args:
template_str: a template string with fields to render
none_str: a str to use if template field renders to None, default is "_".
path_sep: a single character str to use as path separator when joining
fields like folder_album; if not provided, defaults to 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 ','
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
template = PhotoTemplate(self)
return template.render(
template_str,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
)
xmp_str = xmp_template.render(photo=self)
# remove extra lines that mako inserts from template
xmp_str = "\n".join(
[line for line in xmp_str.split("\n") if line.strip() != ""]
)
return xmp_str
def _write_sidecar(self, filename, sidecar_str):
""" write sidecar_str to filename
used for exporting sidecar info """
if not filename and not sidecar_str:
raise (
ValueError(
f"filename {filename} and sidecar_str {sidecar_str} must not be None"
)
)
# TODO: catch exception?
f = open(filename, "w")
f.write(sidecar_str)
f.close()
@property
def _longitude(self):
@@ -773,6 +741,37 @@ class PhotoInfo:
""" Returns latitude, in degrees """
return self._info["latitude"]
def _get_album_uuids(self):
""" Return list of album UUIDs this photo is found in
Filters out albums in the trash and any special album types
Returns: list of album UUIDs
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
version4 = True
album_kind = [_PHOTOS_4_ALBUM_KIND]
else:
version4 = False
album_kind = [_PHOTOS_5_SHARED_ALBUM_KIND, _PHOTOS_5_ALBUM_KIND]
album_list = []
for album in self._info["albums"]:
detail = self._db._dbalbum_details[album]
if (
detail["kind"] in album_kind
and not detail["intrash"]
and (
not version4
# in Photos <= 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
# but should not be listed here; they can be distinguished by looking
# for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
or (version4 and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER)
)
):
album_list.append(album)
return album_list
def __repr__(self):
return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})"
@@ -783,6 +782,8 @@ class PhotoInfo:
date_modified_iso = (
self.date_modified.isoformat() if self.date_modified else None
)
exif = str(self.exif_info) if self.exif_info else None
score = str(self.score) if self.score else None
info = {
"uuid": self.uuid,
@@ -813,6 +814,27 @@ class PhotoInfo:
"iscloudasset": self.iscloudasset,
"incloud": self.incloud,
"date_modified": date_modified_iso,
"portrait": self.portrait,
"screenshot": self.screenshot,
"slow_mo": self.slow_mo,
"time_lapse": self.time_lapse,
"hdr": self.hdr,
"selfie": self.selfie,
"panorama": self.panorama,
"has_raw": self.has_raw,
"uti_raw": self.uti_raw,
"path_raw": self.path_raw,
"place": self.place,
"exif": exif,
"score": score,
"intrash": self.intrash,
"height": self.height,
"width": self.width,
"orientation": self.orientation,
"original_height": self.original_height,
"original_width": self.original_width,
"original_orientation": self.original_orientation,
"original_filesize": self.original_filesize,
}
return yaml.dump(info, sort_keys=False)
@@ -822,6 +844,10 @@ class PhotoInfo:
date_modified_iso = (
self.date_modified.isoformat() if self.date_modified else None
)
folders = {album.title: album.folder_names for album in self.album_info}
exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
place = self.place.as_dict() if self.place else {}
score = dataclasses.asdict(self.score) if self.score else {}
pic = {
"uuid": self.uuid,
@@ -831,7 +857,10 @@ class PhotoInfo:
"description": self.description,
"title": self.title,
"keywords": self.keywords,
"labels": self.labels,
"keywords": self.keywords,
"albums": self.albums,
"folders": folders,
"persons": self.persons,
"path": self.path,
"ismissing": self.ismissing,
@@ -852,15 +881,42 @@ class PhotoInfo:
"iscloudasset": self.iscloudasset,
"incloud": self.incloud,
"date_modified": date_modified_iso,
"portrait": self.portrait,
"screenshot": self.screenshot,
"slow_mo": self.slow_mo,
"time_lapse": self.time_lapse,
"hdr": self.hdr,
"selfie": self.selfie,
"panorama": self.panorama,
"has_raw": self.has_raw,
"uti_raw": self.uti_raw,
"path_raw": self.path_raw,
"place": place,
"exif": exif,
"score": score,
"intrash": self.intrash,
"height": self.height,
"width": self.width,
"orientation": self.orientation,
"original_height": self.original_height,
"original_width": self.original_width,
"original_orientation": self.original_orientation,
"original_filesize": self.original_filesize,
}
return json.dumps(pic)
# compare two PhotoInfo objects for equality
def __eq__(self, other):
""" Compare two PhotoInfo objects for equality """
# Can't just compare the two __dicts__ because some methods (like albums)
# memoize their value once called in an instance variable (e.g. self._albums)
if isinstance(other, self.__class__):
return self.__dict__ == other.__dict__
return (
self._db.db_path == other._db.db_path
and self.uuid == other.uuid
and self._info == other._info
)
return False
def __ne__(self, other):
""" Compare two PhotoInfo objects for inequality """
return not self.__eq__(other)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
"""
PhotosDB class
Processes a Photos.app library database to extract information about photos
"""
from .photosdb import PhotosDB

View File

@@ -0,0 +1,58 @@
""" PhotosDB method for processing exif info
Do not import this module directly """
import logging
from .._constants import _PHOTOS_4_VERSION
from ..utils import _db_is_locked, _debug, _open_sql_file
def _process_exifinfo(self):
""" load the exif data from the database
this is a PhotosDB method that should be imported in
the PhotosDB class definition in photosdb.py
"""
if self._db_version <= _PHOTOS_4_VERSION:
_process_exifinfo_4(self)
else:
_process_exifinfo_5(self)
# The following methods do not get imported into PhotosDB
# but will get called by _process_exifinfo
def _process_exifinfo_4(photosdb):
""" process exif info for Photos <= 4
photosdb: PhotosDB instance """
photosdb._db_exifinfo_uuid = {}
raise NotImplementedError(f"search info not implemented for this database version")
def _process_exifinfo_5(photosdb):
""" process exif info for Photos >= 5
photosdb: PhotosDB instance """
db = photosdb._tmp_db
(conn, cursor) = _open_sql_file(db)
result = conn.execute(
"""
SELECT ZGENERICASSET.ZUUID, ZEXTENDEDATTRIBUTES.*
FROM ZGENERICASSET
JOIN ZEXTENDEDATTRIBUTES
ON ZEXTENDEDATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
"""
)
photosdb._db_exifinfo_uuid = {}
cols = [c[0] for c in result.description]
for row in result.fetchall():
record = dict(zip(cols, row))
uuid = record["ZUUID"]
if uuid in photosdb._db_exifinfo_uuid:
logging.warning(f"duplicate exifinfo record found for uuid {uuid}")
photosdb._db_exifinfo_uuid[uuid] = record
conn.close()

View File

@@ -0,0 +1,328 @@
""" Methods for PhotosDB to add Photos face info
"""
import logging
from .._constants import _PHOTOS_4_VERSION
from ..utils import _open_sql_file
"""
This module should be imported in the class defintion of PhotosDB in photosdb.py
Do not import this module directly
This module adds the following method to PhotosDB:
_process_faceinfo: process photo face info
The following data structures are added to PhotosDB
self._db_faceinfo_pk: {pk: {faceinfo}}
self._db_faceinfo_uuid: {photo uuid: [face pk]}
self._db_faceinfo_person: {person_pk: [face_pk]}
"""
def _process_faceinfo(self):
""" Process face information
"""
self._db_faceinfo_pk = {}
self._db_faceinfo_uuid = {}
self._db_faceinfo_person = {}
if self._db_version <= _PHOTOS_4_VERSION:
_process_faceinfo_4(self)
else:
_process_faceinfo_5(self)
def _process_faceinfo_4(photosdb):
""" Process face information for Photos 4 databases
Args:
photosdb: an OSXPhotosDB instance
"""
db = photosdb._tmp_db
(conn, cursor) = _open_sql_file(db)
result = cursor.execute(
"""
SELECT
RKFace.modelId,
RKVersion.uuid,
RKFace.uuid,
RKPerson.name,
RKFace.isInTrash,
RKFace.personId,
RKFace.imageModelId,
RKFace.sourceWidth,
RKFace.sourceHeight,
RKFace.centerX,
RKFace.centerY,
RKFace.size,
RKFace.leftEyeX,
RKFace.leftEyeY,
RKFace.rightEyeX,
RKFace.rightEyeY,
RKFace.mouthX,
RKFace.mouthY,
RKFace.hidden,
RKFace.manual,
RKFace.hasSmile,
RKFace.isLeftEyeClosed,
RKFace.isRightEyeClosed,
RKFace.poseRoll,
RKFace.poseYaw,
RKFace.posePitch,
RKFace.faceType,
RKFace.qualityMeasure
FROM
RKFace
JOIN RKPerson on RKPerson.modelId = RKFace.personId
JOIN RKVersion on RKVersion.modelId = RKFace.imageModelId
"""
)
# 0 RKFace.modelId,
# 1 RKVersion.uuid,
# 2 RKFace.uuid,
# 3 RKPerson.name,
# 4 RKFace.isInTrash,
# 5 RKFace.personId,
# 6 RKFace.imageModelId,
# 7 RKFace.sourceWidth,
# 8 RKFace.sourceHeight,
# 9 RKFace.centerX,
# 10 RKFace.centerY,
# 11 RKFace.size,
# 12 RKFace.leftEyeX,
# 13 RKFace.leftEyeY,
# 14 RKFace.rightEyeX,
# 15 RKFace.rightEyeY,
# 16 RKFace.mouthX,
# 17 RKFace.mouthY,
# 18 RKFace.hidden,
# 19 RKFace.manual,
# 20 RKFace.hasSmile,
# 21 RKFace.isLeftEyeClosed,
# 22 RKFace.isRightEyeClosed,
# 23 RKFace.poseRoll,
# 24 RKFace.poseYaw,
# 25 RKFace.posePitch,
# 26 RKFace.faceType,
# 27 RKFace.qualityMeasure
for row in result:
modelid = row[0]
asset_uuid = row[1]
person_id = row[5]
face = {}
face["pk"] = modelid
face["asset_uuid"] = asset_uuid
face["uuid"] = row[2]
face["person"] = person_id
face["fullname"] = row[3]
face["sourcewidth"] = row[7]
face["sourceheight"] = row[8]
face["centerx"] = row[9]
face["centery"] = row[10]
face["size"] = row[11]
face["lefteyex"] = row[12]
face["lefteyey"] = row[13]
face["righteyex"] = row[14]
face["righteyey"] = row[15]
face["mouthx"] = row[16]
face["mouthy"] = row[17]
face["hidden"] = row[18]
face["manual"] = row[19]
face["has_smile"] = row[20]
face["left_eye_closed"] = row[21]
face["right_eye_closed"] = row[22]
face["roll"] = row[23]
face["yaw"] = row[24]
face["pitch"] = row[25]
face["facetype"] = row[26]
face["quality"] = row[27]
# Photos 5 only
face["agetype"] = None
face["baldtype"] = None
face["eyemakeuptype"] = None
face["eyestate"] = None
face["facialhairtype"] = None
face["gendertype"] = None
face["glassestype"] = None
face["haircolortype"] = None
face["intrash"] = None
face["lipmakeuptype"] = None
face["smiletype"] = None
photosdb._db_faceinfo_pk[modelid] = face
try:
photosdb._db_faceinfo_uuid[asset_uuid].append(modelid)
except KeyError:
photosdb._db_faceinfo_uuid[asset_uuid] = [modelid]
try:
photosdb._db_faceinfo_person[person_id].append(modelid)
except KeyError:
photosdb._db_faceinfo_person[person_id] = [modelid]
conn.close()
def _process_faceinfo_5(photosdb):
""" Process face information for Photos 5 databases
Args:
photosdb: an OSXPhotosDB instance
"""
db = photosdb._tmp_db
(conn, cursor) = _open_sql_file(db)
result = cursor.execute(
"""
SELECT
ZDETECTEDFACE.Z_PK,
ZGENERICASSET.ZUUID,
ZDETECTEDFACE.ZUUID,
ZDETECTEDFACE.ZPERSON,
ZPERSON.ZFULLNAME,
ZDETECTEDFACE.ZAGETYPE,
ZDETECTEDFACE.ZBALDTYPE,
ZDETECTEDFACE.ZEYEMAKEUPTYPE,
ZDETECTEDFACE.ZEYESSTATE,
ZDETECTEDFACE.ZFACIALHAIRTYPE,
ZDETECTEDFACE.ZGENDERTYPE,
ZDETECTEDFACE.ZGLASSESTYPE,
ZDETECTEDFACE.ZHAIRCOLORTYPE,
ZDETECTEDFACE.ZHASSMILE,
ZDETECTEDFACE.ZHIDDEN,
ZDETECTEDFACE.ZISINTRASH,
ZDETECTEDFACE.ZISLEFTEYECLOSED,
ZDETECTEDFACE.ZISRIGHTEYECLOSED,
ZDETECTEDFACE.ZLIPMAKEUPTYPE,
ZDETECTEDFACE.ZMANUAL,
ZDETECTEDFACE.ZQUALITYMEASURE,
ZDETECTEDFACE.ZSMILETYPE,
ZDETECTEDFACE.ZSOURCEHEIGHT,
ZDETECTEDFACE.ZSOURCEWIDTH,
ZDETECTEDFACE.ZBLURSCORE,
ZDETECTEDFACE.ZCENTERX,
ZDETECTEDFACE.ZCENTERY,
ZDETECTEDFACE.ZLEFTEYEX,
ZDETECTEDFACE.ZLEFTEYEY,
ZDETECTEDFACE.ZMOUTHX,
ZDETECTEDFACE.ZMOUTHY,
ZDETECTEDFACE.ZPOSEYAW,
ZDETECTEDFACE.ZQUALITY,
ZDETECTEDFACE.ZRIGHTEYEX,
ZDETECTEDFACE.ZRIGHTEYEY,
ZDETECTEDFACE.ZROLL,
ZDETECTEDFACE.ZSIZE,
ZDETECTEDFACE.ZYAW,
ZDETECTEDFACE.ZMASTERIDENTIFIER
FROM ZDETECTEDFACE
JOIN ZGENERICASSET ON ZGENERICASSET.Z_PK = ZDETECTEDFACE.ZASSET
JOIN ZPERSON ON ZPERSON.Z_PK = ZDETECTEDFACE.ZPERSON;
"""
)
# 0 ZDETECTEDFACE.Z_PK
# 1 ZGENERICASSET.ZUUID,
# 2 ZDETECTEDFACE.ZUUID,
# 3 ZDETECTEDFACE.ZPERSON,
# 4 ZPERSON.ZFULLNAME,
# 5 ZDETECTEDFACE.ZAGETYPE,
# 6 ZDETECTEDFACE.ZBALDTYPE,
# 7 ZDETECTEDFACE.ZEYEMAKEUPTYPE,
# 8 ZDETECTEDFACE.ZEYESSTATE,
# 9 ZDETECTEDFACE.ZFACIALHAIRTYPE,
# 10 ZDETECTEDFACE.ZGENDERTYPE,
# 11 ZDETECTEDFACE.ZGLASSESTYPE,
# 12 ZDETECTEDFACE.ZHAIRCOLORTYPE,
# 13 ZDETECTEDFACE.ZHASSMILE,
# 14 ZDETECTEDFACE.ZHIDDEN,
# 15 ZDETECTEDFACE.ZISINTRASH,
# 16 ZDETECTEDFACE.ZISLEFTEYECLOSED,
# 17 ZDETECTEDFACE.ZISRIGHTEYECLOSED,
# 18 ZDETECTEDFACE.ZLIPMAKEUPTYPE,
# 19 ZDETECTEDFACE.ZMANUAL,
# 20 ZDETECTEDFACE.ZQUALITYMEASURE,
# 21 ZDETECTEDFACE.ZSMILETYPE,
# 22 ZDETECTEDFACE.ZSOURCEHEIGHT,
# 23 ZDETECTEDFACE.ZSOURCEWIDTH,
# 24 ZDETECTEDFACE.ZBLURSCORE,
# 25 ZDETECTEDFACE.ZCENTERX,
# 26 ZDETECTEDFACE.ZCENTERY,
# 27 ZDETECTEDFACE.ZLEFTEYEX,
# 28 ZDETECTEDFACE.ZLEFTEYEY,
# 29 ZDETECTEDFACE.ZMOUTHX,
# 30 ZDETECTEDFACE.ZMOUTHY,
# 31 ZDETECTEDFACE.ZPOSEYAW,
# 32 ZDETECTEDFACE.ZQUALITY,
# 33 ZDETECTEDFACE.ZRIGHTEYEX,
# 34 ZDETECTEDFACE.ZRIGHTEYEY,
# 35 ZDETECTEDFACE.ZROLL,
# 36 ZDETECTEDFACE.ZSIZE,
# 37 ZDETECTEDFACE.ZYAW,
# 38 ZDETECTEDFACE.ZMASTERIDENTIFIER
for row in result:
pk = row[0]
asset_uuid = row[1]
person_pk = row[3]
face = {}
face["pk"] = pk
face["asset_uuid"] = asset_uuid
face["uuid"] = row[2]
face["person"] = person_pk
face["fullname"] = row[4]
face["agetype"] = row[5]
face["baldtype"] = row[6]
face["eyemakeuptype"] = row[7]
face["eyestate"] = row[8]
face["facialhairtype"] = row[9]
face["gendertype"] = row[10]
face["glassestype"] = row[11]
face["haircolortype"] = row[12]
face["has_smile"] = row[13]
face["hidden"] = row[14]
face["intrash"] = row[15]
face["left_eye_closed"] = row[16]
face["right_eye_closed"] = row[17]
face["lipmakeuptype"] = row[18]
face["manual"] = row[19]
face["smiletype"] = row[21]
face["sourceheight"] = row[22]
face["sourcewidth"] = row[23]
face["facetype"] = None # Photos 4 only
face["centerx"] = row[25]
face["centery"] = row[26]
face["lefteyex"] = row[27]
face["lefteyey"] = row[28]
face["mouthx"] = row[29]
face["mouthy"] = row[30]
face["quality"] = row[32]
face["righteyex"] = row[33]
face["righteyey"] = row[34]
face["roll"] = row[35]
face["size"] = row[36]
face["yaw"] = row[37]
face["pitch"] = 0.0 # not defined in Photos 5
photosdb._db_faceinfo_pk[pk] = face
try:
photosdb._db_faceinfo_uuid[asset_uuid].append(pk)
except KeyError:
photosdb._db_faceinfo_uuid[asset_uuid] = [pk]
try:
photosdb._db_faceinfo_person[person_pk].append(pk)
except KeyError:
photosdb._db_faceinfo_person[person_pk] = [pk]
conn.close()

View File

@@ -0,0 +1,147 @@
""" Methods for PhotosDB to add Photos 5 photo score info
ref: https://simonwillison.net/2020/May/21/dogsheep-photos/
"""
import logging
from .._constants import _PHOTOS_4_VERSION
from ..utils import _open_sql_file
"""
This module should be imported in the class defintion of PhotosDB in photosdb.py
Do not import this module directly
This module adds the following method to PhotosDB:
_process_scoreinfo: process photo score info
The following data structures are added to PhotosDB
self._db_scoreinfo_uuid
These methods only work on Photos 5 databases. Will print warning on earlier library versions.
"""
def _process_scoreinfo(self):
""" Process computed photo scores
Note: Only works on Photos version == 5.0
"""
# _db_scoreinfo_uuid is dict in form {uuid: {score values}}
self._db_scoreinfo_uuid = {}
if self._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError(
f"search info not implemented for this database version"
)
else:
_process_scoreinfo_5(self)
def _process_scoreinfo_5(photosdb):
""" Process computed photo scores for Photos 5 databases
Args:
photosdb: an OSXPhotosDB instance
"""
db = photosdb._tmp_db
(conn, cursor) = _open_sql_file(db)
result = cursor.execute(
"""
SELECT
ZGENERICASSET.ZUUID,
ZGENERICASSET.ZOVERALLAESTHETICSCORE,
ZGENERICASSET.ZCURATIONSCORE,
ZGENERICASSET.ZPROMOTIONSCORE,
ZGENERICASSET.ZHIGHLIGHTVISIBILITYSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZBEHAVIORALSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZFAILURESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZHARMONIOUSCOLORSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZIMMERSIVENESSSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZINTERACTIONSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZINTERESTINGSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZINTRUSIVEOBJECTPRESENCESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZLIVELYCOLORSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZLOWLIGHT,
ZCOMPUTEDASSETATTRIBUTES.ZNOISESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCAMERATILTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCOMPOSITIONSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTLIGHTINGSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPATTERNSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPERSPECTIVESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPOSTPROCESSINGSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTREFLECTIONSSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTSYMMETRYSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZSHARPLYFOCUSEDSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZTASTEFULLYBLURREDSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLCHOSENSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLFRAMEDSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLTIMEDSHOTSCORE
FROM ZGENERICASSET
JOIN ZCOMPUTEDASSETATTRIBUTES ON ZCOMPUTEDASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
"""
)
# 0 ZGENERICASSET.ZUUID,
# 1 ZGENERICASSET.ZOVERALLAESTHETICSCORE,
# 2 ZGENERICASSET.ZCURATIONSCORE,
# 3 ZGENERICASSET.ZPROMOTIONSCORE,
# 4 ZGENERICASSET.ZHIGHLIGHTVISIBILITYSCORE,
# 5 ZCOMPUTEDASSETATTRIBUTES.ZBEHAVIORALSCORE,
# 6 ZCOMPUTEDASSETATTRIBUTES.ZFAILURESCORE,
# 7 ZCOMPUTEDASSETATTRIBUTES.ZHARMONIOUSCOLORSCORE,
# 8 ZCOMPUTEDASSETATTRIBUTES.ZIMMERSIVENESSSCORE,
# 9 ZCOMPUTEDASSETATTRIBUTES.ZINTERACTIONSCORE,
# 10 ZCOMPUTEDASSETATTRIBUTES.ZINTERESTINGSUBJECTSCORE,
# 11 ZCOMPUTEDASSETATTRIBUTES.ZINTRUSIVEOBJECTPRESENCESCORE,
# 12 ZCOMPUTEDASSETATTRIBUTES.ZLIVELYCOLORSCORE,
# 13 ZCOMPUTEDASSETATTRIBUTES.ZLOWLIGHT,
# 14 ZCOMPUTEDASSETATTRIBUTES.ZNOISESCORE,
# 15 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCAMERATILTSCORE,
# 16 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCOMPOSITIONSCORE,
# 17 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTLIGHTINGSCORE,
# 18 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPATTERNSCORE,
# 19 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPERSPECTIVESCORE,
# 20 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPOSTPROCESSINGSCORE,
# 21 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTREFLECTIONSSCORE,
# 22 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTSYMMETRYSCORE,
# 23 ZCOMPUTEDASSETATTRIBUTES.ZSHARPLYFOCUSEDSUBJECTSCORE,
# 24 ZCOMPUTEDASSETATTRIBUTES.ZTASTEFULLYBLURREDSCORE,
# 25 ZCOMPUTEDASSETATTRIBUTES.ZWELLCHOSENSUBJECTSCORE,
# 26 ZCOMPUTEDASSETATTRIBUTES.ZWELLFRAMEDSUBJECTSCORE,
# 27 ZCOMPUTEDASSETATTRIBUTES.ZWELLTIMEDSHOTSCORE
for row in result:
uuid = row[0]
scores = {"uuid": uuid}
scores["overall_aesthetic"] = row[1]
scores["curation"] = row[2]
scores["promotion"] = row[3]
scores["highlight_visibility"] = row[4]
scores["behavioral"] = row[5]
scores["failure"] = row[6]
scores["harmonious_color"] = row[7]
scores["immersiveness"] = row[8]
scores["interaction"] = row[9]
scores["interesting_subject"] = row[10]
scores["intrusive_object_presence"] = row[11]
scores["lively_color"] = row[12]
scores["low_light"] = row[13]
scores["noise"] = row[14]
scores["pleasant_camera_tilt"] = row[15]
scores["pleasant_composition"] = row[16]
scores["pleasant_lighting"] = row[17]
scores["pleasant_pattern"] = row[18]
scores["pleasant_perspective"] = row[19]
scores["pleasant_post_processing"] = row[20]
scores["pleasant_reflection"] = row[21]
scores["pleasant_symmetry"] = row[22]
scores["sharply_focused_subject"] = row[23]
scores["tastefully_blurred"] = row[24]
scores["well_chosen_subject"] = row[25]
scores["well_framed_subject"] = row[26]
scores["well_timed_shot"] = row[27]
photosdb._db_scoreinfo_uuid[uuid] = scores
conn.close()

View File

@@ -0,0 +1,209 @@
""" Methods for PhotosDB to add Photos 5 search info such as machine learning labels
Kudos to Simon Willison who figured out how to extract this data from psi.sql
ref: https://github.com/dogsheep/photos-to-sqlite/issues/16
"""
from functools import lru_cache
import logging
import pathlib
import uuid as uuidlib
from pprint import pformat
from .._constants import _PHOTOS_4_VERSION, SEARCH_CATEGORY_LABEL
from ..utils import _db_is_locked, _debug, _open_sql_file
"""
This module should be imported in the class defintion of PhotosDB in photosdb.py
Do not import this module directly
This module adds the following method to PhotosDB:
_process_searchinfo: process search terms from psi.sqlite
The following properties are added to PhotosDB
labels: list of all labels in the library
labels_normalized: list of all labels normalized in the library
labels_as_dict: dict of {label: count of photos} in reverse sorted order (most photos first)
labels_normalized_as_dict: dict of {normalized label: count of photos} in reverse sorted order (most photos first)
The following data structures are added to PhotosDB
self._db_searchinfo_categories
self._db_searchinfo_uuid
self._db_searchinfo_labels
self._db_searchinfo_labels_normalized
These methods only work on Photos 5 databases. Will print warning on earlier library versions.
"""
def _process_searchinfo(self):
""" load machine learning/search term label info from a Photos library
db_connection: a connection to the SQLite database file containing the
search terms. In Photos 5, this is called psi.sqlite
Note: Only works on Photos version == 5.0 """
# _db_searchinfo_uuid is dict in form {uuid : [list of associated search info records]
self._db_searchinfo_uuid = _db_searchinfo_uuid = {}
# _db_searchinfo_categories is dict in form {search info category id: list normalized strings for the category
# right now, this is mostly for debugging to easily see which search terms are in the library
self._db_searchinfo_categories = _db_searchinfo_categories = {}
# _db_searchinfo_labels is dict in form {normalized label: [list of photo uuids]}
# this serves as a reverse index from label to photos containing the label
# _db_searchinfo_labels_normalized is the same but with normalized (lower case) version of the label
self._db_searchinfo_labels = _db_searchinfo_labels = {}
self._db_searchinfo_labels_normalized = _db_searchinfo_labels_normalized = {}
if self._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError(
f"search info not implemented for this database version"
)
search_db_path = pathlib.Path(self._dbfile).parent / "search" / "psi.sqlite"
if not search_db_path.exists():
logging.warning(f"could not find search db: {search_db_path}")
return None
if _db_is_locked(search_db_path):
search_db = self._copy_db_file(search_db_path)
else:
search_db = search_db_path
(conn, c) = _open_sql_file(search_db)
result = c.execute(
"""
select
ga.rowid,
assets.uuid_0,
assets.uuid_1,
groups.rowid as groupid,
groups.category,
groups.owning_groupid,
groups.content_string,
groups.normalized_string,
groups.lookup_identifier
from
ga
join groups on groups.rowid = ga.groupid
join assets on ga.assetid = assets.rowid
order by
ga.rowid
"""
)
# 0: ga.rowid,
# 1: assets.uuid_0,
# 2: assets.uuid_1,
# 3: groups.rowid as groupid,
# 4: groups.category,
# 5: groups.owning_groupid,
# 6: groups.content_string,
# 7: groups.normalized_string,
# 8: groups.lookup_identifier
for row in c:
uuid = ints_to_uuid(row[1], row[2])
# strings have null character appended, so strip it
record = {}
record["uuid"] = uuid
record["rowid"] = row[0]
record["uuid_0"] = row[1]
record["uuid_1"] = row[2]
record["groupid"] = row[3]
record["category"] = row[4]
record["owning_groupid"] = row[5]
record["content_string"] = row[6].replace("\x00", "")
record["normalized_string"] = row[7].replace("\x00", "")
record["lookup_identifier"] = row[8]
try:
_db_searchinfo_uuid[uuid].append(record)
except KeyError:
_db_searchinfo_uuid[uuid] = [record]
category = record["category"]
try:
_db_searchinfo_categories[category].append(record["normalized_string"])
except KeyError:
_db_searchinfo_categories[category] = [record["normalized_string"]]
if category == SEARCH_CATEGORY_LABEL:
label = record["content_string"]
label_norm = record["normalized_string"]
try:
_db_searchinfo_labels[label].append(uuid)
_db_searchinfo_labels_normalized[label_norm].append(uuid)
except KeyError:
_db_searchinfo_labels[label] = [uuid]
_db_searchinfo_labels_normalized[label_norm] = [uuid]
if _debug():
logging.debug(
"_db_searchinfo_categories: \n" + pformat(self._db_searchinfo_categories)
)
logging.debug("_db_searchinfo_uuid: \n" + pformat(self._db_searchinfo_uuid))
logging.debug("_db_searchinfo_labels: \n" + pformat(self._db_searchinfo_labels))
logging.debug(
"_db_searchinfo_labels_normalized: \n"
+ pformat(self._db_searchinfo_labels_normalized)
)
conn.close()
@property
def labels(self):
""" return list of all search info labels found in the library """
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
return []
return list(self._db_searchinfo_labels.keys())
@property
def labels_normalized(self):
""" return list of all normalized search info labels found in the library """
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
return []
return list(self._db_searchinfo_labels_normalized.keys())
@property
def labels_as_dict(self):
""" return labels as dict of label: count in reverse sorted order (descending) """
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
return dict()
labels = {k: len(v) for k, v in self._db_searchinfo_labels.items()}
labels = dict(sorted(labels.items(), key=lambda kv: kv[1], reverse=True))
return labels
@property
def labels_normalized_as_dict(self):
""" return normalized labels as dict of label: count in reverse sorted order (descending) """
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(f"SearchInfo not implemented for this library version")
return dict()
labels = {k: len(v) for k, v in self._db_searchinfo_labels_normalized.items()}
labels = dict(sorted(labels.items(), key=lambda kv: kv[1], reverse=True))
return labels
# The following method is not imported into PhotosDB
@lru_cache(maxsize=128)
def ints_to_uuid(uuid_0, uuid_1):
""" convert two signed ints into a UUID strings
uuid_0, uuid_1: the two int components of an RFC 4122 UUID """
# assumes uuid imported as uuidlib (to avoid namespace conflict with other uses of uuid)
bytes_ = uuid_0.to_bytes(8, "little", signed=True) + uuid_1.to_bytes(
8, "little", signed=True
)
return str(uuidlib.UUID(bytes=bytes_)).upper()

File diff suppressed because it is too large Load Diff

651
osxphotos/phototemplate.py Normal file
View File

@@ -0,0 +1,651 @@
""" Custom template system for osxphotos (implemented in PhotoInfo.render_template) """
# Rolled my own template system because:
# 1. Needed to handle multiple values (e.g. album, keyword)
# 2. Needed to handle default values if template not found
# 3. Didn't want user to need to know python (e.g. by using Mako which is
# already used elsewhere in this project)
# 4. Couldn't figure out how to do #1 and #2 with str.format()
#
# This code isn't elegant but it seems to work well. PRs gladly accepted.
import datetime
import locale
import os
import re
import pathlib
from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter
# ensure locale set to user's locale
locale.setlocale(locale.LC_ALL, "")
# 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",
"{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.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.strftime}": "Apply strftime template to file modification date/time. Should be used in form "
# + "{modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
# + "{modified.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.",
"{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",
"{today.mm}": "2-digit month of the current date (zero padded)",
"{today.month}": "Month name in user's locale of the current date",
"{today.mon}": "Month abbreviation in the user's locale of the current date",
"{today.dd}": "2-digit day of the month (zero padded) of current date",
"{today.dow}": "Day of week in user's locale of the current date",
"{today.doy}": "3-digit day of year (e.g Julian day) of current date, starting from 1 (zero padded)",
"{today.hour}": "2-digit hour of the current date",
"{today.min}": "2-digit minute of the current date",
"{today.sec}": "2-digit second of the current date",
"{today.strftime}": "Apply strftime template to current date/time. Should be used in form "
+ "{today.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
+ "{today.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.",
"{place.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos",
"{place.country_code}": "The ISO country code from the photo's reverse geolocation data",
"{place.name.country}": "Country name from the photo's reverse geolocation data",
"{place.name.state_province}": "State or province name from the photo's reverse geolocation data",
"{place.name.city}": "City or locality name from the photo's reverse geolocation data",
"{place.name.area_of_interest}": "Area of interest name (e.g. landmark or public place) from the photo's reverse geolocation data",
"{place.address}": "Postal address from the photo's reverse geolocation data, e.g. '2007 18th St NW, Washington, DC 20009, United States'",
"{place.address.street}": "Street part of the postal address, e.g. '2007 18th St NW'",
"{place.address.city}": "City part of the postal address, e.g. 'Washington'",
"{place.address.state_province}": "State/province part of the postal address, e.g. 'DC'",
"{place.address.postal_code}": "Postal code part of the postal address, e.g. '20009'",
"{place.address.country}": "Country name of the postal address, e.g. 'United States'",
"{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
}
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{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)",
}
# Just the multi-valued substitution names without the braces
MULTI_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "")
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
]
class PhotoTemplate:
""" PhotoTemplate class to render a template string from a PhotoInfo object """
def __init__(self, photo):
""" Inits PhotoTemplate class with photo, non_str, and path_sep
Args:
photo: a PhotoInfo instance.
"""
self.photo = photo
# holds value of current date/time for {today.x} fields
# gets initialized in get_template_value
self.today = None
def render(
self,
template,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
):
""" Render a filename or directory template
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
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 ','
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
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 = ","
# the rendering happens in two phases:
# phase 1: handle all the single-value template substitutions
# results in a single string with all the template fields replaced
# phase 2: loop through all the multi-value template substitutions
# could result in multiple strings
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
# 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\-\%. ]+))?)(?=\}(?!\}))\}"
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
def make_subst_function(self, none_str, get_func=self.get_template_value):
""" 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 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:
return (
matchobj.group(3)
if matchobj.group(3) is not None
else none_str
)
else:
return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
return subst
subst_func = make_subst_function(self, none_str)
# do the replacements
rendered = re.sub(regex, subst_func, template)
# do multi-valued placements
# start with the single string from phase 1 above then loop through all
# multi-valued fields and all values for each of those fields
# rendered_strings will be updated as each field is processed
# for example: if two albums, two keywords, and one person and template is:
# "{created.year}/{album}/{keyword}/{person}"
# rendered strings would do the following:
# start (created.year filled in phase 1)
# ['2011/{album}/{keyword}/{person}']
# after processing albums:
# ['2011/Album1/{keyword}/{person}',
# '2011/Album2/{keyword}/{person}',]
# after processing keywords:
# ['2011/Album1/keyword1/{person}',
# '2011/Album1/keyword2/{person}',
# '2011/Album2/keyword1/{person}',
# '2011/Album2/keyword2/{person}',]
# after processing person:
# ['2011/Album1/keyword1/person1',
# '2011/Album1/keyword2/person1',
# '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',]
rendered_strings = set([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)
# holds each of the new rendered_strings, set() to avoid duplicates
new_strings = set()
for str_template in rendered_strings:
if regex_multi.search(str_template):
values = self.get_template_value_multi(field, path_sep)
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, default):
""" 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
default 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, default):
""" 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
default 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.add(new_string)
# update rendered_strings for the next field to process
rendered_strings = new_strings
# find any {fields} that weren't replaced
unmatched = []
for rendered_str in rendered_strings:
unmatched.extend(
[
no_match[0]
for no_match in re.findall(regex, rendered_str)
if no_match[0] not in unmatched
]
)
# fix any escaped curly braces
rendered_strings = [
rendered_str.replace("{{", "{").replace("}}", "}")
for rendered_str in rendered_strings
]
return rendered_strings, unmatched
def get_template_value(self, field, default):
"""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
Returns:
The matching template value (which may be None).
Raises:
ValueError if no rule exists for field.
"""
# initialize today with current date/time if needed
if self.today is None:
self.today = datetime.datetime.now()
# must be a valid keyword
if field == "name":
return pathlib.Path(self.photo.filename).stem
if field == "original_name":
return pathlib.Path(self.photo.original_filename).stem
if field == "title":
return self.photo.title
if field == "descr":
return self.photo.description
if field == "created.date":
return DateTimeFormatter(self.photo.date).date
if field == "created.year":
return DateTimeFormatter(self.photo.date).year
if field == "created.yy":
return DateTimeFormatter(self.photo.date).yy
if field == "created.mm":
return DateTimeFormatter(self.photo.date).mm
if field == "created.month":
return DateTimeFormatter(self.photo.date).month
if field == "created.mon":
return DateTimeFormatter(self.photo.date).mon
if field == "created.dd":
return DateTimeFormatter(self.photo.date).dd
if field == "created.dow":
return DateTimeFormatter(self.photo.date).dow
if field == "created.doy":
return DateTimeFormatter(self.photo.date).doy
if field == "created.hour":
return DateTimeFormatter(self.photo.date).hour
if field == "created.min":
return DateTimeFormatter(self.photo.date).min
if field == "created.sec":
return DateTimeFormatter(self.photo.date).sec
if field == "created.strftime":
if default:
try:
return self.photo.date.strftime(default)
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
return None
if field == "modified.date":
return (
DateTimeFormatter(self.photo.date_modified).date
if self.photo.date_modified
else None
)
if field == "modified.year":
return (
DateTimeFormatter(self.photo.date_modified).year
if self.photo.date_modified
else None
)
if field == "modified.yy":
return (
DateTimeFormatter(self.photo.date_modified).yy
if self.photo.date_modified
else None
)
if field == "modified.mm":
return (
DateTimeFormatter(self.photo.date_modified).mm
if self.photo.date_modified
else None
)
if field == "modified.month":
return (
DateTimeFormatter(self.photo.date_modified).month
if self.photo.date_modified
else None
)
if field == "modified.mon":
return (
DateTimeFormatter(self.photo.date_modified).mon
if self.photo.date_modified
else None
)
if field == "modified.dd":
return (
DateTimeFormatter(self.photo.date_modified).dd
if self.photo.date_modified
else None
)
if field == "modified.doy":
return (
DateTimeFormatter(self.photo.date_modified).doy
if self.photo.date_modified
else None
)
if field == "modified.hour":
return (
DateTimeFormatter(self.photo.date_modified).hour
if self.photo.date_modified
else None
)
if field == "modified.min":
return (
DateTimeFormatter(self.photo.date_modified).min
if self.photo.date_modified
else None
)
if field == "modified.sec":
return (
DateTimeFormatter(self.photo.date_modified).sec
if self.photo.date_modified
else None
)
# TODO: disabling modified.strftime for now because now clean way to pass
# a default value if modified time is None
# if field == "modified.strftime":
# if default and self.photo.date_modified:
# try:
# return self.photo.date_modified.strftime(default)
# except:
# raise ValueError(f"Invalid strftime template: '{default}'")
# else:
# return None
if field == "today.date":
return DateTimeFormatter(self.today).date
if field == "today.year":
return DateTimeFormatter(self.today).year
if field == "today.yy":
return DateTimeFormatter(self.today).yy
if field == "today.mm":
return DateTimeFormatter(self.today).mm
if field == "today.month":
return DateTimeFormatter(self.today).month
if field == "today.mon":
return DateTimeFormatter(self.today).mon
if field == "today.dd":
return DateTimeFormatter(self.today).dd
if field == "today.dow":
return DateTimeFormatter(self.today).dow
if field == "today.doy":
return DateTimeFormatter(self.today).doy
if field == "today.hour":
return DateTimeFormatter(self.today).hour
if field == "today.min":
return DateTimeFormatter(self.today).min
if field == "today.sec":
return DateTimeFormatter(self.today).sec
if field == "today.strftime":
if default:
try:
return self.today.strftime(default)
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
return None
if field == "place.name":
return self.photo.place.name if self.photo.place else None
if field == "place.country_code":
return self.photo.place.country_code if self.photo.place else None
if field == "place.name.country":
return (
self.photo.place.names.country[0]
if self.photo.place and self.photo.place.names.country
else None
)
if field == "place.name.state_province":
return (
self.photo.place.names.state_province[0]
if self.photo.place and self.photo.place.names.state_province
else None
)
if field == "place.name.city":
return (
self.photo.place.names.city[0]
if self.photo.place and self.photo.place.names.city
else None
)
if field == "place.name.area_of_interest":
return (
self.photo.place.names.area_of_interest[0]
if self.photo.place and self.photo.place.names.area_of_interest
else None
)
if field == "place.address":
return (
self.photo.place.address_str
if self.photo.place and self.photo.place.address_str
else None
)
if field == "place.address.street":
return (
self.photo.place.address.street
if self.photo.place and self.photo.place.address.street
else None
)
if field == "place.address.city":
return (
self.photo.place.address.city
if self.photo.place and self.photo.place.address.city
else None
)
if field == "place.address.state_province":
return (
self.photo.place.address.state_province
if self.photo.place and self.photo.place.address.state_province
else None
)
if field == "place.address.postal_code":
return (
self.photo.place.address.postal_code
if self.photo.place and self.photo.place.address.postal_code
else None
)
if field == "place.address.country":
return (
self.photo.place.address.country
if self.photo.place and self.photo.place.address.country
else None
)
if field == "place.address.country_code":
return (
self.photo.place.address.iso_country_code
if self.photo.place and self.photo.place.address.iso_country_code
else None
)
# if here, didn't get a match
raise ValueError(f"Unhandled template value: {field}")
def get_template_value_multi(self, field, path_sep):
"""lookup value for template field (multi-value template substitutions)
Args:
field: template field to find value for.
path_sep: path separator to use for folder_album field
Returns:
List of the matching template values or [None].
Raises:
ValueError if no rule exists for field.
"""
""" return list of values for a multi-valued template field """
if field == "album":
values = self.photo.albums
elif field == "keyword":
values = self.photo.keywords
elif field == "person":
values = self.photo.persons
# remove any _UNKNOWN_PERSON values
values = [val for val in values if val != _UNKNOWN_PERSON]
elif field == "label":
values = self.photo.labels
elif field == "label_normalized":
values = self.photo.labels_normalized
elif field == "folder_album":
values = []
# photos must be in an album to be in a folder
for album in self.photo.album_info:
if album.folder_names:
# album in folder
folder = path_sep.join(album.folder_names)
folder += path_sep + album.title
values.append(folder)
else:
# album not in folder
values.append(album.title)
else:
raise ValueError(f"Unhandleded template value: {field}")
# If no values, insert None so code below will substite none_str for None
values = values or [None]
return values

642
osxphotos/placeinfo.py Normal file
View File

@@ -0,0 +1,642 @@
"""
PlaceInfo class
Provides reverse geolocation info for photos
See https://developer.apple.com/documentation/corelocation/clplacemark
for additional documentation on reverse geolocation data
"""
from abc import ABC, abstractmethod
from collections import namedtuple # pylint: disable=syntax-error
import yaml
from bpylist import archiver
# postal address information, returned by PlaceInfo.address
PostalAddress = namedtuple(
"PostalAddress",
[
"street",
"sub_locality",
"city",
"sub_administrative_area",
"state_province",
"postal_code",
"country",
"iso_country_code",
],
)
# PlaceNames tuple returned by PlaceInfo.names
# order of fields 0 - 17 is mapped to placeType value in
# PLRevGeoLocationInfo.mapInfo.sortedPlaceInfos
# field 18 is combined bodies of water (ocean + inland_water)
# and maps to Photos <= 4, RKPlace.type == 44
# (Photos <= 4 doesn't have ocean or inland_water types)
# The fields named "field0", etc. appear to be unused
PlaceNames = namedtuple(
"PlaceNames",
[
"field0",
"country", # The name of the country associated with the placemark.
"state_province", # administrativeArea, The state or province associated with the placemark.
"sub_administrative_area", # Additional administrative area information for the placemark.
"city", # locality, The city associated with the placemark.
"field5",
"additional_city_info", # subLocality, Additional city-level information for the placemark.
"ocean", # The name of the ocean associated with the placemark.
"area_of_interest", # areasOfInterest, The relevant areas of interest associated with the placemark.
"inland_water", # The name of the inland water body associated with the placemark.
"field10",
"region", # The geographic region associated with the placemark.
"sub_throughfare", # Additional street-level information for the placemark.
"field13",
"postal_code", # The postal code associated with the placemark.
"field15",
"field16",
"street_address", # throughfare, The street address associated with the placemark.
"body_of_water", # RKPlace.type == 44, appears to be any body of water (ocean or inland)
],
)
# The following classes represent Photo Library Reverse Geolocation Info as stored
# in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
# These classes are used by bpylist.archiver to unarchive the serialized objects
class PLRevGeoLocationInfo:
""" The top level reverse geolocation object """
def __init__(
self,
addressString,
countryCode,
mapItem,
isHome,
compoundNames,
compoundSecondaryNames,
version,
geoServiceProvider,
postalAddress,
):
self.addressString = addressString
self.countryCode = countryCode
self.mapItem = mapItem
self.isHome = isHome
self.compoundNames = compoundNames
self.compoundSecondaryNames = compoundSecondaryNames
self.version = version
self.geoServiceProvider = geoServiceProvider
self.postalAddress = postalAddress
def __eq__(self, other):
return all(
getattr(self, field) == getattr(other, field)
for field in [
"addressString",
"countryCode",
"isHome",
"compoundNames",
"compoundSecondaryNames",
"version",
"geoServiceProvider",
"postalAddress",
]
)
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
return f"addressString: {self.addressString}, countryCode: {self.countryCode}, isHome: {self.isHome}, mapItem: {self.mapItem}, postalAddress: {self.postalAddress}"
@staticmethod
def encode_archive(obj, archive):
archive.encode("addressString", obj.addressString)
archive.encode("countryCode", obj.countryCode)
archive.encode("mapItem", obj.mapItem)
archive.encode("isHome", obj.isHome)
archive.encode("compoundNames", obj.compoundNames)
archive.encode("compoundSecondaryNames", obj.compoundSecondaryNames)
archive.encode("version", obj.version)
archive.encode("geoServiceProvider", obj.geoServiceProvider)
archive.encode("postalAddress", obj.postalAddress)
@staticmethod
def decode_archive(archive):
addressString = archive.decode("addressString")
countryCode = archive.decode("countryCode")
mapItem = archive.decode("mapItem")
isHome = archive.decode("isHome")
compoundNames = archive.decode("compoundNames")
compoundSecondaryNames = archive.decode("compoundSecondaryNames")
version = archive.decode("version")
geoServiceProvider = archive.decode("geoServiceProvider")
postalAddress = archive.decode("postalAddress")
return PLRevGeoLocationInfo(
addressString,
countryCode,
mapItem,
isHome,
compoundNames,
compoundSecondaryNames,
version,
geoServiceProvider,
postalAddress,
)
class PLRevGeoMapItem:
""" Stores the list of place names, organized by area """
def __init__(self, sortedPlaceInfos, finalPlaceInfos):
self.sortedPlaceInfos = sortedPlaceInfos
self.finalPlaceInfos = finalPlaceInfos
def __eq__(self, other):
return all(
getattr(self, field) == getattr(other, field)
for field in ["sortedPlaceInfos", "finalPlaceInfos"]
)
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
sortedPlaceInfos = [str(place) for place in self.sortedPlaceInfos]
finalPlaceInfos = [str(place) for place in self.finalPlaceInfos]
return (
f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}"
)
@staticmethod
def encode_archive(obj, archive):
archive.encode("sortedPlaceInfos", obj.sortedPlaceInfos)
archive.encode("finalPlaceInfos", obj.finalPlaceInfos)
@staticmethod
def decode_archive(archive):
sortedPlaceInfos = archive.decode("sortedPlaceInfos")
finalPlaceInfos = archive.decode("finalPlaceInfos")
return PLRevGeoMapItem(sortedPlaceInfos, finalPlaceInfos)
class PLRevGeoMapItemAdditionalPlaceInfo:
""" Additional info about individual places """
def __init__(self, area, name, placeType, dominantOrderType):
self.area = area
self.name = name
self.placeType = placeType
self.dominantOrderType = dominantOrderType
def __eq__(self, other):
return all(
getattr(self, field) == getattr(other, field)
for field in ["area", "name", "placeType", "dominantOrderType"]
)
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
return f"area: {self.area}, name: {self.name}, placeType: {self.placeType}"
@staticmethod
def encode_archive(obj, archive):
archive.encode("area", obj.area)
archive.encode("name", obj.name)
archive.encode("placeType", obj.placeType)
archive.encode("dominantOrderType", obj.dominantOrderType)
@staticmethod
def decode_archive(archive):
area = archive.decode("area")
name = archive.decode("name")
placeType = archive.decode("placeType")
dominantOrderType = archive.decode("dominantOrderType")
return PLRevGeoMapItemAdditionalPlaceInfo(
area, name, placeType, dominantOrderType
)
class CNPostalAddress:
""" postal address for the reverse geolocation info """
def __init__(
self,
_ISOCountryCode,
_city,
_country,
_postalCode,
_state,
_street,
_subAdministrativeArea,
_subLocality,
):
self._ISOCountryCode = _ISOCountryCode
self._city = _city
self._country = _country
self._postalCode = _postalCode
self._state = _state
self._street = _street
self._subAdministrativeArea = _subAdministrativeArea
self._subLocality = _subLocality
def __eq__(self, other):
return all(
getattr(self, field) == getattr(other, field)
for field in [
"_ISOCountryCode",
"_city",
"_country",
"_postalCode",
"_state",
"_street",
"_subAdministrativeArea",
"_subLocality",
]
)
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
return ", ".join(
map(
str,
[
self._street,
self._city,
self._subLocality,
self._subAdministrativeArea,
self._state,
self._postalCode,
self._country,
self._ISOCountryCode,
],
)
)
@staticmethod
def encode_archive(obj, archive):
archive.encode("_ISOCountryCode", obj._ISOCountryCode)
archive.encode("_country", obj._country)
archive.encode("_city", obj._city)
archive.encode("_postalCode", obj._postalCode)
archive.encode("_state", obj._state)
archive.encode("_street", obj._street)
archive.encode("_subAdministrativeArea", obj._subAdministrativeArea)
archive.encode("_subLocality", obj._subLocality)
@staticmethod
def decode_archive(archive):
_ISOCountryCode = archive.decode("_ISOCountryCode")
_country = archive.decode("_country")
_city = archive.decode("_city")
_postalCode = archive.decode("_postalCode")
_state = archive.decode("_state")
_street = archive.decode("_street")
_subAdministrativeArea = archive.decode("_subAdministrativeArea")
_subLocality = archive.decode("_subLocality")
return CNPostalAddress(
_ISOCountryCode,
_city,
_country,
_postalCode,
_state,
_street,
_subAdministrativeArea,
_subLocality,
)
# register the classes with bpylist.archiver
archiver.update_class_map({"CNPostalAddress": CNPostalAddress})
archiver.update_class_map(
{"PLRevGeoMapItemAdditionalPlaceInfo": PLRevGeoMapItemAdditionalPlaceInfo}
)
archiver.update_class_map({"PLRevGeoMapItem": PLRevGeoMapItem})
archiver.update_class_map({"PLRevGeoLocationInfo": PLRevGeoLocationInfo})
class PlaceInfo(ABC):
@property
@abstractmethod
def address_str(self):
pass
@property
@abstractmethod
def country_code(self):
pass
@property
@abstractmethod
def ishome(self):
pass
@property
@abstractmethod
def name(self):
pass
@property
@abstractmethod
def names(self):
pass
@property
@abstractmethod
def address(self):
pass
class PlaceInfo4(PlaceInfo):
""" Reverse geolocation place info for a photo (Photos <= 4) """
def __init__(self, place_names, country_code):
""" place_names: list of place name tuples in ascending order by area
tuple fields are: modelID, place name, place type, area, e.g.
[(5, "St James's Park", 45, 0),
(4, 'Westminster', 16, 22097376),
(3, 'London', 4, 1596146816),
(2, 'England', 2, 180406091776),
(1, 'United Kingdom', 1, 414681432064)]
country_code: two letter country code for the country
"""
self._place_names = place_names
self._country_code = country_code
self._process_place_info()
@property
def address_str(self):
return None
@property
def country_code(self):
return self._country_code
@property
def ishome(self):
return None
@property
def name(self):
return self._name
@property
def names(self):
return self._names
@property
def address(self):
return PostalAddress(None, None, None, None, None, None, None, None)
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
else:
return (
self._place_names == other._place_names
and self._country_code == other._country_code
)
def _process_place_info(self):
""" Process place_names to set self._name and self._names """
places = self._place_names
# build a dictionary where key is placetype
places_dict = {}
for p in places:
# places in format:
# [(5, "St James's Park", 45, 0), ]
# 0: modelID
# 1: name
# 2: type
# 3: area
try:
places_dict[p[2]].append((p[1], p[3]))
except KeyError:
places_dict[p[2]] = [(p[1], p[3])]
# build list to populate PlaceNames tuple
# initialize with empty lists for each field in PlaceNames
place_info = [[]] * 19
# add the place names sorted by area (ascending)
# in Photos <=4, possible place type values are:
# 45: areasOfInterest (The relevant areas of interest associated with the placemark.)
# 44: body of water (includes both inlandWater and ocean)
# 43: subLocality (Additional city-level information for the placemark.
# 16: locality (The city associated with the placemark.)
# 4: subAdministrativeArea (Additional administrative area information for the placemark.)
# 2: administrativeArea (The state or province associated with the placemark.)
# 1: country
# mapping = mapping from PlaceNames to field in places_dict
# PlaceNames fields map to the placeType value in Photos5 (0..17)
# but place type in Photos <=4 has different values
# hence (3, 4) means PlaceNames[3] = places_dict[4] (sub_administrative_area)
mapping = [(1, 1), (2, 2), (3, 4), (4, 16), (18, 44), (8, 45)]
for field5, field4 in mapping:
try:
place_info[field5] = [
p[0]
for p in sorted(places_dict[field4], key=lambda place: place[1])
]
except KeyError:
pass
place_names = PlaceNames(*place_info)
self._names = place_names
# build the name as it appears in Photos
# the length of the name is at most 3 fields and appears to be based on available
# reverse geolocation data in the following order (left to right, joined by ',')
# always has country if available then either area of interest and city OR
# city and state
# e.g. 4, 2, 1 OR 8, 4, 1
# 8 (45): area_of_interest
# 4 (16): locality / city
# 2 (2): administrative area (state/province)
# 1 (1): country
name_list = []
if place_names[8]:
name_list.append(place_names[8][0])
if place_names[4]:
name_list.append(place_names[4][0])
elif place_names[4]:
name_list.append(place_names[4][0])
if place_names[2]:
name_list.append(place_names[2][0])
elif place_names[2]:
name_list.append(place_names[2][0])
# add country
if place_names[1]:
name_list.append(place_names[1][0])
name = ", ".join(name_list)
self._name = name if name != "" else None
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
info = {
"name": self.name,
"names": self.names,
"country_code": self.country_code,
}
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
def as_dict(self):
return {
"name": self.name,
"names": self.names._asdict(),
"country_code": self.country_code,
}
class PlaceInfo5(PlaceInfo):
""" Reverse geolocation place info for a photo (Photos >= 5) """
def __init__(self, revgeoloc_bplist):
""" revgeoloc_bplist: a binary plist blob containing
a serialized PLRevGeoLocationInfo object """
self._bplist = revgeoloc_bplist
self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist)
self._process_place_info()
@property
def address_str(self):
""" returns the postal address as a string """
return self._plrevgeoloc.addressString
@property
def country_code(self):
""" returns the country code """
return self._plrevgeoloc.countryCode
@property
def ishome(self):
""" returns True if place is user's home address """
return self._plrevgeoloc.isHome
@property
def name(self):
""" returns local place name """
return self._name
@property
def names(self):
""" returns PlaceNames tuple with detailed reverse geolocation place names """
return self._names
@property
def address(self):
addr = self._plrevgeoloc.postalAddress
if addr is not None:
postal_address = PostalAddress(
street=addr._street,
sub_locality=addr._subLocality,
city=addr._city,
sub_administrative_area=addr._subAdministrativeArea,
state_province=addr._state,
postal_code=addr._postalCode,
country=addr._country,
iso_country_code=addr._ISOCountryCode,
)
else:
postal_address = PostalAddress(
None, None, None, None, None, None, None, None
)
return postal_address
def _process_place_info(self):
""" Process sortedPlaceInfos to set self._name and self._names """
places = self._plrevgeoloc.mapItem.sortedPlaceInfos
# build a dictionary where key is placetype
places_dict = {}
for p in places:
try:
places_dict[p.placeType].append((p.name, p.area))
except KeyError:
places_dict[p.placeType] = [(p.name, p.area)]
# build list to populate PlaceNames tuple
place_info = []
for field in range(18):
try:
# add the place names sorted by area (ascending)
place_info.append(
[
p[0]
for p in sorted(places_dict[field], key=lambda place: place[1])
]
)
except:
place_info.append([])
# fill in body_of_water for compatibility with Photos <= 4
place_info.append(place_info[7] + place_info[9])
place_names = PlaceNames(*place_info)
self._names = place_names
# build the name as it appears in Photos
# the length of the name is variable and appears to be based on available
# reverse geolocation data in the following order (left to right, joined by ',')
# 8: area_of_interest
# 11: region (I've only seen this applied to islands)
# 4: locality / city
# 2: administrative area (state/province)
# 1: country
# 9: inland_water
# 7: ocean
name = ", ".join(
[
p[0]
for p in [
place_names[8], # area of interest
place_names[11], # region (I've only seen this applied to islands)
place_names[4], # locality / city
place_names[2], # administrative area (state/province)
place_names[1], # country
place_names[9], # inland_water
place_names[7], # ocean
]
if p and p[0]
]
)
self._name = name if name != "" else None
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
else:
return self._plrevgeoloc == other._plrevgeoloc
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
info = {
"name": self.name,
"names": self.names,
"country_code": self.country_code,
"ishome": self.ishome,
"address_str": self.address_str,
"address": str(self.address),
}
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
def as_dict(self):
return {
"name": self.name,
"names": self.names._asdict(),
"country_code": self.country_code,
"ishome": self.ishome,
"address_str": self.address_str,
"address": self.address._asdict() if self.address is not None else None,
}

View File

@@ -71,29 +71,42 @@
% endif
</%def>
<%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>
% endif
</%def>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
${dc_description(photo.description)}
${dc_description(description)}
${dc_title(photo.title)}
${dc_subject(photo.keywords + photo.persons)}
${dc_subject(subjects)}
${dc_datecreated(photo.date)}
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
${iptc_personinimage(photo.persons)}
${iptc_personinimage(persons)}
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
${dk_tagslist(photo.keywords)}
${dk_tagslist(keywords)}
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
${adobe_createdate(photo.date)}
${adobe_modifydate(photo.date)}
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
${gps_info(*photo.location)}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>

View File

@@ -1,19 +1,24 @@
import fnmatch
import glob
import logging
import os
import os.path
import pathlib
import platform
import re
import sqlite3
import subprocess
import sys
import tempfile
import urllib.parse
import pathlib
from plistlib import load as plistload
import CoreFoundation
import CoreServices
import objc
from Foundation import *
from osxphotos._applescript import AppleScript
from .fileutil import FileUtil
_DEBUG = False
@@ -113,31 +118,6 @@ def _dd_to_dms(dd):
return int(deg_), int(min_), sec_
def _copy_file(src, dest):
""" Copies a file from src path to dest path
src: source path as string
dest: destination path as string
Uses ditto to perform copy; will silently overwrite dest if it exists
Raises exception if copy fails or either path is None """
if src is None or dest is None:
raise ValueError("src and dest must not be None", src, dest)
if not os.path.isfile(src):
raise ValueError("src file does not appear to exist", src)
# if error on copy, subprocess will raise CalledProcessError
try:
subprocess.run(
["/usr/bin/ditto", src, dest], 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
def dd_to_dms_str(lat, lon):
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
""" lat: latitude in degrees """
@@ -169,7 +149,7 @@ def dd_to_dms_str(lat, lon):
def get_system_library_path():
""" return the path to the system Photos library as string """
""" only works on MacOS 10.15+ """
""" only works on MacOS 10.15 """
""" on earlier versions, returns None """
_, major, _ = _get_os_version()
if int(major) < 15:
@@ -186,16 +166,10 @@ def get_system_library_path():
with open(plist_file, "rb") as fp:
pl = plistload(fp)
else:
logging.warning(f"could not find plist file: {str(plist_file)}")
logging.debug(f"could not find plist file: {str(plist_file)}")
return None
photospath = pl["SystemLibraryPath"]
if photospath is not None:
return photospath
else:
logging.warning("Could not get path to Photos database")
return None
return pl.get("SystemLibraryPath")
def get_last_library_path():
@@ -214,10 +188,12 @@ def get_last_library_path():
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
# this is a serialized CFData object
photosurlref = pl["IPXDefaultLibraryURLBookmark"]
photosurlref = pl.get("IPXDefaultLibraryURLBookmark")
if photosurlref is not None:
# use CFURLCreateByResolvingBookmarkData to de-serialize bookmark data into a CFURLRef
# pylint: disable=no-member
# pylint: disable=undefined-variable
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
)
@@ -255,7 +231,7 @@ def list_photo_libraries():
# On older MacOS versions, mdfind appears to ignore some libraries
# glob to find libraries in ~/Pictures then mdfind to find all the others
# TODO: make this more robust
lib_list = glob.glob(f"{str(Path.home())}/Pictures/*.photoslibrary")
lib_list = glob.glob(f"{str(pathlib.Path.home())}/Pictures/*.photoslibrary")
# On older OS, may not get all libraries so make sure we get the last one
last_lib = get_last_library_path()
@@ -272,22 +248,28 @@ def list_photo_libraries():
return lib_list
def create_path_by_date(dest, dt):
""" Creates a path in dest folder in form dest/YYYY/MM/DD/
dest: valid path as str
dt: datetime.timetuple() object
Checks to see if path exists, if it does, do nothing and return path
If path does not exist, creates it and returns path"""
if not os.path.isdir(dest):
raise FileNotFoundError(f"dest {dest} must be valid path")
yyyy, mm, dd = dt[0:3]
yyyy = str(yyyy).zfill(4)
mm = str(mm).zfill(2)
dd = str(dd).zfill(2)
new_dest = os.path.join(dest, yyyy, mm, dd)
if not os.path.isdir(new_dest):
os.makedirs(new_dest)
return new_dest
def get_preferred_uti_extension(uti):
""" get preferred extension for a UTI type
uti: UTI str, e.g. 'public.jpeg'
returns: preferred extension as str """
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
return CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension
)
def findfiles(pattern, path_):
"""Returns list of filenames from path_ matched by pattern
shell pattern. Matching is case-insensitive.
If 'path_' is invalid/doesn't exist, returns []."""
if not os.path.isdir(path_):
return []
# See: https://gist.github.com/techtonik/5694830
rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
return [name for name in os.listdir(path_) if rule.match(name)]
# TODO: this doesn't always work, still looking for a way to
@@ -299,8 +281,7 @@ def create_path_by_date(dest, dt):
# f"""
# on openLibrary
# tell application "Photos"
# activate
# open POSIX file "{library_path}"
# open POSIX file "{library_path}"
# end tell
# end openLibrary
# """
@@ -308,110 +289,6 @@ def create_path_by_date(dest, dt):
# open_scpt.run()
def _export_photo_uuid_applescript(
uuid,
dest,
filestem=None,
original=True,
edited=False,
live_photo=False,
timeout=120,
):
""" Export photo to dest path using applescript to control Photos
If photo is a live photo, exports both the photo and associated .mov file
uuid: UUID of photo to export
dest: destination path to export to
filestem: (string) if provided, exported filename will be named stem.ext
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
If not provided, file will be named with whatever name Photos uses
If filestem.ext exists, it wil be overwritten
original: (boolean) if True, export original image; default = True
edited: (boolean) if True, export edited photo; default = False
will produce an error if image does not have edits/adjustments
*Note*: must be called with either edited or original but not both,
will raise error if called with both edited and original = True
live_photo: (boolean) if True, export associated .mov live photo; default = False
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
Returns: list of paths to exported file(s) or None if export failed
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
has not been edited. This is due to how Photos Applescript interface works.
"""
# setup the applescript to do the export
export_scpt = AppleScript(
"""
on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
tell application "Photos"
activate
set thePath to thePath
set theItem to media item id theUUID
set theFilename to filename of theItem
set itemList to {theItem}
if original then
with timeout of theTimeOut seconds
export itemList to POSIX file thePath with using originals
end timeout
end if
if edited then
with timeout of theTimeOut seconds
export itemList to POSIX file thePath
end timeout
end if
return theFilename
end tell
end export_by_uuid
"""
)
dest = pathlib.Path(dest)
if not dest.is_dir:
raise ValueError(f"dest {dest} must be a directory")
if not original ^ edited:
raise ValueError(f"edited or original must be True but not both")
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
# export original
filename = None
try:
filename = export_scpt.call(
"export_by_uuid", uuid, tmpdir.name, original, edited, timeout
)
except Exception as e:
logging.warning("Error exporting uuid %s: %s" % (uuid, str(e)))
return None
if filename is not None:
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
# TemporaryDirectory will cleanup on return
files = glob.glob(os.path.join(tmpdir.name, "*"))
exported_paths = []
for fname in files:
path = pathlib.Path(fname)
if len(files) > 1 and not live_photo and path.suffix.lower() == ".mov":
# it's the .mov part of live photo but not requested, so don't export
logging.debug(f"Skipping live photo file {path}")
continue
if filestem:
# rename the file based on filestem, keeping original extension
dest_new = dest / f"{filestem}{path.suffix}"
else:
# use the name Photos provided
dest_new = dest / path.name
logging.debug(f"exporting {path} to dest_new: {dest_new}")
_copy_file(str(path), str(dest_new))
exported_paths.append(str(dest_new))
return exported_paths
else:
return None
def _open_sql_file(dbname):
""" opens sqlite file dbname in read-only mode
returns tuple of (connection, cursor) """
@@ -443,8 +320,35 @@ def _db_is_locked(dbname):
conn.close()
logging.debug(f"{dbname} is not locked")
locked = False
except Exception as e:
except:
logging.debug(f"{dbname} is locked")
locked = True
return locked
# OSXPHOTOS_XATTR_UUID = "com.osxphotos.uuid"
# def get_uuid_for_file(filepath):
# """ returns UUID associated with an exported file
# filepath: path to exported photo
# """
# attr = xattr.xattr(filepath)
# try:
# uuid_bytes = attr[OSXPHOTOS_XATTR_UUID]
# uuid_str = uuid_bytes.decode('utf-8')
# except KeyError:
# uuid_str = None
# return uuid_str
# def set_uuid_for_file(filepath, uuid):
# """ sets the UUID associated with an exported file
# filepath: path to exported photo
# uuid: uuid string for photo
# """
# if not os.path.exists(filepath):
# raise FileNotFoundError(f"Missing file: {filepath}")
# attr = xattr.xattr(filepath)
# uuid_bytes = bytes(uuid, 'utf-8')
# attr.set(OSXPHOTOS_XATTR_UUID, uuid_bytes)

View File

@@ -1,146 +1,206 @@
aiohttp==4.0.0a1
altgraph==0.17
ansimarkup==1.4.0
appdirs==1.4.3
appnope==0.1.0
astroid==2.2.5
async-timeout==3.0.1
atomicwrites==1.3.0
attrs==19.1.0
backcall==0.1.0
better-exceptions-fork==0.2.1.post6
certifi==2019.3.9
black==19.10b0
bleach==3.1.4
bpylist2==3.0.2
certifi==2020.4.5.1
cffi==1.14.0
chardet==3.0.4
Click==7.0
colorama==0.4.1
coverage==4.5.4
importlib-metadata==0.18
decorator==4.4.2
distlib==0.3.1
docutils==0.16
entrypoints==0.3
filelock==3.0.12
idna==2.9
importlib-metadata==1.6.0
ipykernel==5.1.4
ipython==7.13.0
ipython-genutils==0.2.0
isort==4.3.20
jedi==0.16.0
jupyter-client==6.1.2
jupyter-core==4.6.3
keyring==21.2.0
lazy-object-proxy==1.4.1
loguru==0.2.5
macholib==1.14
Mako==1.1.1
MarkupSafe==1.1.1
mccabe==0.6.1
modulegraph==0.18
more-itertools==7.2.0
-e git+https://github.com/RhetTbull/osxphotos.git@0271b8ad9daf8b2fb80ce81e894478370e421379#egg=osxphotos
multidict==4.7.6
packaging==19.0
parso==0.6.2
pathspec==0.7.0
pathvalidate==2.2.1
pexpect==4.8.0
pickleshare==0.7.5
Pillow==7.2.0
pkginfo==1.5.0.1
pluggy==0.12.0
prompt-toolkit==3.0.4
psutil==5.7.0
ptyprocess==0.6.0
py==1.8.0
Pygments==2.4.2
py2app==0.21
pycparser==2.20
pyfiglet==0.8.post1
Pygments==2.6.1
PyInstaller==3.6
pyinstaller-setuptools==2019.3
pylint==2.3.1
pyobjc==6.0.1
pyobjc-core==6.0.1
pyobjc-framework-Accounts==6.0.1
pyobjc-framework-AddressBook==6.0.1
pyobjc-framework-AdSupport==6.0.1
pyobjc-framework-AppleScriptKit==6.0.1
pyobjc-framework-AppleScriptObjC==6.0.1
pyobjc-framework-ApplicationServices==6.0.1
pyobjc-framework-AuthenticationServices==6.0.1
pyobjc-framework-Automator==6.0.1
pyobjc-framework-AVFoundation==6.0.1
pyobjc-framework-AVKit==6.0.1
pyobjc-framework-BusinessChat==6.0.1
pyobjc-framework-CalendarStore==6.0.1
pyobjc-framework-CFNetwork==6.0.1
pyobjc-framework-CloudKit==6.0.1
pyobjc-framework-Cocoa==6.0.1
pyobjc-framework-Collaboration==6.0.1
pyobjc-framework-ColorSync==6.0.1
pyobjc-framework-Contacts==6.0.1
pyobjc-framework-ContactsUI==6.0.1
pyobjc-framework-CoreAudio==6.0.1
pyobjc-framework-CoreAudioKit==6.0.1
pyobjc-framework-CoreBluetooth==6.0.1
pyobjc-framework-CoreData==6.0.1
pyobjc-framework-CoreHaptics==6.0.1
pyobjc-framework-CoreLocation==6.0.1
pyobjc-framework-CoreMedia==6.0.1
pyobjc-framework-CoreMediaIO==6.0.1
pyobjc-framework-CoreML==6.0.1
pyobjc-framework-CoreMotion==6.0.1
pyobjc-framework-CoreServices==6.0.1
pyobjc-framework-CoreSpotlight==6.0.1
pyobjc-framework-CoreText==6.0.1
pyobjc-framework-CoreWLAN==6.0.1
pyobjc-framework-CryptoTokenKit==6.0.1
pyobjc-framework-DeviceCheck==6.0.1
pyobjc-framework-DictionaryServices==6.0.1
pyobjc-framework-DiscRecording==6.0.1
pyobjc-framework-DiscRecordingUI==6.0.1
pyobjc-framework-DiskArbitration==6.0.1
pyobjc-framework-DVDPlayback==6.0.1
pyobjc-framework-EventKit==6.0.1
pyobjc-framework-ExceptionHandling==6.0.1
pyobjc-framework-ExecutionPolicy==6.0.1
pyobjc-framework-ExternalAccessory==6.0.1
pyobjc-framework-FileProvider==6.0.1
pyobjc-framework-FileProviderUI==6.0.1
pyobjc-framework-FinderSync==6.0.1
pyobjc-framework-FSEvents==6.0.1
pyobjc-framework-GameCenter==6.0.1
pyobjc-framework-GameController==6.0.1
pyobjc-framework-GameKit==6.0.1
pyobjc-framework-GameplayKit==6.0.1
pyobjc-framework-ImageCaptureCore==6.0.1
pyobjc-framework-IMServicePlugIn==6.0.1
pyobjc-framework-InputMethodKit==6.0.1
pyobjc-framework-InstallerPlugins==6.0.1
pyobjc-framework-InstantMessage==6.0.1
pyobjc-framework-Intents==6.0.1
pyobjc-framework-IOSurface==6.0.1
pyobjc-framework-iTunesLibrary==6.0.1
pyobjc-framework-LatentSemanticMapping==6.0.1
pyobjc-framework-LaunchServices==6.0.1
pyobjc-framework-libdispatch==6.0.1
pyobjc-framework-LinkPresentation==6.0.1
pyobjc-framework-LocalAuthentication==6.0.1
pyobjc-framework-MapKit==6.0.1
pyobjc-framework-MediaAccessibility==6.0.1
pyobjc-framework-MediaLibrary==6.0.1
pyobjc-framework-MediaPlayer==6.0.1
pyobjc-framework-MediaToolbox==6.0.1
pyobjc-framework-MetalKit==6.0.1
pyobjc-framework-ModelIO==6.0.1
pyobjc-framework-MultipeerConnectivity==6.0.1
pyobjc-framework-NaturalLanguage==6.0.1
pyobjc-framework-NetFS==6.0.1
pyobjc-framework-Network==6.0.1
pyobjc-framework-NetworkExtension==6.0.1
pyobjc-framework-NotificationCenter==6.0.1
pyobjc-framework-OpenDirectory==6.0.1
pyobjc-framework-OSAKit==6.0.1
pyobjc-framework-OSLog==6.0.1
pyobjc-framework-PencilKit==6.0.1
pyobjc-framework-Photos==6.0.1
pyobjc-framework-PhotosUI==6.0.1
pyobjc-framework-PreferencePanes==6.0.1
pyobjc-framework-PubSub==6.0.1
pyobjc-framework-PushKit==6.0.1
pyobjc==6.2.2
pyobjc-core==6.2.2
pyobjc-framework-Accounts==6.2.2
pyobjc-framework-AddressBook==6.2.2
pyobjc-framework-AdSupport==6.2.2
pyobjc-framework-AppleScriptKit==6.2.2
pyobjc-framework-AppleScriptObjC==6.2.2
pyobjc-framework-ApplicationServices==6.2.2
pyobjc-framework-AuthenticationServices==6.2.2
pyobjc-framework-AutomaticAssessmentConfiguration==6.2.2
pyobjc-framework-Automator==6.2.2
pyobjc-framework-AVFoundation==6.2.2
pyobjc-framework-AVKit==6.2.2
pyobjc-framework-BusinessChat==6.2.2
pyobjc-framework-CalendarStore==6.2.2
pyobjc-framework-CFNetwork==6.2.2
pyobjc-framework-CloudKit==6.2.2
pyobjc-framework-Cocoa==6.2.2
pyobjc-framework-Collaboration==6.2.2
pyobjc-framework-ColorSync==6.2.2
pyobjc-framework-Contacts==6.2.2
pyobjc-framework-ContactsUI==6.2.2
pyobjc-framework-CoreAudio==6.2.2
pyobjc-framework-CoreAudioKit==6.2.2
pyobjc-framework-CoreBluetooth==6.2.2
pyobjc-framework-CoreData==6.2.2
pyobjc-framework-CoreHaptics==6.2.2
pyobjc-framework-CoreLocation==6.2.2
pyobjc-framework-CoreMedia==6.2.2
pyobjc-framework-CoreMediaIO==6.2.2
pyobjc-framework-CoreML==6.2.2
pyobjc-framework-CoreMotion==6.2.2
pyobjc-framework-CoreServices==6.2.2
pyobjc-framework-CoreSpotlight==6.2.2
pyobjc-framework-CoreText==6.2.2
pyobjc-framework-CoreWLAN==6.2.2
pyobjc-framework-CryptoTokenKit==6.2.2
pyobjc-framework-DeviceCheck==6.2.2
pyobjc-framework-DictionaryServices==6.2.2
pyobjc-framework-DiscRecording==6.2.2
pyobjc-framework-DiscRecordingUI==6.2.2
pyobjc-framework-DiskArbitration==6.2.2
pyobjc-framework-DVDPlayback==6.2.2
pyobjc-framework-EventKit==6.2.2
pyobjc-framework-ExceptionHandling==6.2.2
pyobjc-framework-ExecutionPolicy==6.2.2
pyobjc-framework-ExternalAccessory==6.2.2
pyobjc-framework-FileProvider==6.2.2
pyobjc-framework-FileProviderUI==6.2.2
pyobjc-framework-FinderSync==6.2.2
pyobjc-framework-FSEvents==6.2.2
pyobjc-framework-GameCenter==6.2.2
pyobjc-framework-GameController==6.2.2
pyobjc-framework-GameKit==6.2.2
pyobjc-framework-GameplayKit==6.2.2
pyobjc-framework-ImageCaptureCore==6.2.2
pyobjc-framework-IMServicePlugIn==6.2.2
pyobjc-framework-InputMethodKit==6.2.2
pyobjc-framework-InstallerPlugins==6.2.2
pyobjc-framework-InstantMessage==6.2.2
pyobjc-framework-Intents==6.2.2
pyobjc-framework-IOSurface==6.2.2
pyobjc-framework-iTunesLibrary==6.2.2
pyobjc-framework-LatentSemanticMapping==6.2.2
pyobjc-framework-LaunchServices==6.2.2
pyobjc-framework-libdispatch==6.2.2
pyobjc-framework-LinkPresentation==6.2.2
pyobjc-framework-LocalAuthentication==6.2.2
pyobjc-framework-MapKit==6.2.2
pyobjc-framework-MediaAccessibility==6.2.2
pyobjc-framework-MediaLibrary==6.2.2
pyobjc-framework-MediaPlayer==6.2.2
pyobjc-framework-MediaToolbox==6.2.2
pyobjc-framework-Metal==6.2.2
pyobjc-framework-MetalKit==6.2.2
pyobjc-framework-ModelIO==6.2.2
pyobjc-framework-MultipeerConnectivity==6.2.2
pyobjc-framework-NaturalLanguage==6.2.2
pyobjc-framework-NetFS==6.2.2
pyobjc-framework-Network==6.2.2
pyobjc-framework-NetworkExtension==6.2.2
pyobjc-framework-NotificationCenter==6.2.2
pyobjc-framework-OpenDirectory==6.2.2
pyobjc-framework-OSAKit==6.2.2
pyobjc-framework-OSLog==6.2.2
pyobjc-framework-PencilKit==6.2.2
pyobjc-framework-Photos==6.2.2
pyobjc-framework-PhotosUI==6.2.2
pyobjc-framework-PreferencePanes==6.2.2
pyobjc-framework-PubSub==6.2
pyobjc-framework-PushKit==6.2.2
pyobjc-framework-QTKit==6.0.1
pyobjc-framework-Quartz==6.0.1
pyobjc-framework-QuickLookThumbnailing==6.0.1
pyobjc-framework-SafariServices==6.0.1
pyobjc-framework-SceneKit==6.0.1
pyobjc-framework-ScreenSaver==6.0.1
pyobjc-framework-ScriptingBridge==6.0.1
pyobjc-framework-SearchKit==6.0.1
pyobjc-framework-Security==6.0.1
pyobjc-framework-SecurityFoundation==6.0.1
pyobjc-framework-SecurityInterface==6.0.1
pyobjc-framework-ServiceManagement==6.0.1
pyobjc-framework-Social==6.0.1
pyobjc-framework-SoundAnalysis==6.0.1
pyobjc-framework-Speech==6.0.1
pyobjc-framework-SpriteKit==6.0.1
pyobjc-framework-StoreKit==6.0.1
pyobjc-framework-SyncServices==6.0.1
pyobjc-framework-SystemConfiguration==6.0.1
pyobjc-framework-SystemExtensions==6.0.1
pyobjc-framework-UserNotifications==6.0.1
pyobjc-framework-VideoSubscriberAccount==6.0.1
pyobjc-framework-VideoToolbox==6.0.1
pyobjc-framework-Vision==6.0.1
pyobjc-framework-WebKit==6.0.1
pyobjc-framework-Quartz==6.2.2
pyobjc-framework-QuickLookThumbnailing==6.2.2
pyobjc-framework-SafariServices==6.2.2
pyobjc-framework-SceneKit==6.2.2
pyobjc-framework-ScreenSaver==6.2.2
pyobjc-framework-ScriptingBridge==6.2.2
pyobjc-framework-SearchKit==6.2.2
pyobjc-framework-Security==6.2.2
pyobjc-framework-SecurityFoundation==6.2.2
pyobjc-framework-SecurityInterface==6.2.2
pyobjc-framework-ServiceManagement==6.2.2
pyobjc-framework-Social==6.2.2
pyobjc-framework-SoundAnalysis==6.2.2
pyobjc-framework-Speech==6.2.2
pyobjc-framework-SpriteKit==6.2.2
pyobjc-framework-StoreKit==6.2.2
pyobjc-framework-SyncServices==6.2.2
pyobjc-framework-SystemConfiguration==6.2.2
pyobjc-framework-SystemExtensions==6.2.2
pyobjc-framework-UserNotifications==6.2.2
pyobjc-framework-VideoSubscriberAccount==6.2.2
pyobjc-framework-VideoToolbox==6.2.2
pyobjc-framework-Vision==6.2.2
pyobjc-framework-WebKit==6.2.2
pyparsing==2.4.1.1
pytest==5.3.1
pytest-cov==2.8.1
pytest-sugar==0.9.2
python-dateutil==2.8.1
PyYAML==5.1.2
six==1.12.0
pyzmq==18.1.1
readme-renderer==25.0
regex==2020.2.20
requests==2.23.0
requests-toolbelt==0.9.1
six==1.14.0
termcolor==1.1.0
wcwidth==0.1.7
toml==0.10.0
tornado==6.0.4
tox==3.19.0
tox-conda==0.2.1
tqdm==4.45.0
traitlets==4.3.3
twine==3.1.1
typed-ast==1.4.1
typing-extensions==3.7.4.2
urllib3==1.25.9
virtualenv==20.0.30
wcwidth==0.1.9
webencodings==0.5.1
wrapt==1.11.1
yarl==1.4.2
zipp==0.5.2
Mako==1.1.1

View File

@@ -3,7 +3,7 @@
#
# setup.py script for osxphotos
#
# Copyright (c) 2019 Rhet Turnbull, rturnbull+git@gmail.com
# Copyright (c) 2019, 2020 Rhet Turnbull, rturnbull+git@gmail.com
# All rights reserved.
#
# Permission is hereby granted, free of charge, to any person
@@ -27,30 +27,39 @@
# SOFTWARE.
import os
import platform
from setuptools import find_packages, setup
this_directory = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
long_description = f.read()
# python version as 2-digit float (e.g. 3.6)
py_ver = float(".".join(platform.python_version_tuple()[:2]))
# holds config info read from disk
about = {}
this_directory = os.path.abspath(os.path.dirname(__file__))
# get version info from _version
with open(
os.path.join(this_directory, "osxphotos", "_version.py"), mode="r", encoding="utf-8"
) as f:
exec(f.read(), about)
# read README.md into long_description
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
about["long_description"] = f.read()
setup(
name="osxphotos",
version=about["__version__"],
description="Manipulate (read-only) Apple's Photos app library on Mac OS X",
long_description=long_description,
long_description=about["long_description"],
long_description_content_type="text/markdown",
author="Rhet Turnbull",
author_email="rturnbull+git@gmail.com",
url="https://github.com/RhetTbull/",
project_urls={"GitHub": "https://github.com/RhetTbull/osxphotos"},
download_url="https://github.com/RhetTbull/osxphotos",
packages=find_packages(exclude=["tests", "examples"]),
packages=find_packages(exclude=["tests", "examples", "utils"]),
license="License :: OSI Approved :: MIT License",
classifiers=[
"Development Status :: 4 - Beta",
@@ -58,9 +67,18 @@ setup(
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: MacOS :: MacOS X",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Topic :: Software Development :: Libraries :: Python Modules",
],
install_requires=["pyobjc>=6.0.1", "Click>=7", "PyYAML>=5.1.2", "Mako>=1.1.1"],
install_requires=[
"pyobjc>=6.2.2",
"Click>=7",
"PyYAML>=5.1.2",
"Mako>=1.1.1",
"bpylist2==3.0.2",
"pathvalidate==2.2.1",
"dataclasses==0.7;python_version<'3.7'",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,
)

View File

@@ -1,22 +1,42 @@
# Tests for osxphotos #
## Running Tests ##
Tests require pytest:
Tests require pytest and pytest-mock:
`pip install pytest`
`pip install pytest-mock`
To run the tests, do the following from the main source folder:
`python -m pytest tests/`
Running the tests this way allows the library to be tested without installing it.
## Attribution ##
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com). All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
## Skipped Tests ##
A few tests will look for certain environment variables to determine if they should run.
Images used from:
Some of the export tests rely on photos in my local library and will look for `OSXPHOTOS_TEST_EXPORT=1` to determine if they should run.
One test for locale does not run on GitHub's automated workflow and will look for `OSXPHOTOS_TEST_LOCALE=1` to determine if it should be run. If you want to run this test, set the environment variable.
## Attribution ##
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com) and from my own photo library. All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
Flickr images used from:
- [Jeff Hitchcock](https://www.flickr.com/photos/arbron/48353451872/)
- [Carlos Montesdeoca](https://www.flickr.com/photos/carlosmontesdeocastudio)
- [Rydale Clothing](https://www.flickr.com/photos/rydaleclothing)
- [Marco Verch](https://www.flickr.com/photos/30478819@N08/48228222317/)
- [K M](https://www.flickr.com/photos/153387643@N08/49334338022/)
- [Shelby Mash](https://www.flickr.com/photos/shelbzyleigh/3809603052)
- [Rory MacLeod](https://www.flickr.com/photos/macrj/6969547134)
- [Md. Al Amin](https://www.flickr.com/photos/alamin_bd/45207044465)
- [Fatlum Haliti](https://www.flickr.com/photos/lumlumi/363449752)
- [Benny Mazur](https://www.flickr.com/photos/benimoto/399012465)
- [Sara Cooper PR](https://www.flickr.com/photos/saracooperpr/6422472677)
- [herval](https://www.flickr.com/photos/herval/2403994289)
- [Vox Efx](https://www.flickr.com/photos/vox_efx/141137669)
- [Bill Strain](https://www.flickr.com/photos/billstrain/5117042252)
- [Guilherme Yagui](https://www.flickr.com/photos/yagui7/15895161088/)
- [Deborah Austin](https://www.flickr.com/photos/littledebbie11/8703591799/)
- [We Are Social](https://www.flickr.com/photos/wearesocial/23309711462/)
- [cloud.shepherd](https://www.flickr.com/photos/exnucboy1/31017877125)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2019-12-08T16:44:38Z</date>
<date>2020-04-17T18:39:50Z</date>
</dict>
<key>PXPeopleScreenUnlocked</key>
<true/>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-01-22T02:10:26Z</date>
<date>2020-04-17T18:40:46Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-01-22T02:10:27Z</date>
<date>2020-04-17T18:39:51Z</date>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-01-19T17:29:28Z</date>
<date>2020-04-17T18:39:52Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>LastHistoryRowId</key>
<integer>414</integer>
<integer>502</integer>
<key>LibraryBuildTag</key>
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
<key>LibrarySchemaVersion</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -9,7 +9,7 @@
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>414</integer>
<integer>502</integer>
<key>LibraryBuildTag</key>
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
<key>LibrarySchemaVersion</key>

View File

@@ -8,8 +8,11 @@
<array/>
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>obfeGcvoT1auxoh2Tu86OQ</string>
<string>TopLevelAlbums</string>
<string>TopLevelSlideshows</string>
<string>MBS8+gBrQCWQxmcav+C8HQ</string>
<string>cHwwVoUiQ8a2nZNXgVsnCA</string>
</array>
<key>IPXWorkspaceControllerZoomLevelsKey</key>
<dict>
@@ -23,11 +26,11 @@
<key>key</key>
<integer>1</integer>
<key>lastKnownDisplayName</key>
<string>September 28, 2018</string>
<string>Pumpkin Farm (1)</string>
<key>type</key>
<string>album</string>
<key>uuid</key>
<string>+Ep8CrNRRhea9eVA618FMg</string>
<string>AU8Gp8bwRlOvngZFgwXBdg</string>
</dict>
<key>lastKnownItemCounts</key>
<dict>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-07-28T01:23:52Z</date>
<date>2020-04-30T14:09:38Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-07-28T01:23:52Z</date>
<date>2020-05-01T04:27:48Z</date>
</dict>
</plist>

View File

@@ -2,7 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ProcessedInQuiescentState</key>
<true/>
<key>SuggestedMeIdentifier</key>
<string></string>
<key>Version</key>
<integer>3</integer>
</dict>
</plist>

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2019-07-27T12:01:15Z</date>
<date>2020-05-01T03:34:50Z</date>
</dict>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2019-07-26T20:15:18Z</date>
<date>2020-04-30T12:51:41Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>LastHistoryRowId</key>
<integer>615</integer>
<integer>670</integer>
<key>LibraryBuildTag</key>
<string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string>
<key>LibrarySchemaVersion</key>

View File

@@ -9,7 +9,7 @@
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>615</integer>
<integer>670</integer>
<key>LibraryBuildTag</key>
<string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string>
<key>LibrarySchemaVersion</key>
@@ -24,7 +24,7 @@
<key>SnapshotCompletedDate</key>
<date>2019-07-26T20:15:17Z</date>
<key>SnapshotLastValidated</key>
<date>2019-07-27T12:01:15Z</date>
<date>2020-05-01T03:34:50Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -9,12 +9,14 @@
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>TopLevelAlbums</string>
<string>QtSnVvTkQ%i2z3hB834M1A</string>
<string>TopLevelSlideshows</string>
<string>N7eQ4VhfTfeHFp9PPHaJDw</string>
</array>
<key>IPXWorkspaceControllerZoomLevelsKey</key>
<dict>
<key>kZoomLevelIdentifierAlbums</key>
<integer>10</integer>
<integer>5</integer>
<key>kZoomLevelIdentifierVersions</key>
<integer>7</integer>
</dict>
@@ -23,11 +25,11 @@
<key>key</key>
<integer>1</integer>
<key>lastKnownDisplayName</key>
<string>Test Album (1)</string>
<string>Pumpkin Farm (1)</string>
<key>type</key>
<string>album</string>
<key>uuid</key>
<string>Uq6qsKihRRSjMHTiD+0Azg</string>
<string>xJ8ya3NBRWC24gKhcwwNeQ</string>
</dict>
<key>lastKnownItemCounts</key>
<dict>

Some files were not shown because too many files have changed in this diff Show More