Compare commits

...

98 Commits

Author SHA1 Message Date
Rhet Turnbull
675371f0d7 Updated README.md [skip ci] 2021-07-04 08:41:58 -07:00
Rhet Turnbull
7e2d09bf12 Added --preview, #470 2021-07-04 08:39:06 -07:00
Rhet Turnbull
28c681aa96 Refactored export2, #485, #486 2021-07-03 22:50:03 -07:00
Rhet Turnbull
5d39aa92df Update README.md 2021-07-02 19:07:02 -07:00
Rhet Turnbull
b4dbad5e74 Fixed path_derivatives to always return jpeg if photo is a photo 2021-07-02 18:59:01 -07:00
Rhet Turnbull
b1b099257f Updated README.md [skip ci] 2021-07-02 13:27:22 -07:00
Rhet Turnbull
63e8410841 Updated CHANGELOG.md [skip ci] 2021-07-02 13:23:48 -07:00
Rhet Turnbull
2e1c91cd67 Added get_selected() to REPL 2021-07-02 13:19:15 -07:00
Rhet Turnbull
391b0a577b Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-07-02 13:01:28 -07:00
Rhet Turnbull
1d26ac9630 Removed _applescript, #461 2021-07-02 13:01:09 -07:00
Rhet Turnbull
03b4f59549 Removed _applescript, #461 2021-07-02 13:00:40 -07:00
Rhet Turnbull
9aa3ac3640 Updated CHANGELOG.md [skip ci] 2021-07-02 12:48:23 -07:00
Rhet Turnbull
6339e3c70e Updated README.md [skip ci] 2021-07-02 12:43:17 -07:00
Rhet Turnbull
4cc3220287 Fix for path_raw when file is reference, #480 2021-07-02 12:39:41 -07:00
Rhet Turnbull
f32c4f4acd Updated CHANGELOG.md [skip ci] 2021-06-30 22:54:34 -07:00
allcontributors[bot]
aba2ce0923 docs: add jcommisso07 as a contributor for data (#483)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-06-30 22:52:51 -07:00
allcontributors[bot]
c209ceae2e docs: add mkirkland4874 as a contributor for bug (#482)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-06-30 22:49:50 -07:00
Rhet Turnbull
94ac2bd04e Updated README.md [skip ci] 2021-06-30 22:43:56 -07:00
Rhet Turnbull
d1b1d20bcf Fixed --cleanup for empty export, #481 2021-06-30 22:41:03 -07:00
Rhet Turnbull
fb723fb8b7 Fixed raw+jpeg for Monterey 2021-06-29 18:22:48 -07:00
Rhet Turnbull
fc7c61b11b Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-06-29 17:47:36 -07:00
Rhet Turnbull
a73db3a1bb Updated photokit code to work with raw+jpeg, #478 2021-06-29 17:47:21 -07:00
Rhet Turnbull
d2dcbaaec4 Updated photokit code to work with raw+jpeg 2021-06-29 17:46:40 -07:00
Rhet Turnbull
08147e91d9 Alpha support for Monterey/macOS 12 2021-06-29 13:32:36 -07:00
Rhet Turnbull
d034605784 Refactored UTI utils to get ready for Monterey 2021-06-29 09:31:22 -07:00
Rhet Turnbull
64fd852535 Updated README.md [skip ci] 2021-06-23 22:43:36 -07:00
Rhet Turnbull
3fbfc55e84 Fixed deprecation warning 2021-06-23 22:40:23 -07:00
Rhet Turnbull
49317582c4 Bug fix for template functions #477 2021-06-23 22:36:58 -07:00
Rhet Turnbull
5ea01df69b Bug fix 2021-06-21 06:34:56 -07:00
Rhet Turnbull
4a9f8a9ef5 Updated CHANGELOG.md [skip ci] 2021-06-20 18:19:21 -07:00
Rhet Turnbull
49adff1f3b Updated example [skip ci] 2021-06-20 18:11:41 -07:00
Rhet Turnbull
377e165be4 Updated README.md [skip ci] 2021-06-20 17:56:48 -07:00
Rhet Turnbull
07da8031c6 Implemented --query-function, #430 2021-06-20 17:26:07 -07:00
Rhet Turnbull
be363b9727 Added query function [skip ci] 2021-06-20 16:38:51 -07:00
Rhet Turnbull
870a59a2fa Added --location, --no-location, #474 2021-06-20 15:33:03 -07:00
Rhet Turnbull
500cf71f7e Updated CHANGELOG.md [skip ci] 2021-06-20 15:31:44 -07:00
Rhet Turnbull
821e338b75 Fixed function names to work around Click.runner issue 2021-06-20 09:29:23 -07:00
Rhet Turnbull
987c91a9ff Implemented --post-function, #442 2021-06-20 08:52:45 -07:00
Rhet Turnbull
233942c9b6 Added post_function.py 2021-06-20 08:11:10 -07:00
Rhet Turnbull
a0ab64a841 Updated CHANGELOG.md [skip ci] 2021-06-19 21:56:01 -07:00
Rhet Turnbull
0cd8f32893 Bug fix for --download-missing, #456 2021-06-19 21:41:54 -07:00
Rhet Turnbull
904acbc576 Added isort cfg to match black 2021-06-19 18:03:05 -07:00
Rhet Turnbull
37dc023fcb Updated README.md [skip ci] 2021-06-19 18:02:32 -07:00
Rhet Turnbull
876ff17e3f Updated CHANGELOG.md [skip ci] 2021-06-19 17:49:47 -07:00
Rhet Turnbull
130df1a767 Updated README.md [skip ci] 2021-06-19 17:42:03 -07:00
Rhet Turnbull
5d7dea3fc3 Added repl command to CLI; closes #472 2021-06-19 17:31:02 -07:00
Rhet Turnbull
ca8397bc97 Updated CHANGELOG.md [skip ci] 2021-06-19 10:05:21 -07:00
Rhet Turnbull
91023ac8ec Added tutorial, closes #432 2021-06-19 09:59:43 -07:00
Rhet Turnbull
0ad59e9e29 Updated CHANGELOG.md [skip ci] 2021-06-18 22:14:14 -07:00
Rhet Turnbull
42c551de8a Updated help text, #469 2021-06-18 22:01:55 -07:00
Rhet Turnbull
62d49a7138 Updated README.md [skip ci] 2021-06-18 15:09:26 -07:00
Rhet Turnbull
bc5cd93e97 Added error handling for --add-to-album 2021-06-18 15:02:17 -07:00
Rhet Turnbull
7bd1ba8075 Updated CHANGELOG.md [skip ci] 2021-06-18 14:37:34 -07:00
Rhet Turnbull
64bb07a026 Added additional info to error message for --add-to-album 2021-06-18 14:03:59 -07:00
Rhet Turnbull
f1902b7fd4 Updated README.md [skip ci] 2021-06-18 13:10:06 -07:00
Rhet Turnbull
8e3f8fc7d0 Fix for #471 2021-06-18 13:05:37 -07:00
Rhet Turnbull
c588dcf0ba Updated CHANGELOG.md [skip ci] 2021-06-18 09:15:19 -07:00
Rhet Turnbull
fa29f51aeb Added --post-command, implements #443 2021-06-18 09:04:36 -07:00
Rhet Turnbull
ee0b369086 Added matrix for GitHub action OS 2021-06-18 08:49:39 -07:00
Rhet Turnbull
2fc45c2468 Added macos 10.15 and 11 2021-06-18 08:47:34 -07:00
Rhet Turnbull
15d2f45f0c Added macos 10.15 and 11 2021-06-18 08:46:21 -07:00
Rhet Turnbull
df7b73212f Added macos 10.15 and 11 2021-06-18 08:44:49 -07:00
Rhet Turnbull
5143b165b5 Updated CHANGELOG.md [skip ci] 2021-06-14 06:18:04 -07:00
Rhet Turnbull
10097323e5 Fixed missing more-itertools, #466 2021-06-14 05:16:56 -07:00
Rhet Turnbull
c0bd0ffc9f Added {filepath} template field in prep for --post-command and other goodies 2021-06-13 18:40:45 -07:00
Rhet Turnbull
2cdec3fc78 Refactored PhotoTemplate to support pathlib templates 2021-06-13 09:17:55 -07:00
Rhet Turnbull
1a46cdf63c Updated README.md [skip ci] 2021-06-12 22:08:34 -07:00
Rhet Turnbull
83892e096a Added --duplicate flag to find possible duplicates 2021-06-12 18:31:53 -07:00
Rhet Turnbull
6a0b8b4a3f version bump 2021-06-12 07:21:07 -07:00
Rhet Turnbull
5957fde809 Fixed cli status for --only-new and 0 photos to export 2021-06-12 07:20:39 -07:00
Rhet Turnbull
5711545b81 Fixed test for running in GitHub actions 2021-06-12 06:49:31 -07:00
Rhet Turnbull
0758f84dc4 Cleaned up tests, fixed bug in PhotosDB.query 2021-06-11 23:02:48 -07:00
Rhet Turnbull
4b6c35b5f9 Fix for --convert-to-jpeg with use_photos_export, #460 2021-06-09 04:00:05 -07:00
Rhet Turnbull
d7a9ad1d0a Refactored PhotoInfo.export2 2021-06-06 21:02:22 -07:00
Rhet Turnbull
bb96c35672 Updated test UUIDs 2021-06-06 13:58:44 -07:00
Rhet Turnbull
0880e5b9e8 added pyinstaller 2021-06-05 22:07:31 -07:00
Rhet Turnbull
87af23d98c Added python 3.9 to tests 2021-06-05 11:29:16 -07:00
Rhet Turnbull
61943d051b Updated dependencies to minimize pyobjc requirements 2021-06-05 11:25:41 -07:00
Rhet Turnbull
ef1daf5922 Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-06-05 11:25:17 -07:00
Rhet Turnbull
bb98cff608 Test library update 2021-06-05 11:25:06 -07:00
Rhet Turnbull
620ba9ce03 Added dev_requirements.txt 2021-06-05 11:23:33 -07:00
Rhet Turnbull
86d94ad310 Added dev_requirements.txt 2021-06-05 11:22:37 -07:00
Rhet Turnbull
b8cf21ae82 Added venv [skip ci] 2021-06-04 07:01:33 -07:00
Rhet Turnbull
7accfdb066 Added PhotoInfo.duplicates 2021-06-01 17:32:43 -07:00
Rhet Turnbull
99f4394f8e Added CONTRIBUTING.md 2021-05-30 08:22:02 -07:00
Rhet Turnbull
748aed96cb Updated CHANGELOG.md [skip ci] 2021-05-29 09:08:46 -07:00
Rhet Turnbull
9161739ee6 Updated README.md [skip ci] 2021-05-29 09:05:05 -07:00
Rhet Turnbull
71cf8be94a Updated README.rst for PyPI 2021-05-29 09:03:35 -07:00
Rhet Turnbull
b48133cd83 Fix for #455 2021-05-29 08:51:58 -07:00
Rhet Turnbull
6b5a57fae9 Updated CHANGELOG.md [skip ci] 2021-05-28 09:09:26 -07:00
Rhet Turnbull
24ccf798c2 Updated README.md [skip ci] 2021-05-28 09:05:00 -07:00
Rhet Turnbull
a298772515 Updated tested versions to 11.3 2021-05-28 09:02:37 -07:00
Rhet Turnbull
2d68594b78 Fixes for #454 2021-05-28 08:48:21 -07:00
Rhet Turnbull
b026147c9a Updated README.md [skip ci] 2021-05-23 14:26:01 -07:00
Rhet Turnbull
186a5b77d0 Fixed bug in imageconverter exception handling, closes #440 2021-05-23 14:21:13 -07:00
Rhet Turnbull
518f855a9b PhotoInfo.exiftool now returns ExifToolCaching, closes #450 2021-05-23 14:14:22 -07:00
allcontributors[bot]
0d2067787c docs: add kaduskj as a contributor (#453)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-05-23 12:33:08 -07:00
Rhet Turnbull
0448a42329 Updated CHANGELOG.md [skip ci] 2021-05-23 12:30:10 -07:00
343 changed files with 6710 additions and 8638 deletions

View File

@@ -213,6 +213,33 @@
"example",
"ideas"
]
},
{
"login": "kaduskj",
"name": "kaduskj",
"avatar_url": "https://avatars.githubusercontent.com/u/983067?v=4",
"profile": "https://github.com/kaduskj",
"contributions": [
"bug"
]
},
{
"login": "mkirkland4874",
"name": "mkirkland4874",
"avatar_url": "https://avatars.githubusercontent.com/u/36466711?v=4",
"profile": "https://github.com/mkirkland4874",
"contributions": [
"bug"
]
},
{
"login": "jcommisso07",
"name": "Joseph Commisso",
"avatar_url": "https://avatars.githubusercontent.com/u/3111054?v=4",
"profile": "https://github.com/jcommisso07",
"contributions": [
"data"
]
}
],
"contributorsPerLine": 7,

View File

@@ -4,13 +4,13 @@ on: [push, pull_request]
jobs:
build:
runs-on: macOS-latest
runs-on: ${{ matrix.os }}
if: "!contains(github.event.head_commit.message, '[skip ci]')"
strategy:
max-parallel: 4
matrix:
python-version: [3.7, 3.8]
os: [macos-10.15]
python-version: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v1
@@ -21,6 +21,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r dev_requirements.txt
pip install -r requirements.txt
# - name: Lint with flake8
# run: |
@@ -31,6 +32,4 @@ jobs:
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pip install pytest
pip install pytest-mock
python -m pytest tests/

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ osxphotos.egg-info/
cli.spec
*.pyc
docsrc/_build/
venv/

3
.isort.cfg Normal file
View File

@@ -0,0 +1,3 @@
[settings]
profile=black
multi_line_output=3

View File

@@ -4,6 +4,175 @@ 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.42.54](https://github.com/RhetTbull/osxphotos/compare/v0.42.52...v0.42.54)
> 2 July 2021
- Removed _applescript, #461 [`1d26ac9`](https://github.com/RhetTbull/osxphotos/commit/1d26ac9630dd0a414c01cc4f89a080e4efd7fd97)
- Removed _applescript, #461 [`03b4f59`](https://github.com/RhetTbull/osxphotos/commit/03b4f59549de54da91c36feba613d69f9e86e47b)
- Added get_selected() to REPL [`2e1c91c`](https://github.com/RhetTbull/osxphotos/commit/2e1c91cd672eefe84063933437e5d691f5ad1db1)
#### [v0.42.52](https://github.com/RhetTbull/osxphotos/compare/v0.42.51...v0.42.52)
> 2 July 2021
- docs: add jcommisso07 as a contributor for data [`#483`](https://github.com/RhetTbull/osxphotos/pull/483)
- docs: add mkirkland4874 as a contributor for bug [`#482`](https://github.com/RhetTbull/osxphotos/pull/482)
- Fix for path_raw when file is reference, #480 [`4cc3220`](https://github.com/RhetTbull/osxphotos/commit/4cc322028790b3beefce42af5e35c23976b1a35a)
- Updated README.md [skip ci] [`6339e3c`](https://github.com/RhetTbull/osxphotos/commit/6339e3c70ee174394af356710de4bf9442bad9fc)
#### [v0.42.51](https://github.com/RhetTbull/osxphotos/compare/v0.42.46...v0.42.51)
> 30 June 2021
- Alpha support for Monterey/macOS 12 [`08147e9`](https://github.com/RhetTbull/osxphotos/commit/08147e91d92013c9cd179187a447f81bc08de3af)
- Refactored UTI utils to get ready for Monterey [`d034605`](https://github.com/RhetTbull/osxphotos/commit/d0346057843aae3a72a79695819df31385db596f)
- Updated photokit code to work with raw+jpeg, #478 [`a73db3a`](https://github.com/RhetTbull/osxphotos/commit/a73db3a1bbc2a320d68dcf7f31f1074bc23a242a)
#### [v0.42.46](https://github.com/RhetTbull/osxphotos/compare/v0.42.45...v0.42.46)
> 23 June 2021
- Bug fix for template functions #477 [`4931758`](https://github.com/RhetTbull/osxphotos/commit/49317582c4582e291463d368425513b09a799058)
- Updated README.md [skip ci] [`64fd852`](https://github.com/RhetTbull/osxphotos/commit/64fd85253508b51c3f945f4c8ff02585f1b90aab)
- Fixed deprecation warning [`3fbfc55`](https://github.com/RhetTbull/osxphotos/commit/3fbfc55e84756844070f4080ce415ba77d5c7665)
#### [v0.42.45](https://github.com/RhetTbull/osxphotos/compare/v0.42.44...v0.42.45)
> 20 June 2021
- Implemented --query-function, #430 [`07da803`](https://github.com/RhetTbull/osxphotos/commit/07da8031c63487eb42cb3e524f20971e6d2fc929)
- Added query function [skip ci] [`be363b9`](https://github.com/RhetTbull/osxphotos/commit/be363b9727d6fca6e747b0d952cd3252ddfe6e3b)
- Updated README.md [skip ci] [`377e165`](https://github.com/RhetTbull/osxphotos/commit/377e165be48b84c7678ca2f86fc2ffdcbcb93736)
#### [v0.42.44](https://github.com/RhetTbull/osxphotos/compare/v0.42.43...v0.42.44)
> 20 June 2021
- Added --location, --no-location, #474 [`870a59a`](https://github.com/RhetTbull/osxphotos/commit/870a59a2fa10766361b384216594af36d3605850)
#### [v0.42.43](https://github.com/RhetTbull/osxphotos/compare/v0.42.42...v0.42.43)
> 20 June 2021
- Implemented --post-function, #442 [`987c91a`](https://github.com/RhetTbull/osxphotos/commit/987c91a9ff4b9936d479d7d238a5e5b842265dec)
- Added post_function.py [`233942c`](https://github.com/RhetTbull/osxphotos/commit/233942c9b6836fb6fa9907e9264ec3513322930b)
- Fixed function names to work around Click.runner issue [`821e338`](https://github.com/RhetTbull/osxphotos/commit/821e338b7575c6e053b8d3d958c481dfa62a00bc)
#### [v0.42.42](https://github.com/RhetTbull/osxphotos/compare/v0.42.41...v0.42.42)
> 19 June 2021
- Bug fix for --download-missing, #456 [`0cd8f32`](https://github.com/RhetTbull/osxphotos/commit/0cd8f32893046b679ea6280822f4dba5aa7de1fd)
- Updated README.md [skip ci] [`37dc023`](https://github.com/RhetTbull/osxphotos/commit/37dc023fcbfddca8abd2b72119138d72e0bfed53)
- Added isort cfg to match black [`904acbc`](https://github.com/RhetTbull/osxphotos/commit/904acbc576b27d7d05d770e061a6c01a439b8fad)
#### [v0.42.41](https://github.com/RhetTbull/osxphotos/compare/v0.42.40...v0.42.41)
> 19 June 2021
- Added repl command to CLI; closes #472 [`#472`](https://github.com/RhetTbull/osxphotos/issues/472)
- Updated README.md [skip ci] [`130df1a`](https://github.com/RhetTbull/osxphotos/commit/130df1a76794f77bc0e8f148185c6407d6b480bc)
#### [v0.42.40](https://github.com/RhetTbull/osxphotos/compare/v0.42.39...v0.42.40)
> 19 June 2021
- Added tutorial, closes #432 [`#432`](https://github.com/RhetTbull/osxphotos/issues/432)
#### [v0.42.39](https://github.com/RhetTbull/osxphotos/compare/v0.42.38...v0.42.39)
> 18 June 2021
- Updated help text, #469 [`42c551d`](https://github.com/RhetTbull/osxphotos/commit/42c551de8a1e6f682c04b6071c1147eb8039ed3a)
#### [v0.42.38](https://github.com/RhetTbull/osxphotos/compare/v0.42.37...v0.42.38)
> 18 June 2021
- Added error handling for --add-to-album [`bc5cd93`](https://github.com/RhetTbull/osxphotos/commit/bc5cd93e974214e2327d604ff92b3c6b6ce62f04)
- Updated README.md [skip ci] [`62d49a7`](https://github.com/RhetTbull/osxphotos/commit/62d49a7138971c43625e55518f069b1b36b787ff)
#### [v0.42.37](https://github.com/RhetTbull/osxphotos/compare/v0.42.36...v0.42.37)
> 18 June 2021
- Added additional info to error message for --add-to-album [`64bb07a`](https://github.com/RhetTbull/osxphotos/commit/64bb07a0267f2fdd024a7150fe1788b07218ac2f)
#### [v0.42.36](https://github.com/RhetTbull/osxphotos/compare/v0.42.35...v0.42.36)
> 18 June 2021
- Fix for #471 [`8e3f8fc`](https://github.com/RhetTbull/osxphotos/commit/8e3f8fc7d089b644b85e8e52fe220519133d2bea)
- Updated README.md [skip ci] [`f1902b7`](https://github.com/RhetTbull/osxphotos/commit/f1902b7fd4d22c47bcf9fd101b077bbbabb71a9a)
#### [v0.42.35](https://github.com/RhetTbull/osxphotos/compare/v0.42.34...v0.42.35)
> 18 June 2021
- Added --post-command, implements #443 [`fa29f51`](https://github.com/RhetTbull/osxphotos/commit/fa29f51aeb89b3f14176693a9d0a5ff8c3565b71)
- Added matrix for GitHub action OS [`ee0b369`](https://github.com/RhetTbull/osxphotos/commit/ee0b3690869e9dbf48e733353540c19d44da51e3)
- Added macos 10.15 and 11 [`2fc45c2`](https://github.com/RhetTbull/osxphotos/commit/2fc45c2468ecf09bb9370f1c2057d63157501839)
#### [v0.42.34](https://github.com/RhetTbull/osxphotos/compare/v0.42.31...v0.42.34)
> 14 June 2021
- Refactored PhotoTemplate to support pathlib templates [`2cdec3f`](https://github.com/RhetTbull/osxphotos/commit/2cdec3fc78155a10362e6c65c2ec0e7ebf61ee38)
- Added {filepath} template field in prep for --post-command and other goodies [`c0bd0ff`](https://github.com/RhetTbull/osxphotos/commit/c0bd0ffc9fa3c8aeefd1452cbb9b82511393004f)
- Fixed missing more-itertools, #466 [`1009732`](https://github.com/RhetTbull/osxphotos/commit/10097323e5372939e1af69849dc1d4ddaf3c6667)
#### [v0.42.31](https://github.com/RhetTbull/osxphotos/compare/v0.42.30...v0.42.31)
> 12 June 2021
- Cleaned up tests, fixed bug in PhotosDB.query [`0758f84`](https://github.com/RhetTbull/osxphotos/commit/0758f84dc4bae74854c2321bc71c033d71acd4e2)
- Added --duplicate flag to find possible duplicates [`83892e0`](https://github.com/RhetTbull/osxphotos/commit/83892e096a2987a99c2bb2dc08e7bb8ab569a289)
- Updated README.md [skip ci] [`1a46cdf`](https://github.com/RhetTbull/osxphotos/commit/1a46cdf63ce6defbd8cd6cbacc65fa5779102582)
#### [v0.42.30](https://github.com/RhetTbull/osxphotos/compare/v0.42.28...v0.42.30)
> 9 June 2021
- Refactored PhotoInfo.export2 [`d7a9ad1`](https://github.com/RhetTbull/osxphotos/commit/d7a9ad1d0a6d1c4327e9d43b7719d860abd34836)
- Updated dependencies to minimize pyobjc requirements [`61943d0`](https://github.com/RhetTbull/osxphotos/commit/61943d051b8e37397eb009c8ae0b0ba86c0ab3a3)
- Fix for --convert-to-jpeg with use_photos_export, #460 [`4b6c35b`](https://github.com/RhetTbull/osxphotos/commit/4b6c35b5f939f18c0147fb034ab619f7c4f9b124)
#### [v0.42.28](https://github.com/RhetTbull/osxphotos/compare/v0.42.27...v0.42.28)
> 1 June 2021
- Added PhotoInfo.duplicates [`7accfdb`](https://github.com/RhetTbull/osxphotos/commit/7accfdb06654184e74517033749787ed049d8b7f)
- Added CONTRIBUTING.md [`99f4394`](https://github.com/RhetTbull/osxphotos/commit/99f4394f8e71f636f6e090ecb508672f672205e8)
#### [v0.42.27](https://github.com/RhetTbull/osxphotos/compare/v0.42.26...v0.42.27)
> 29 May 2021
- Fix for #455 [`b48133c`](https://github.com/RhetTbull/osxphotos/commit/b48133cd8309ce7e9a6dbab283d484a552135e33)
- Updated README.md [skip ci] [`9161739`](https://github.com/RhetTbull/osxphotos/commit/9161739ee61b0098a6930df34ec5cfd5a9abd722)
- Updated README.rst for PyPI [`71cf8be`](https://github.com/RhetTbull/osxphotos/commit/71cf8be94a4387676135d8f2e108a9de7f7cf4df)
#### [v0.42.26](https://github.com/RhetTbull/osxphotos/compare/v0.42.24...v0.42.26)
> 28 May 2021
- docs: add kaduskj as a contributor [`#453`](https://github.com/RhetTbull/osxphotos/pull/453)
- Fixed bug in imageconverter exception handling, closes #440 [`#440`](https://github.com/RhetTbull/osxphotos/issues/440)
- PhotoInfo.exiftool now returns ExifToolCaching, closes #450 [`#450`](https://github.com/RhetTbull/osxphotos/issues/450)
- Fixes for #454 [`2d68594`](https://github.com/RhetTbull/osxphotos/commit/2d68594b7811a60fedf002e712c48b1a0ca87361)
- Updated tested versions to 11.3 [`a298772`](https://github.com/RhetTbull/osxphotos/commit/a2987725151a0e4b6e399ccfeaedceac33afd5c6)
- Updated README.md [skip ci] [`24ccf79`](https://github.com/RhetTbull/osxphotos/commit/24ccf798c2aefd8cafa8645c1bff4c0a5776f0b1)
- Updated README.md [skip ci] [`b026147`](https://github.com/RhetTbull/osxphotos/commit/b026147c9ad4ba01129a243a1d2d60044b0181d3)
#### [v0.42.24](https://github.com/RhetTbull/osxphotos/compare/v0.42.23...v0.42.24)
> 23 May 2021
- Bug fix for #452 [`be8fe9d`](https://github.com/RhetTbull/osxphotos/commit/be8fe9d0595c4b4a1a9d15899774b4400b725106)
- Updated README.md [skip ci] [`a724e15`](https://github.com/RhetTbull/osxphotos/commit/a724e15dd63d3acf2224260c431ee6b954892c4e)
- Updated README.md [`a54e051`](https://github.com/RhetTbull/osxphotos/commit/a54e051d41ec3e05df76122de94efd99bbfd09ca)
#### [v0.42.23](https://github.com/RhetTbull/osxphotos/compare/v0.42.22...v0.42.23)
> 23 May 2021
@@ -79,6 +248,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added tutorial to README [`f54205f`](https://github.com/RhetTbull/osxphotos/commit/f54205ff49a37bbef4dfca435602a50fbb4ebd02)
- Refactored export_photo to enable work on #420 [`48c229b`](https://github.com/RhetTbull/osxphotos/commit/48c229b52c9a1881832d61434fcf38284ade918c)
- Refactored README.md to improve Template System section [`1d14fc8`](https://github.com/RhetTbull/osxphotos/commit/1d14fc8041ae0a2b7db3b95bb08a5986176de649)
- Updated tutorial [`aad435d`](https://github.com/RhetTbull/osxphotos/commit/aad435da3683834e17cb18b87c2aa7d1306e068e)
- Fixed typo in tutorial [`131105d`](https://github.com/RhetTbull/osxphotos/commit/131105d82cf74bdf2dbf67077fd317d775c5b74e)
#### [v0.42.9](https://github.com/RhetTbull/osxphotos/compare/v0.42.8...v0.42.9)
@@ -94,6 +265,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Updated docs [skip ci] [`3f57514`](https://github.com/RhetTbull/osxphotos/commit/3f57514fa37bdaf372f52e02dbf76f1bc2b66b9b)
- Updated docs [`50fa851`](https://github.com/RhetTbull/osxphotos/commit/50fa851f23f5a40f116d520fc70b1f523636b9a3)
- Added template_filter.py to examples [`9371db0`](https://github.com/RhetTbull/osxphotos/commit/9371db094e40c3d64745b705b8b3ebdcbd04267d)
- Fixed docs for function: filter [`1cdf4ad`](https://github.com/RhetTbull/osxphotos/commit/1cdf4addade706b5bf3105441a70fc9d529608a9)
- Version bump [`a483b8a`](https://github.com/RhetTbull/osxphotos/commit/a483b8a900de66b6124e91d53c44260e3c3dfea8)
#### [v0.42.6](https://github.com/RhetTbull/osxphotos/compare/v0.42.4...v0.42.6)
@@ -229,6 +402,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added AdjustmentsInfo, #150, #379 [`5ee6aff`](https://github.com/RhetTbull/osxphotos/commit/5ee6affc0525db1975cb5095f62494ef10d92f7e)
- docs: update .all-contributorsrc [skip ci] [`ebac9d0`](https://github.com/RhetTbull/osxphotos/commit/ebac9d0bfb43f59f046aacdd0290d1fcd29a3b5e)
- docs: update README.md [skip ci] [`29716c5`](https://github.com/RhetTbull/osxphotos/commit/29716c52726a4e699c03d43ecc67db57f55b36f8)
- Version bump [`fbe8229`](https://github.com/RhetTbull/osxphotos/commit/fbe822910370652975ab83b82344169df4c3027c)
#### [v0.40.17](https://github.com/RhetTbull/osxphotos/compare/v0.40.16...v0.40.17)
@@ -298,6 +472,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Fixed XMP template for issue #361 [`43af4d2`](https://github.com/RhetTbull/osxphotos/commit/43af4d205a7264e530bc2b2789d297be633391e1)
- Updated sidecar test data [`591f9bc`](https://github.com/RhetTbull/osxphotos/commit/591f9bcc62720f7eddebba3b3dcff265907550dd)
- Added tests for --only-new, #358 [`adc4b05`](https://github.com/RhetTbull/osxphotos/commit/adc4b056029794faddd464d22022a2a17298a924)
- Updated tests for ExportDB, #358 [`48d2223`](https://github.com/RhetTbull/osxphotos/commit/48d2223edde4850830cc6a3f9776ce08f81a6636)
- Added 11.2 to tested versions, #360 [`2284598`](https://github.com/RhetTbull/osxphotos/commit/2284598a24f63232c01dcf27b9982002123834ca)
#### [v0.40.6](https://github.com/RhetTbull/osxphotos/compare/v0.40.5...v0.40.6)
@@ -423,6 +599,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Create terminalizer-demo.yml [`5dc2eea`](https://github.com/RhetTbull/osxphotos/commit/5dc2eeaf9a7265873c81db23bbc86d3023189a26)
- Force cleanup of objects with autorelease pool [`b67f11a`](https://github.com/RhetTbull/osxphotos/commit/b67f11a3bb95c08a39a185b6d884092870e949f2)
- doc: Recorded screencast and updated of readme [`658e8ac`](https://github.com/RhetTbull/osxphotos/commit/658e8ac096d141fce48483dbfc1426bea317d806)
- doc: fixed toc in readme [`aba50c5`](https://github.com/RhetTbull/osxphotos/commit/aba50c5c733420dc30f861d866a2c0bdc8933714)
- Add @Rott-Apple as a contributor [`71cb015`](https://github.com/RhetTbull/osxphotos/commit/71cb01572d2d946df18dd7b36f95b2f2e5b48f86)
#### [v0.39.11](https://github.com/RhetTbull/osxphotos/compare/v0.39.10...v0.39.11)
@@ -434,6 +612,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Ensure merge_exif_keywords are str not int [`123ebb2`](https://github.com/RhetTbull/osxphotos/commit/123ebb2cb752bb94291ac2b77e4a327cee996df1)
- docs: update .all-contributorsrc [skip ci] [`5e676d3`](https://github.com/RhetTbull/osxphotos/commit/5e676d3507c3e2e1f1cd9da7d8843005865c0d4c)
- docs: update README.md [skip ci] [`935865d`](https://github.com/RhetTbull/osxphotos/commit/935865dc6572bc8e80a8eb1ab8f000342ded0a2b)
- Updated tests workflow badge link [`a7678df`](https://github.com/RhetTbull/osxphotos/commit/a7678df3974ff539050f5acb4c94817f525dcd56)
- Ensure keyword list only contains string [`7b6a0af`](https://github.com/RhetTbull/osxphotos/commit/7b6a0af3146202030069ed5823061ee221ab41bc)
#### [v0.39.10](https://github.com/RhetTbull/osxphotos/compare/v0.39.9...v0.39.10)
@@ -465,6 +645,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added tag_groups arg to ExifTool.asdict(), issue #324 [`2480f2a`](https://github.com/RhetTbull/osxphotos/commit/2480f2a325dbb09689f8c417618b7b9e976bfcb9)
- doc: start with examples before the export reference [`7c7bf1b`](https://github.com/RhetTbull/osxphotos/commit/7c7bf1be6b6382a995a4e17906adfd8720d0a1c3)
- Updated dependencies in README.md [`b1cab32`](https://github.com/RhetTbull/osxphotos/commit/b1cab32ff4c7b65ae4c9a5a9a11c175dbd487c0a)
- remove extra spaces [`a59bb5b`](https://github.com/RhetTbull/osxphotos/commit/a59bb5b02f10fa554dae346a7271be37f50d8bcc)
- Adding back dependency https://github.com/RhetTbull/PhotoScript) [`7c8bfc8`](https://github.com/RhetTbull/osxphotos/commit/7c8bfc811ab3a93dabadf1655f7d0e217d6c7b01)
#### [v0.39.6](https://github.com/RhetTbull/osxphotos/compare/v0.39.5...v0.39.6)
@@ -474,6 +656,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- doc simplify readme [`02ef0f9`](https://github.com/RhetTbull/osxphotos/commit/02ef0f9a254e83a3729a09cea1ae523407074896)
- Added exception handling/capture for convert-to-jpeg, issue #322 [`05f111a`](https://github.com/RhetTbull/osxphotos/commit/05f111a287e882ed6b451a550a87753501316aba)
- Cleanup up the readme [`38842ff`](https://github.com/RhetTbull/osxphotos/commit/38842ff9249e6f5b3069a88a759c8df97ddce51c)
- Add @synox as a contributor [`83915c6`](https://github.com/RhetTbull/osxphotos/commit/83915c65abb880036f80ebd830eb1e34292f9599)
#### [v0.39.5](https://github.com/RhetTbull/osxphotos/compare/v0.39.4...v0.39.5)
@@ -511,6 +694,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added tests for Finder tags [`29e4245`](https://github.com/RhetTbull/osxphotos/commit/29e424575a522ae03efe5a140be46bfd0a1346c5)
- Initial implementation for Finder tags [`5885b23`](https://github.com/RhetTbull/osxphotos/commit/5885b23d3249cf91953092a6b1ce967da2667e29)
- Updated README for finder tags [`f25a299`](https://github.com/RhetTbull/osxphotos/commit/f25a2993097ad7b2b8ab2d1c787db58c0d799a41)
- Updated requirements.txt [`ea373c4`](https://github.com/RhetTbull/osxphotos/commit/ea373c4197ce1cce00e89157fe560d1366f7e764)
#### [v0.38.22](https://github.com/RhetTbull/osxphotos/compare/v0.38.21...v0.38.22)
@@ -620,6 +804,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added additional test cases for #286, --ignore-signature [`880a9b6`](https://github.com/RhetTbull/osxphotos/commit/880a9b67a14787ef23ae68ad3164d7eda1af16ec)
- Add @finestream as a contributor [`ad860b1`](https://github.com/RhetTbull/osxphotos/commit/ad860b1500dffd846322e05562ba4f2019cd1017)
- Fixed issue #296 [`a7c688c`](https://github.com/RhetTbull/osxphotos/commit/a7c688cfc2221833e0252d71bbe596eee5f9a6e8)
- Updated README.md [`d40b16a`](https://github.com/RhetTbull/osxphotos/commit/d40b16a456c64014674505b7c715c80b977da76a)
- Update __main__.py [`e097f3a`](https://github.com/RhetTbull/osxphotos/commit/e097f3aad546b5be5eabab529bd2c35ce3056876)
#### [v0.38.5](https://github.com/RhetTbull/osxphotos/compare/v0.38.4...v0.38.5)
@@ -647,6 +833,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- removed extended_attributes reference [`6559c4d`](https://github.com/RhetTbull/osxphotos/commit/6559c4d8f64ad41df925182f9f24f6f67eecd1df)
- This is why I never use branches [`baf45cc`](https://github.com/RhetTbull/osxphotos/commit/baf45ccd2aa24858bb1a8f95ef798121ee80af30)
- Initial implementation of configoptions for --save-config, --load-config [`22355fd`](https://github.com/RhetTbull/osxphotos/commit/22355fd44609f42e412c580dfc9e5e0b7cf6c464)
- Refactoring of save-config/load-config code [`37b1e5c`](https://github.com/RhetTbull/osxphotos/commit/37b1e5ca472e9679301fa96d2b7fdd8c4ad438b2)
- Added tests for configoptions.py [`0262e0d`](https://github.com/RhetTbull/osxphotos/commit/0262e0d97e06ee36786b4491efa178608afb5de5)
#### [v0.38.0](https://github.com/RhetTbull/osxphotos/compare/v0.37.7...v0.38.0)
@@ -738,6 +926,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added test for missing original_filename [`116cb66`](https://github.com/RhetTbull/osxphotos/commit/116cb662fbddf9153f6858c6ea97dc7f65c77705)
- Add @jstrine as a contributor [`7460bc8`](https://github.com/RhetTbull/osxphotos/commit/7460bc88fcc5e1e7435c9b9bcdf7ec9c7c5e39ea)
- Escape characters which cause XML parsing issues [`c42050a`](https://github.com/RhetTbull/osxphotos/commit/c42050a10cac40b0b5ac70c587e07f257a9b50dd)
- Fix tests for apostrophe [`d0d2e80`](https://github.com/RhetTbull/osxphotos/commit/d0d2e8080096bf66f93a830386800ce713680c51)
- Fix test for XMP sidecar with GPS info [`c27cfb1`](https://github.com/RhetTbull/osxphotos/commit/c27cfb1223fa82b9e5549b93c283e9444693270a)
#### [v0.36.21](https://github.com/RhetTbull/osxphotos/compare/v0.36.20...v0.36.21)
@@ -921,6 +1111,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- --convert-to-jpeg initial version working [`38f201d`](https://github.com/RhetTbull/osxphotos/commit/38f201d0fb70bf299a828c1dd0d034a119e380c4)
- Added tests, fixed bug in export_db [`5a13605`](https://github.com/RhetTbull/osxphotos/commit/5a13605f850bb947c8888246f06a5ca4e6aa5f10)
- Updated tests [`b2b39aa`](https://github.com/RhetTbull/osxphotos/commit/b2b39aa6075df11861cf5d8945b657204f120e87)
- Fixed path_edited for Big Sur [`c389207`](https://github.com/RhetTbull/osxphotos/commit/c389207daa4fec555fbf9d2aee8347997f9a8412)
- Added HEIC test image [`ddc1e69`](https://github.com/RhetTbull/osxphotos/commit/ddc1e69b4a4ac712e1af312b865c4216f9ad350c)
#### [v0.34.5](https://github.com/RhetTbull/osxphotos/compare/v0.34.3...v0.34.5)
@@ -937,6 +1129,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added tests for 10.15.6 [`432da7f`](https://github.com/RhetTbull/osxphotos/commit/432da7f139a5e4b37eeb358f4ede45314407f8e5)
- Fixed bug related to issue #222 [`c939df7`](https://github.com/RhetTbull/osxphotos/commit/c939df717159e8b97955c0b267327cd56a9ed56c)
- Version bump for bug fix [`62d54cc`](https://github.com/RhetTbull/osxphotos/commit/62d54cc0beabd0141545608184d4b2c658eedf0f)
- Update README.md [`6883fec`](https://github.com/RhetTbull/osxphotos/commit/6883fec2b2236d892b88327e1b4e9da1237f7dea)
- Update exiftool.py [`3d21dad`](https://github.com/RhetTbull/osxphotos/commit/3d21dadf4102e9101e48a0c6f739a544f7f9d9de)
#### [v0.34.2](https://github.com/RhetTbull/osxphotos/compare/v0.34.1...v0.34.2)
@@ -972,6 +1166,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Normalize unicode for issue #208 [`a36eb41`](https://github.com/RhetTbull/osxphotos/commit/a36eb416b19284477922b6a5f837f4040327138b)
- Added force_download.py to examples [`b611d34`](https://github.com/RhetTbull/osxphotos/commit/b611d34d19db480af72f57ef55eacd0a32c8d1e8)
- Added photoshop:SidecarForExtension to XMP, partial fix for #210 [`60d96a8`](https://github.com/RhetTbull/osxphotos/commit/60d96a8f563882fba2365a6ab58c1276725eedaa)
- Updated README.md [`c9b1518`](https://github.com/RhetTbull/osxphotos/commit/c9b15186a022d91248451279e5f973e3f2dca4b4)
- Update README.md [`42e8fba`](https://github.com/RhetTbull/osxphotos/commit/42e8fba125a3c6b1bd0d538f2af511aabfbeb478)
#### [v0.33.5](https://github.com/RhetTbull/osxphotos/compare/v0.33.3...v0.33.5)
@@ -998,6 +1194,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- --touch-file now working with --update [`6c11e3f`](https://github.com/RhetTbull/osxphotos/commit/6c11e3fa5b5b05b98b9fdbb0e59e3a78c7dff980)
- Refactor/cleanup _export_photo [`eefa1f1`](https://github.com/RhetTbull/osxphotos/commit/eefa1f181f4fd7b027ae69abd2b764afb590c081)
- Fixed touch tests [`1bf7105`](https://github.com/RhetTbull/osxphotos/commit/1bf7105737fbd756064a2f9ef4d4bbd0b067978c)
- Working on issue 206 [`ebd878a`](https://github.com/RhetTbull/osxphotos/commit/ebd878a075983ef3df0b1ead1a725e01508721f8)
- Working on issue #206 [`c9c9202`](https://github.com/RhetTbull/osxphotos/commit/c9c920220545dc27c8cb1379d7bde15987cce72c)
#### [v0.33.0](https://github.com/RhetTbull/osxphotos/compare/v0.32.0...v0.33.0)
@@ -1008,6 +1206,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added tests for 10.15.6 [`d2deeff`](https://github.com/RhetTbull/osxphotos/commit/d2deefff834e46e1a26adc01b1b025ac839dbc78)
- Added ImportInfo for Photos 5+ [`98e4170`](https://github.com/RhetTbull/osxphotos/commit/98e417023ec5bd8292b25040d0844f3706645950)
- Update README.md [`360c8d8`](https://github.com/RhetTbull/osxphotos/commit/360c8d8e1b4760e95a8b71b3a0bf0df4fb5adaf5)
- Update README.md [`868cda8`](https://github.com/RhetTbull/osxphotos/commit/868cda8482ce6b29dd00e04a209d40550e6b128b)
#### [v0.32.0](https://github.com/RhetTbull/osxphotos/compare/v0.31.2...v0.32.0)
@@ -1024,6 +1223,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Dropped py36 due to datetime.fromisoformat [`a714ae0`](https://github.com/RhetTbull/osxphotos/commit/a714ae0af089b13acf70c4f29934393aa48ed222)
- Added --uuid-from-file to CLI [`840e993`](https://github.com/RhetTbull/osxphotos/commit/840e9937bede407ef55972a361618683245e086b)
- Added write_uuid_to_file.applescript to utils [`bea770b`](https://github.com/RhetTbull/osxphotos/commit/bea770b322d21cf3f8245d20e182006247cb71d6)
- Updated README.md [`002fce8`](https://github.com/RhetTbull/osxphotos/commit/002fce8e93edd936d4b866118ae6d4c94e5d6744)
- Added py37 [`d0ec862`](https://github.com/RhetTbull/osxphotos/commit/d0ec8620c721fe7576ab7d519a5eaac4d17a317e)
#### [v0.31.0](https://github.com/RhetTbull/osxphotos/compare/v0.30.13...v0.31.0)
@@ -1235,6 +1436,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
@@ -1266,6 +1469,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
@@ -1275,6 +1480,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
@@ -1299,6 +1506,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Refactored photosdb and photoinfo to add SearchInfo and labels [`98b3f63`](https://github.com/RhetTbull/osxphotos/commit/98b3f63a92aa2105f8fa97af992fc6fe2d78b973)
- 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)
- Added link to original work by @simonw [`ca8f2b8`](https://github.com/RhetTbull/osxphotos/commit/ca8f2b8d5c55b5a554fd1337b1070c97ec381916)
#### [v0.28.13](https://github.com/RhetTbull/osxphotos/compare/v0.28.10...v0.28.13)
@@ -1349,6 +1558,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Updated setup.py and README with install instructions [`85d2baa`](https://github.com/RhetTbull/osxphotos/commit/85d2baac104fbd0db5cccc0888a55805a2385b9a)
#### [0.28.2](https://github.com/RhetTbull/osxphotos/compare/v0.28.1...0.28.2)
@@ -1427,6 +1638,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
@@ -1505,6 +1718,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Fixed bug in --download-missing related to burst images [`1f13ba8`](https://github.com/RhetTbull/osxphotos/commit/1f13ba837fe36ff4eeb48cca02f5312a88a0a765)
- test library update [`acb6b9e`](https://github.com/RhetTbull/osxphotos/commit/acb6b9e72f7f6b8f4f1d64b46f270a4d3e984fef)
#### [v0.22.13](https://github.com/RhetTbull/osxphotos/compare/v0.22.12...v0.22.13)
@@ -1538,6 +1753,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Slight refactor to PhotosDB.photos() [`91d5729`](https://github.com/RhetTbull/osxphotos/commit/91d5729beaa0f0c2583e6320b18d958429e66075)
- 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)
- 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)
@@ -1550,6 +1767,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added XMP sidecar to export [`4dfb131`](https://github.com/RhetTbull/osxphotos/commit/4dfb131a21b1b1efefe3b918ecb06fc6fcb03f2c)
- 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)
#### [v0.22.4](https://github.com/RhetTbull/osxphotos/compare/v0.22.0...v0.22.4)
@@ -1560,6 +1779,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Refactor cli: singular --db, --json and query options. [`e214746`](https://github.com/RhetTbull/osxphotos/commit/e214746063271e6f9f586286103ed051ada49d85)
- 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)
#### [v0.22.0](https://github.com/RhetTbull/osxphotos/compare/v0.21.5...v0.22.0)
@@ -1610,6 +1831,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- removed old applescript code and files [`1839593`](https://github.com/RhetTbull/osxphotos/commit/18395933a583314d5d992492713752003852e75c)
- Added test cases and documentation for shared photos and shared albums [`6d20e9e`](https://github.com/RhetTbull/osxphotos/commit/6d20e9e36185aa027d82237cadfe3b55614ba96f)
- Refactored PhotoInfo to use properties instead of methods--major update [`1ddd90c`](https://github.com/RhetTbull/osxphotos/commit/1ddd90cbdc824afc5df9d2347e730bd9f86350ee)
- Moved PhotosDB attributes to properties instead of methods [`d95acdf`](https://github.com/RhetTbull/osxphotos/commit/d95acdf9f8764a1720bcba71a6dad29bf668eaf9)
- changed interface for export, prepped for exiftool_json_sidecar [`1fe8859`](https://github.com/RhetTbull/osxphotos/commit/1fe885962e8a9a420e776bdd3dc640ca143224b2)
#### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.15.0...v0.15.1)
@@ -1633,6 +1856,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added get_db_path and get_library_path to PhotosDB [`1d006a4`](https://github.com/RhetTbull/osxphotos/commit/1d006a4b50ed58b01c6116734bef5f740655a063)
- Updated PhotosDB.__init__() to accept positional or named arg for dbfile and added associated tests [`9118043`](https://github.com/RhetTbull/osxphotos/commit/911804317b98bf485a39b8588c772be14314aa51)
- Updated album code in process_database4 and process_database5 to use album uuid [`1cf3e4b`](https://github.com/RhetTbull/osxphotos/commit/1cf3e4b9540c15f8bda2545deb183912bcda40a7)
- Updated get_db_version and associated tests [`eb563ad`](https://github.com/RhetTbull/osxphotos/commit/eb563ad29738f29f3514ebfb4747baa2dc5356be)
- Added external_edit for Photos 5 [`42baa29`](https://github.com/RhetTbull/osxphotos/commit/42baa29c18fe2ff16e4d684f87ef7a85993898c1)
#### [v0.14.8](https://github.com/RhetTbull/osxphotos/compare/v0.14.6...v0.14.8)

15
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,15 @@
# Contributing
Contributions of all kinds are welcome! You don't need to know python to contribute to this project. For example, documentation updates are just as welcome as code!
Please explore open [issues](https://github.com/RhetTbull/osxphotos/issues), [discussions](https://github.com/RhetTbull/osxphotos/discussions), and the project [wiki](https://github.com/RhetTbull/osxphotos/wiki) to learn more about the project.
If you want to contribute source code, I recommend you explore the [wiki](https://github.com/RhetTbull/osxphotos/wiki/Structure-of-the-code) to learn about the source structure first.
See the [README.md](tests/README.md) in the tests directory before running any tests.
## Code of Conduct
Be nice to each other. Treat everyone with dignity and respect.
Abusive behavior of any kind will not be tolerated here.

530
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,9 @@ You can also easily export both the original and edited photos.
Supported operating systems
---------------------------
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Catalina (10.15.7).
Beta support for macOS Big Sur (10.16.01/11.01).
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) through macOS Big Sur (11.3).
If you have access to macOS 12 / Monterey beta and would like to help ensure osxphotos is compatible, please contact me via GitHub.
This package will read Photos databases for any supported version on any supported macOS version.
E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running macOS 10.12 and vice versa.
@@ -109,6 +110,8 @@ Alternatively, you can also run the command line utility like this: ``python3 -m
persons Print out persons (faces) found in the Photos library.
places Print out places found in the Photos library.
query Query the Photos database using 1 or more search options; if...
repl Run interactive osxphotos shell
tutorial Display osxphotos tutorial.
To get help on a specific command, use ``osxphotos help <command_name>``

7
dev_requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
pytest==6.2.4
pytest-mock==3.6.1
Sphinx==4.0.2
sphinx-rtd-theme==0.5.2
wheel==0.36.2
twine==3.4.1
pyinstaller==4.3

54
examples/post_function.py Normal file
View File

@@ -0,0 +1,54 @@
""" Example function for use with osxphotos export --post-function option """
from osxphotos import PhotoInfo, ExportResults
def post_function(
photo: PhotoInfo, results: ExportResults, verbose: callable, **kwargs
):
"""Call this with osxphotos export /path/to/export --post-function post_function.py::post_function
This will get called immediately after the photo has been exported
Args:
photo: PhotoInfo instance for the photo that's just been exported
results: ExportResults instance with information about the files associated with the exported photo
verbose: A function to print verbose output if --verbose is set; if --verbose is not set, acts as a no-op (nothing gets printed)
**kwargs: reserved for future use; recommend you include **kwargs so your function still works if additional arguments are added in future versions
Notes:
Use verbose(str) instead of print if you want your function to conditionally output text depending on --verbose flag
Any string printed with verbose that contains "warning" or "error" (case-insensitive) will be printed with the appropriate warning or error color
Will not be called if --dry-run flag is enabled
Will be called immediately after export and before any --post-command commands are executed
"""
# ExportResults has the following properties
# fields with filenames contain the full path to the file
# exported: list of all files exported
# new: list of all new files exported (--update)
# updated: list of all files updated (--update)
# skipped: list of all files skipped (--update)
# exif_updated: list of all files that were updated with --exiftool
# touched: list of all files that had date updated with --touch-file
# converted_to_jpeg: list of files converted to jpeg with --convert-to-jpeg
# sidecar_json_written: list of all JSON sidecar files written
# sidecar_json_skipped: list of all JSON sidecar files skipped (--update)
# sidecar_exiftool_written: list of all exiftool sidecar files written
# sidecar_exiftool_skipped: list of all exiftool sidecar files skipped (--update)
# sidecar_xmp_written: list of all XMP sidecar files written
# sidecar_xmp_skipped: list of all XMP sidecar files skipped (--update)
# missing: list of all missing files
# error: list tuples of (filename, error) for any errors generated during export
# exiftool_warning: list of tuples of (filename, warning) for any warnings generated by exiftool with --exiftool
# exiftool_error: list of tuples of (filename, error) for any errors generated by exiftool with --exiftool
# xattr_written: list of files that had extended attributes written
# xattr_skipped: list of files that where extended attributes were skipped (--update)
# deleted_files: list of deleted files
# deleted_directories: list of deleted directories
# exported_album: list of tuples of (filename, album_name) for exported files added to album with --add-exported-to-album
# skipped_album: list of tuples of (filename, album_name) for skipped files added to album with --add-skipped-to-album
# missing_album: list of tuples of (filename, album_name) for missing files added to album with --add-missing-to-album
for filename in results.exported:
# do your processing here
verbose(f"post_function: {photo.original_filename} exported as {filename}")

View File

@@ -0,0 +1,31 @@
""" example function for osxphotos --query-function """
from typing import List
from osxphotos import PhotoInfo
# call this with --query-function examples/query_function.py::best_selfies
def best_selfies(photos: List[PhotoInfo]) -> List[PhotoInfo]:
"""your query function should take a list of PhotoInfo objects and return a list of PhotoInfo objects (or empty list)"""
# this example finds your best selfie for every year
# get list of selfies sorted by date
photos = sorted([p for p in photos if p.selfie], key=lambda p: p.date)
if not photos:
return []
start_year = photos[0].date.year
stop_year = photos[-1].date.year
best_selfies = []
for year in range(start_year, stop_year + 1):
# find best selfie each year as determined by overall aesthetic score
selfies = sorted(
[p for p in photos if p.date.year == year],
key=lambda p: p.score.overall,
reverse=True,
)
if selfies:
best_selfies.append(selfies[0])
return best_selfies

View File

@@ -8,41 +8,50 @@ import importlib
pathex = os.getcwd()
# include necessary data files
datas=[('osxphotos/templates/xmp_sidecar.mako', 'osxphotos/templates'), ('osxphotos/templates/xmp_sidecar_beta.mako', 'osxphotos/templates'), ('osxphotos/phototemplate.tx', 'osxphotos'), ('osxphotos/phototemplate.md', 'osxphotos')]
package_imports = [['photoscript', ['photoscript.applescript']]]
datas = [
("osxphotos/templates/xmp_sidecar.mako", "osxphotos/templates"),
("osxphotos/templates/xmp_sidecar_beta.mako", "osxphotos/templates"),
("osxphotos/phototemplate.tx", "osxphotos"),
("osxphotos/phototemplate.md", "osxphotos"),
("osxphotos/tutorial.md", "osxphotos"),
]
package_imports = [["photoscript", ["photoscript.applescript"]]]
for package, files in package_imports:
proot = os.path.dirname(importlib.import_module(package).__file__)
datas.extend((os.path.join(proot, f), package) for f in files)
block_cipher = None
a = Analysis(['cli.py'],
pathex=[pathex],
binaries=[],
datas=datas,
hiddenimports=['pkg_resources.py2_warn'],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
a = Analysis(
["cli.py"],
pathex=[pathex],
binaries=[],
datas=datas,
hiddenimports=["pkg_resources.py2_warn"],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='osxphotos',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True )
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="osxphotos",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
)

View File

@@ -1,5 +1,6 @@
from ._version import __version__
from .photoinfo import PhotoInfo
from .exiftool import ExifTool
from .photoinfo import ExportResults, PhotoInfo
from .photosdb import PhotosDB
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
from .phototemplate import PhotoTemplate
@@ -7,5 +8,4 @@ from .queryoptions import QueryOptions
from .utils import _debug, _get_logger, _set_debug
# TODO: Add test for imageTimeZoneOffsetSeconds = None
# TODO: Add test for __str__ and to_json
# TODO: Add special albums and magic albums

View File

@@ -1,162 +0,0 @@
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
import sys
from Foundation import NSAppleScript, NSAppleEventDescriptor, NSURL, \
NSAppleScriptErrorMessage, NSAppleScriptErrorBriefMessage, \
NSAppleScriptErrorNumber, NSAppleScriptErrorAppName, NSAppleScriptErrorRange
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
from . import kae
__all__ = ['AppleScript', 'ScriptError', 'AEType', 'AEEnum', 'kMissingValue', 'kae']
######################################################################
class AppleScript:
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
"""
_codecs = Codecs()
def __init__(self, source=None, path=None):
"""
source : str | None -- AppleScript source code
path : str | None -- full path to .scpt/.applescript file
Notes:
- Either the path or the source argument must be provided.
- If the script cannot be read/compiled, a ScriptError is raised.
"""
if path:
url = NSURL.fileURLWithPath_(path)
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(url, None)
if errorinfo:
raise ScriptError(errorinfo)
elif source:
self._script = NSAppleScript.alloc().initWithSource_(source)
else:
raise ValueError("Missing source or path argument.")
if not self._script.isCompiled():
errorinfo = self._script.compileAndReturnError_(None)[1]
if errorinfo:
raise ScriptError(errorinfo)
def __repr__(self):
s = self.source
return 'AppleScript({})'.format(repr(s) if len(s) < 100 else '{}...{}'.format(repr(s)[:80], repr(s)[-17:]))
##
def _newevent(self, suite, code, args):
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
fourcharcode(suite), fourcharcode(code), NSAppleEventDescriptor.nullDescriptor(), 0, 0)
evt.setDescriptor_forKeyword_(self._codecs.pack(args), fourcharcode(kae.keyDirectObject))
return evt
def _unpackresult(self, result, errorinfo):
if not result:
raise ScriptError(errorinfo)
return self._codecs.unpack(result)
##
source = property(lambda self: str(self._script.source()), doc="str -- the script's source code")
def run(self, *args):
""" Run the script, optionally passing arguments to its run handler.
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
Result : anything | None -- the script's return value, if any
Notes:
- The run handler must be explicitly declared in order to pass arguments.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
if args:
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
else:
return self._unpackresult(*self._script.executeAndReturnError_(None))
def call(self, name, *args):
""" Call the specified user-defined handler.
name : str -- the handler's name (case-sensitive)
args : anything -- arguments to pass to script, if any; see documentation for supported types
Result : anything | None -- the script's return value, if any
Notes:
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
evt = self._newevent(kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args)
evt.setDescriptor_forKeyword_(NSAppleEventDescriptor.descriptorWithString_(name),
fourcharcode(kae.keyASSubroutineName))
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
##
class ScriptError(Exception):
""" Indicates an AppleScript compilation/execution error. """
def __init__(self, errorinfo):
self._errorinfo = dict(errorinfo)
def __repr__(self):
return 'ScriptError({})'.format(self._errorinfo)
@property
def message(self):
""" str -- the error message """
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
if not msg:
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, 'Script Error')
return msg
number = property(lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
doc="int | None -- the error number, if given")
appname = property(lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
doc="str | None -- the name of the application that reported the error, where relevant")
@property
def range(self):
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
range = self._errorinfo.get(NSAppleScriptErrorRange)
if range:
start = range.rangeValue().location
end = start + range.rangeValue().length
return (start, end)
else:
return None
def __str__(self):
msg = self.message
for s, v in [(' ({})', self.number), (' app={!r}', self.appname), (' range={0[0]}-{0[1]}', self.range)]:
if v is not None:
msg += s.format(v)
return msg.encode('ascii', 'replace') if sys.version_info.major < 3 else msg # 2.7 compatibility
##
kMissingValue = AEType(kae.cMissingValue) # convenience constant

View File

@@ -1,269 +0,0 @@
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
import datetime, struct, sys
from Foundation import NSAppleEventDescriptor, NSURL
from . import kae
__all__ = ['Codecs', 'AEType', 'AEEnum']
######################################################################
def fourcharcode(code):
""" Convert four-char code for use in NSAppleEventDescriptor methods.
code : bytes -- four-char code, e.g. b'utxt'
Result : int -- OSType, e.g. 1970567284
"""
return struct.unpack('>I', code)[0]
#######
class Codecs:
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
kMacEpoch = datetime.datetime(1904, 1, 1)
kUSRF = fourcharcode(kae.keyASUserRecordFields)
def __init__(self):
# Clients may add/remove/replace encoder and decoder items:
self.encoders = {
NSAppleEventDescriptor.class__(): self.packdesc,
type(None): self.packnone,
bool: self.packbool,
int: self.packint,
float: self.packfloat,
bytes: self.packbytes,
str: self.packstr,
list: self.packlist,
tuple: self.packlist,
dict: self.packdict,
datetime.datetime: self.packdatetime,
AEType: self.packtype,
AEEnum: self.packenum,
}
if sys.version_info.major < 3: # 2.7 compatibility
self.encoders[unicode] = self.packstr
self.decoders = {fourcharcode(k): v for k, v in {
kae.typeNull: self.unpacknull,
kae.typeBoolean: self.unpackboolean,
kae.typeFalse: self.unpackboolean,
kae.typeTrue: self.unpackboolean,
kae.typeSInt32: self.unpacksint32,
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
kae.typeUTF8Text: self.unpackunicodetext,
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
kae.typeUnicodeText: self.unpackunicodetext,
kae.typeLongDateTime: self.unpacklongdatetime,
kae.typeAEList: self.unpackaelist,
kae.typeAERecord: self.unpackaerecord,
kae.typeAlias: self.unpackfile,
kae.typeFSS: self.unpackfile,
kae.typeFSRef: self.unpackfile,
kae.typeFileURL: self.unpackfile,
kae.typeType: self.unpacktype,
kae.typeEnumeration: self.unpackenumeration,
}.items()}
def pack(self, data):
"""Pack Python data.
data : anything -- a Python value
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
"""
try:
return self.encoders[data.__class__](data) # quick lookup by type/class
except (KeyError, AttributeError) as e:
for type, encoder in self.encoders.items(): # slower but more thorough lookup that can handle subtypes/subclasses
if isinstance(data, type):
return encoder(data)
raise TypeError("Can't pack data into an AEDesc (unsupported type): {!r}".format(data))
def unpack(self, desc):
"""Unpack an Apple event descriptor.
desc : NSAppleEventDescriptor
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
"""
decoder = self.decoders.get(desc.descriptorType())
# unpack known type
if decoder:
return decoder(desc)
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
if rec:
rec = self.unpackaerecord(rec)
rec[AEType(kae.pClass)] = AEType(struct.pack('>I', desc.descriptorType()))
return rec
# return as-is
return desc
##
def _packbytes(self, desctype, data):
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
fourcharcode(desctype), data, len(data))
def packdesc(self, val):
return val
def packnone(self, val):
return NSAppleEventDescriptor.nullDescriptor()
def packbool(self, val):
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
def packint(self, val):
if (-2**31) <= val < (2**31):
return NSAppleEventDescriptor.descriptorWithInt32_(val)
else:
return self.pack(float(val))
def packfloat(self, val):
return self._packbytes(kae.typeFloat, struct.pack('d', val))
def packbytes(self, val):
return self._packbytes(kae.typeData, val)
def packstr(self, val):
return NSAppleEventDescriptor.descriptorWithString_(val)
def packdatetime(self, val):
delta = val - self.kMacEpoch
sec = delta.days * 3600 * 24 + delta.seconds
return self._packbytes(kae.typeLongDateTime, struct.pack('q', sec))
def packlist(self, val):
lst = NSAppleEventDescriptor.listDescriptor()
for item in val:
lst.insertDescriptor_atIndex_(self.pack(item), 0)
return lst
def packdict(self, val):
record = NSAppleEventDescriptor.recordDescriptor()
usrf = desctype = None
for key, value in val.items():
if isinstance(key, AEType):
if key.code == kae.pClass and isinstance(value, AEType): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
desctype = value
else:
record.setDescriptor_forKeyword_(self.pack(value), fourcharcode(key.code))
else:
if not usrf:
usrf = NSAppleEventDescriptor.listDescriptor()
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
if usrf:
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
if desctype:
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
if newrecord:
record = newrecord
else: # coercion failed for some reason, so pack as normal key-value pair
record.setDescriptor_forKeyword_(self.pack(desctype), fourcharcode(key.code))
return record
def packtype(self, val):
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
def packenum(self, val):
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
#######
def unpacknull(self, desc):
return None
def unpackboolean(self, desc):
return desc.booleanValue()
def unpacksint32(self, desc):
return desc.int32Value()
def unpackfloat64(self, desc):
return struct.unpack('d', bytes(desc.data()))[0]
def unpackunicodetext(self, desc):
return desc.stringValue()
def unpacklongdatetime(self, desc):
return self.kMacEpoch + datetime.timedelta(seconds=struct.unpack('q', bytes(desc.data()))[0])
def unpackaelist(self, desc):
return [self.unpack(desc.descriptorAtIndex_(i + 1)) for i in range(desc.numberOfItems())]
def unpackaerecord(self, desc):
dct = {}
for i in range(desc.numberOfItems()):
key = desc.keywordForDescriptorAtIndex_(i + 1)
value = desc.descriptorForKeyword_(key)
if key == self.kUSRF:
lst = self.unpackaelist(value)
for i in range(0, len(lst), 2):
dct[lst[i]] = lst[i+1]
else:
dct[AEType(struct.pack('>I', key))] = self.unpack(value)
return dct
def unpacktype(self, desc):
return AEType(struct.pack('>I', desc.typeCodeValue()))
def unpackenumeration(self, desc):
return AEEnum(struct.pack('>I', desc.enumCodeValue()))
def unpackfile(self, desc):
url = bytes(desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()).decode('utf8')
return NSURL.URLWithString_(url).path()
#######
class AETypeBase:
""" Base class for AEType and AEEnum.
Notes:
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
"""
def __init__(self, code):
"""
code : bytes -- four-char code, e.g. b'utxt'
"""
if not isinstance(code, bytes):
raise TypeError('invalid code (not a bytes object): {!r}'.format(code))
elif len(code) != 4:
raise ValueError('invalid code (not four bytes long): {!r}'.format(code))
self._code = code
code = property(lambda self:self._code, doc="bytes -- four-char code, e.g. b'utxt'")
def __hash__(self):
return hash(self._code)
def __eq__(self, val):
return val.__class__ == self.__class__ and val.code == self._code
def __ne__(self, val):
return not self == val
def __repr__(self):
return "{}({!r})".format(self.__class__.__name__, self._code)
##
class AEType(AETypeBase):
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
class AEEnum(AETypeBase):
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""

File diff suppressed because it is too large Load Diff

View File

@@ -34,11 +34,12 @@ _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.6
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.7 (also Big Sur and Monterey which switch to model version)
# Ranges for model version by Photos version
_PHOTOS_5_MODEL_VERSION = [13000, 13999]
_PHOTOS_6_MODEL_VERSION = [14000, 14999]
_PHOTOS_7_MODEL_VERSION = [15000, 15999] # Monterey developer preview is 15134
# some table names differ between Photos 5 and Photos 6
_DB_TABLE_NAMES = {
@@ -49,6 +50,10 @@ _DB_TABLE_NAMES = {
"ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_34ASSETS",
"IMPORT_FOK": "ZGENERICASSET.Z_FOK_IMPORTSESSION",
"DEPTH_STATE": "ZGENERICASSET.ZDEPTHSTATES",
"UTI_ORIGINAL": "ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER",
"ASSET_ALBUM_JOIN": "Z_26ASSETS.Z_26ALBUMS",
"ASSET_ALBUM_TABLE": "Z_26ASSETS",
"HDR_TYPE": "ZCUSTOMRENDEREDVALUE",
},
6: {
"ASSET": "ZASSET",
@@ -57,6 +62,22 @@ _DB_TABLE_NAMES = {
"ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_3ASSETS",
"IMPORT_FOK": "null",
"DEPTH_STATE": "ZASSET.ZDEPTHTYPE",
"UTI_ORIGINAL": "ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER",
"ASSET_ALBUM_JOIN": "Z_26ASSETS.Z_26ALBUMS",
"ASSET_ALBUM_TABLE": "Z_26ASSETS",
"HDR_TYPE": "ZCUSTOMRENDEREDVALUE",
},
7: {
"ASSET": "ZASSET",
"KEYWORD_JOIN": "Z_1KEYWORDS.Z_38KEYWORDS",
"ALBUM_JOIN": "Z_27ASSETS.Z_3ASSETS",
"ALBUM_SORT_ORDER": "Z_27ASSETS.Z_FOK_3ASSETS",
"IMPORT_FOK": "null",
"DEPTH_STATE": "ZASSET.ZDEPTHTYPE",
"UTI_ORIGINAL": "ZINTERNALRESOURCE.ZCOMPACTUTI",
"ASSET_ALBUM_JOIN": "Z_27ASSETS.Z_27ALBUMS",
"ASSET_ALBUM_TABLE": "Z_27ASSETS",
"HDR_TYPE": "ZHDRTYPE",
},
}
@@ -70,6 +91,8 @@ _TESTED_OS_VERSIONS = [
("11", "0"),
("11", "1"),
("11", "2"),
("11", "3"),
("11", "4"),
]
# Photos 5 has persons who are empty string if unidentified face
@@ -187,6 +210,9 @@ DEFAULT_EDITED_SUFFIX = "_edited"
# Default suffix to add to original images
DEFAULT_ORIGINAL_SUFFIX = ""
# Default suffix to add to preview images
DEFAULT_PREVIEW_SUFFIX = "_preview"
# Colors for print CLI messages
CLI_COLOR_ERROR = "red"
CLI_COLOR_WARNING = "yellow"
@@ -212,8 +238,31 @@ EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
# bit flags for burst images ("burstPickType")
BURST_NOT_SELECTED = 0b10 # 2: burst image is not selected
BURST_DEFAULT_PICK = 0b100 # 4: burst image is the one Photos picked to be key image before any selections made
BURST_SELECTED = 0b1000 # 8: burst image is selected
BURST_KEY = 0b10000 # 16: burst image is the key photo (top of burst stack)
BURST_UNKNOWN = 0b100000 # 32: this is almost always set with BURST_DEFAULT_PICK and never if BURST_DEFAULT_PICK is not set. I think this has something to do with what algorithm Photos used to pick the default image
BURST_NOT_SELECTED = 0b10 # 2: burst image is not selected
BURST_DEFAULT_PICK = 0b100 # 4: burst image is the one Photos picked to be key image before any selections made
BURST_SELECTED = 0b1000 # 8: burst image is selected
BURST_KEY = 0b10000 # 16: burst image is the key photo (top of burst stack)
BURST_UNKNOWN = 0b100000 # 32: this is almost always set with BURST_DEFAULT_PICK and never if BURST_DEFAULT_PICK is not set. I think this has something to do with what algorithm Photos used to pick the default image
LIVE_VIDEO_EXTENSIONS = [".mov"]
# categories that --post-command can be used with; these map to ExportResults fields
POST_COMMAND_CATEGORIES = {
"exported": "All exported files",
"new": "When used with '--update', all newly exported files",
"updated": "When used with '--update', all files which were previously exported but updated this time",
"skipped": "When used with '--update', all files which were skipped (because they were previously exported and didn't change)",
"missing": "All files which were not exported because they were missing from the Photos library",
"exif_updated": "When used with '--exiftool', all files on which exiftool updated the metadata",
"touched": "When used with '--touch-file', all files where the date was touched",
"converted_to_jpeg": "When used with '--convert-to-jpeg', all files which were converted to jpeg",
"sidecar_json_written": "When used with '--sidecar json', all JSON sidecar files which were written",
"sidecar_json_skipped": "When used with '--sidecar json' and '--update', all JSON sidecar files which were skipped",
"sidecar_exiftool_written": "When used with '--sidecar exiftool', all exiftool sidecar files which were written",
"sidecar_exiftool_skipped": "When used with '--sidecar exiftool' and '--update, all exiftool sidecar files which were skipped",
"sidecar_xmp_written": "When used with '--sidecar xmp', all XMP sidecar files which were written",
"sidecar_xmp_skipped": "When used with '--sidecar xmp' and '--update', all XMP sidecar files which were skipped",
"error": "All files which produced an error during export",
# "deleted_files": "When used with '--cleanup', all files deleted during the export",
# "deleted_directories": "When used with '--cleanup', all directories deleted during the export",
}

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.42.24"
__version__ = "0.42.57"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
"""Help text helper class for osxphotos CLI """
import io
import pathlib
import re
import click
@@ -12,16 +13,19 @@ from ._constants import (
EXTENDED_ATTRIBUTE_NAMES,
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
OSXPHOTOS_EXPORT_DB,
POST_COMMAND_CATEGORIES,
)
from .phototemplate import (
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
TEMPLATE_SUBSTITUTIONS_PATHLIB,
get_template_help,
)
# TODO: The following help text could probably be done as mako template
class ExportCommand(click.Command):
""" Custom click.Command that overrides get_help() to show additional help info for export """
"""Custom click.Command that overrides get_help() to show additional help info for export"""
def get_help(self, ctx):
help_text = super().get_help(ctx)
@@ -65,7 +69,9 @@ class ExportCommand(click.Command):
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
)
formatter.write("\n\n")
formatter.write(rich_text("[bold]** Extended Attributes **[/bold]", width=formatter.width))
formatter.write(
rich_text("[bold]** Extended Attributes **[/bold]", width=formatter.width)
)
formatter.write("\n")
formatter.write_text(
"""
@@ -99,7 +105,9 @@ The following attributes may be used with '--xattr-template':
"For additional information on extended attributes see: https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys"
)
formatter.write("\n\n")
formatter.write(rich_text("[bold]** Templating System **[/bold]", width=formatter.width))
formatter.write(
rich_text("[bold]** Templating System **[/bold]", width=formatter.width)
)
formatter.write("\n")
formatter.write(template_help(width=formatter.width))
formatter.write("\n")
@@ -128,7 +136,11 @@ The following attributes may be used with '--xattr-template':
+ "an error and the script will abort."
)
formatter.write("\n")
formatter.write(rich_text("[bold]** Template Substitutions **[/bold]", width=formatter.width))
formatter.write(
rich_text(
"[bold]** Template Substitutions **[/bold]", width=formatter.width
)
)
formatter.write("\n")
templ_tuples = [("Substitution", "Description")]
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items())
@@ -151,21 +163,127 @@ The following attributes may be used with '--xattr-template':
)
formatter.write_dl(templ_tuples)
formatter.write("\n")
formatter.write_text(
"The following substitutions are file or directory paths. "
+ "You can access various parts of the path using the following modifiers:"
)
formatter.write("\n")
formatter.write("{path.parent}: the parent directory\n")
formatter.write("{path.name}: the name of the file or final sub-directory\n")
formatter.write("{path.stem}: the name of the file without the extension\n")
formatter.write(
"{path.suffix}: the suffix of the file including the leading '.'\n"
)
formatter.write("\n")
formatter.write(
"For example, if the field {export_dir} is '/Shared/Backup/Photos':\n"
)
formatter.write("{export_dir.parent} is '/Shared/Backup'\n")
formatter.write("\n")
formatter.write(
"If the field {filepath} is '/Shared/Backup/Photos/IMG_1234.JPG':\n"
)
formatter.write("{filepath.parent} is '/Shared/Backup/Photos'\n")
formatter.write("{filepath.name} is 'IMG_1234.JPG'\n")
formatter.write("{filepath.stem} is 'IMG_1234'\n")
formatter.write("{filepath.suffix} is '.JPG'\n")
formatter.write("\n")
templ_tuples = [("Substitution", "Description")]
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS_PATHLIB.items())
formatter.write_dl(templ_tuples)
formatter.write("\n\n")
formatter.write(
rich_text("[bold]** Post Command **[/bold]", width=formatter.width)
)
formatter.write_text(
"You can run commands on the exported photos for post-processing "
+ "using the '--post-command' option. '--post-command' is passed a CATEGORY and a COMMAND. "
+ "COMMAND is an osxphotos template string which will be rendered and passed to the shell "
+ "for execution. CATEGORY is the category of file to pass to COMMAND. "
+ "The following categories are available: "
)
formatter.write("\n")
templ_tuples = [("Catgory", "Description")]
templ_tuples.extend((k, v) for k, v in POST_COMMAND_CATEGORIES.items())
formatter.write_dl(templ_tuples)
formatter.write("\n")
formatter.write_text(
"In addition to all normal template fields, the template fields "
+ "'{filepath}' and '{export_dir}' will be available to your command template. "
+ "Both of these are path-type templates which means their various parts can be accessed using "
+ "the available properties, e.g. '{filepath.name}' provides just the file name without path "
+ "and '{filepath.suffix}' is the file extension (suffix) of the file. "
+ "When using paths in your command template, it is important to properly quote the paths "
+ "as they will be passed to the shell and path names may contain spaces. "
+ "Both the '{shell_quote}' template and the '|shell_quote' template filter are available for "
+ "this purpose. For example, the following command outputs the full path of newly exported files to file 'new.txt': "
)
formatter.write("\n")
formatter.write(
'--post-command new "echo {filepath.name|shell_quote} >> {shell_quote,{export_dir}/exported.txt}"'
)
formatter.write("\n\n")
formatter.write_text(
"In the above command, the 'shell_quote' filter is used to ensure '{filepath.name}' is properly quoted "
+ "and the '{shell_quote}' template ensures the constructed path of '{exported_dir}/exported.txt' is properly quoted. "
"If '{filepath.name}' is 'IMG 1234.jpeg' and '{export_dir}' is '/Volumes/Photo Export', the command "
"thus renders to: "
)
formatter.write("\n")
formatter.write("echo 'IMG 1234.jpeg' >> '/Volumes/Photo Export/exported.txt'")
formatter.write("\n\n")
formatter.write_text(
"It is highly recommended that you run osxphotos with '--dry-run --verbose' "
+ "first to ensure your commands are as expected. This will not actually run the commands but will "
+ "print out the exact command string which would be executed."
)
formatter.write("\n\n")
formatter.write(
rich_text("[bold]** Post Function **[/bold]", width=formatter.width)
)
formatter.write_text(
"You can run your own python functions on the exported photos for post-processing "
+ "using the '--post-function' option. '--post-function' is passed the name a python file "
+ "and the name of the function in the file to call using format 'filename.py::function_name'. "
+ "See the example function at https://github.com/RhetTbull/osxphotos/blob/master/examples/post_function.py "
+ "You may specify multiple functions to run by repeating the --post-function option. "
+ "All post functions will be called immediately after export of each photo and immediately before any --post-command commands. "
+ "Post functions will not be called if the --dry-run flag is set."
)
formatter.write("\n")
help_text += formatter.getvalue()
return help_text
def template_help(width=78):
"""Return formatted string for template system """
"""Return formatted string for template system"""
sio = io.StringIO()
console = Console(file=sio, force_terminal=True, width=width)
template_help_md = strip_md_links(get_template_help())
template_help_md = strip_md_header_and_links(get_template_help())
console.print(Markdown(template_help_md))
help_str = sio.getvalue()
sio.close()
return help_str
def tutorial_help(width=78):
"""Return formatted string for tutorial"""
sio = io.StringIO()
console = Console(file=sio, force_terminal=True, width=width)
help_md = get_tutorial_text()
help_md = strip_html_comments(help_md)
help_md = strip_md_links(help_md)
console.print(Markdown(help_md))
help_str = sio.getvalue()
sio.close()
return help_str
def rich_text(text, width=78):
"""Return rich formatted text"""
sio = io.StringIO()
@@ -176,16 +294,16 @@ def rich_text(text, width=78):
return rich_text
def strip_md_links(md):
"""strip markdown links from markdown text md
def strip_md_header_and_links(md):
"""strip markdown headers and links from markdown text md
Args:
md: str, markdown text
Returns:
str with markdown links removed
Note: This uses a very basic regex that likely fails on all sorts of edge cases
Returns:
str with markdown headers and links removed
Note: This uses a very basic regex that likely fails on all sorts of edge cases
but works for the links in the osxphotos docs
"""
links = r"(?:[*#])|\[(.*?)\]\(.+?\)"
@@ -195,3 +313,36 @@ def strip_md_links(md):
return re.sub(links, subfn, md)
def strip_md_links(md):
"""strip markdown links from markdown text md
Args:
md: str, markdown text
Returns:
str with markdown links removed
Note: This uses a very basic regex that likely fails on all sorts of edge cases
but works for the links in the osxphotos docs
"""
links = r"\[(.*?)\]\(.+?\)"
def subfn(match):
return match.group(1)
return re.sub(links, subfn, md)
def strip_html_comments(text):
"""Strip html comments from text (which doesn't need to be valid HTML)"""
return re.sub(r"<!--(.|\s|\n)*?-->", "", text)
def get_tutorial_text():
"""Load tutorial text from file"""
# TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this
help_file = pathlib.Path(__file__).parent / "tutorial.md"
with open(help_file, "r") as fd:
md = fd.read()
return md

View File

@@ -23,12 +23,14 @@ EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
# list of exiftool processes to cleanup when exiting or when terminate is called
EXIFTOOL_PROCESSES = []
@atexit.register
def terminate_exiftool():
"""Terminate any running ExifTool subprocesses; call this to cleanup when done using ExifTool """
for proc in EXIFTOOL_PROCESSES:
proc._stop_proc()
@lru_cache(maxsize=1)
def get_exiftool_path():
""" return path of exiftool, cache result """
@@ -70,15 +72,14 @@ class _ExifToolProc:
self._exiftool = exiftool or get_exiftool_path()
self._start_proc()
EXIFTOOL_PROCESSES.append(self)
@property
def process(self):
""" return the exiftool subprocess """
if self._process_running:
return self._process
else:
raise ValueError("exiftool process is not running")
self._start_proc()
return self._process
@property
def pid(self):
@@ -116,15 +117,21 @@ class _ExifToolProc:
)
self._process_running = True
EXIFTOOL_PROCESSES.append(self)
def _stop_proc(self):
""" stop the exiftool process if it's running, otherwise, do nothing """
if not self._process_running:
return
self._process.stdin.write(b"-stay_open\n")
self._process.stdin.write(b"False\n")
self._process.stdin.flush()
try:
self._process.stdin.write(b"-stay_open\n")
self._process.stdin.write(b"False\n")
self._process.stdin.flush()
except Exception as e:
pass
try:
self._process.communicate(timeout=5)
except subprocess.TimeoutExpired:
@@ -134,9 +141,6 @@ class _ExifToolProc:
del self._process
self._process_running = False
def __del__(self):
self._stop_proc()
class ExifTool:
""" Basic exiftool interface for reading and writing EXIF tags """
@@ -162,9 +166,12 @@ class ExifTool:
# if running as a context manager, self._context_mgr will be True
self._context_mgr = False
self._exiftoolproc = _ExifToolProc(exiftool=exiftool)
self._process = self._exiftoolproc.process
self._read_exif()
@property
def _process(self):
return self._exiftoolproc.process
def setvalue(self, tag, value):
"""Set tag to value(s); if value is None, will delete tag
@@ -194,7 +201,7 @@ class ExifTool:
return True
else:
_, _, error = self.run_commands(*command)
return error is None
return error == ""
def addvalues(self, tag, *values):
"""Add one or more value(s) to tag
@@ -236,7 +243,7 @@ class ExifTool:
return True
else:
_, _, error = self.run_commands(*command)
return error is None
return error == ""
def run_commands(self, *commands, no_file=False):
"""Run commands in the exiftool process and return result.

View File

@@ -121,6 +121,6 @@ class ImageConverter:
return True
else:
raise ImageConversionError(
"Error converting file {input_path} to jpeg at {output_path}: {error}"
f"Error converting file {input_path} to jpeg at {output_path}: {error}"
)

View File

@@ -6,22 +6,22 @@ from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
def sanitize_filepath(filepath):
""" sanitize a filepath """
"""sanitize a filepath"""
return pathvalidate.sanitize_filepath(filepath, platform="macos")
def is_valid_filepath(filepath):
""" returns True if a filepath is valid otherwise False """
"""returns True if a filepath is valid otherwise False"""
return pathvalidate.is_valid_filepath(filepath, platform="macos")
def sanitize_filename(filename, replacement=":"):
""" replace any illegal characters in a filename and truncate filename if needed
"""replace any illegal characters in a filename and truncate filename if needed
Args:
filename: str, filename to sanitze
replacement: str, value to replace any illegal characters with; default = ":"
Returns:
filename with any illegal characters replaced by replacement and truncated if necessary
"""
@@ -46,12 +46,12 @@ def sanitize_filename(filename, replacement=":"):
def sanitize_dirname(dirname, replacement=":"):
""" replace any illegal characters in a directory name and truncate directory name if needed
"""replace any illegal characters in a directory name and truncate directory name if needed
Args:
dirname: str, directory name to sanitze
replacement: str, value to replace any illegal characters with; default = ":"
dirname: str, directory name to sanitize
replacement: str, value to replace any illegal characters with; default = ":"; if None, no replacement occurs
Returns:
dirname with any illegal characters replaced by replacement and truncated if necessary
"""
@@ -61,19 +61,20 @@ def sanitize_dirname(dirname, replacement=":"):
def sanitize_pathpart(pathpart, replacement=":"):
""" replace any illegal characters in a path part (either directory or filename without extension) and truncate name if needed
"""replace any illegal characters in a path part (either directory or filename without extension) and truncate name if needed
Args:
pathpart: str, path part to sanitze
replacement: str, value to replace any illegal characters with; default = ":"
pathpart: str, path part to sanitize
replacement: str, value to replace any illegal characters with; default = ":"; if None, no replacement occurs
Returns:
pathpart with any illegal characters replaced by replacement and truncated if necessary
"""
if pathpart:
pathpart = pathpart.replace("/", replacement)
pathpart = (
pathpart.replace("/", replacement) if replacement is not None else pathpart
)
if len(pathpart) > MAX_DIRNAME_LEN:
drop = len(pathpart) - MAX_DIRNAME_LEN
pathpart = pathpart[:-drop]
return pathpart

View File

@@ -7,4 +7,4 @@ 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
from .photoinfo import PhotoInfo, PhotoInfoNone

View File

@@ -3,13 +3,13 @@
import logging
import os
from ..exiftool import ExifTool, get_exiftool_path
from ..exiftool import ExifToolCaching, get_exiftool_path
@property
def exiftool(self):
""" Returns an ExifTool object for the photo
requires that exiftool (https://exiftool.org/) be installed
""" Returns a ExifToolCaching (read-only instance of 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
"""
@@ -20,7 +20,7 @@ def exiftool(self):
try:
exiftool_path = self._db._exiftool_path or get_exiftool_path()
if self.path is not None and os.path.isfile(self.path):
exiftool = ExifTool(self.path, exiftool=exiftool_path)
exiftool = ExifToolCaching(self.path, exiftool=exiftool_path)
else:
exiftool = None
except FileNotFoundError:

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ 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
"""
import dataclasses
import datetime
import json
@@ -12,6 +11,7 @@ import os
import os.path
import pathlib
from datetime import timedelta, timezone
from typing import Optional
import yaml
@@ -34,9 +34,10 @@ from .._constants import (
from ..adjustmentsinfo import AdjustmentsInfo
from ..albuminfo import AlbumInfo, ImportInfo
from ..personinfo import FaceInfo, PersonInfo
from ..phototemplate import PhotoTemplate
from ..phototemplate import PhotoTemplate, RenderOptions
from ..placeinfo import PlaceInfo4, PlaceInfo5
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
from ..uti import get_preferred_uti_extension, get_uti_for_extension
from ..utils import _debug, _get_resource_loc, findfiles
class PhotoInfo:
@@ -46,30 +47,31 @@ class PhotoInfo:
"""
# import additional methods
from ._photoinfo_searchinfo import (
search_info,
search_info_normalized,
labels,
labels_normalized,
SearchInfo,
)
from ._photoinfo_exifinfo import exif_info, ExifInfo
from ._photoinfo_comments import comments, likes
from ._photoinfo_exifinfo import ExifInfo, exif_info
from ._photoinfo_exiftool import exiftool
from ._photoinfo_export import (
export,
export2,
_export_photo,
ExportResults,
_exiftool_dict,
_exiftool_json_sidecar,
_export_photo,
_export_photo_with_photos_export,
_get_exif_keywords,
_get_exif_persons,
_write_exif_data,
_write_sidecar,
_xmp_sidecar,
ExportResults,
export,
export2,
)
from ._photoinfo_scoreinfo import ScoreInfo, score
from ._photoinfo_searchinfo import (
SearchInfo,
labels,
labels_normalized,
search_info,
search_info_normalized,
)
from ._photoinfo_scoreinfo import score, ScoreInfo
from ._photoinfo_comments import comments, likes
def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid
@@ -77,9 +79,12 @@ class PhotoInfo:
self._db = db
self._verbose = self._db._verbose
# TODO: remove this once refactor of PhotoExporter is done
self._render_options = RenderOptions()
@property
def filename(self):
""" filename of the picture """
"""filename of the picture"""
if (
self._db._db_version <= _PHOTOS_4_VERSION
and self.has_raw
@@ -107,7 +112,7 @@ class PhotoInfo:
@property
def date(self):
""" image creation date as timezone aware datetime object """
"""image creation date as timezone aware datetime object"""
return self._info["imageDate"]
@property
@@ -133,52 +138,22 @@ class PhotoInfo:
@property
def tzoffset(self):
""" timezone offset from UTC in seconds """
"""timezone offset from UTC in seconds"""
return self._info["imageTimeZoneOffsetSeconds"]
@property
def path(self):
""" absolute path on disk of the original picture """
"""absolute path on disk of the original picture"""
try:
return self._path
except AttributeError:
self._path = None
photopath = None
# TODO: should path try to return path even if ismissing?
if self._info["isMissing"] == 1:
return photopath # path would be meaningless until downloaded
if self._db._db_version <= _PHOTOS_4_VERSION:
if self._info["has_raw"]:
# return the path to JPEG even if RAW is original
vol = (
self._db._dbvolumes[self._info["raw_pair_info"]["volumeId"]]
if self._info["raw_pair_info"]["volumeId"] is not None
else None
)
if vol is not None:
photopath = os.path.join(
"/Volumes", vol, self._info["raw_pair_info"]["imagePath"]
)
else:
photopath = os.path.join(
self._db._masters_path,
self._info["raw_pair_info"]["imagePath"],
)
else:
vol = self._info["volume"]
if vol is not None:
photopath = os.path.join(
"/Volumes", vol, self._info["imagePath"]
)
else:
photopath = os.path.join(
self._db._masters_path, self._info["imagePath"]
)
if not os.path.isfile(photopath):
photopath = None
self._path = photopath
return photopath
return self._path_4()
if self._info["shared"]:
# shared photo
@@ -208,9 +183,40 @@ class PhotoInfo:
self._path = photopath
return photopath
def _path_4(self):
"""return path for photo on Photos <= version 4"""
if self._info["has_raw"]:
# return the path to JPEG even if RAW is original
vol = (
self._db._dbvolumes[self._info["raw_pair_info"]["volumeId"]]
if self._info["raw_pair_info"]["volumeId"] is not None
else None
)
if vol is not None:
photopath = os.path.join(
"/Volumes", vol, self._info["raw_pair_info"]["imagePath"]
)
else:
photopath = os.path.join(
self._db._masters_path,
self._info["raw_pair_info"]["imagePath"],
)
else:
vol = self._info["volume"]
if vol is not None:
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
else:
photopath = os.path.join(
self._db._masters_path, self._info["imagePath"]
)
if not os.path.isfile(photopath):
photopath = None
self._path = photopath
return photopath
@property
def path_edited(self):
""" absolute path on disk of the edited picture """
"""absolute path on disk of the edited picture"""
""" None if photo has not been edited """
try:
@@ -224,7 +230,7 @@ class PhotoInfo:
return self._path_edited
def _path_edited_5(self):
""" return path_edited for Photos >= 5 """
"""return path_edited for Photos >= 5"""
# In Photos 5.0 / Catalina / MacOS 10.15:
# edited photos appear to always be converted to .jpeg and stored in
# library_name/resources/renders/X/UUID_1_201_a.jpeg
@@ -247,14 +253,10 @@ class PhotoInfo:
filename = None
if self._info["type"] == _PHOTO_TYPE:
# it's a photo
if self._db._photos_ver == 5:
filename = f"{self._uuid}_1_201_a.jpeg"
if self._db._photos_ver != 5 and self.uti == "public.heic":
filename = f"{self._uuid}_1_201_a.heic"
else:
# could be a heic or a jpeg
if self.uti == "public.heic":
filename = f"{self._uuid}_1_201_a.heic"
else:
filename = f"{self._uuid}_1_201_a.jpeg"
filename = f"{self._uuid}_1_201_a.jpeg"
elif self._info["type"] == _MOVIE_TYPE:
# it's a movie
filename = f"{self._uuid}_2_0_a.mov"
@@ -282,7 +284,7 @@ class PhotoInfo:
return photopath
def _path_edited_4(self):
""" return path_edited for Photos <= 4 """
"""return path_edited for Photos <= 4"""
if self._db._db_version > _PHOTOS_4_VERSION:
raise RuntimeError("Wrong database format!")
@@ -340,9 +342,40 @@ class PhotoInfo:
return photopath
@property
def path_edited_live_photo(self):
"""return path to edited version of live photo movie; only valid for Photos 5+"""
if self._db._db_version < _PHOTOS_5_VERSION:
return None
try:
return self._path_edited_live_photo
except AttributeError:
self._path_edited_live_photo = self._path_edited_5_live_photo()
return self._path_edited_live_photo
def _path_edited_5_live_photo(self):
"""return path_edited_live_photo for Photos >= 5"""
if self._db._db_version < _PHOTOS_5_VERSION:
raise RuntimeError("Wrong database format!")
if self.live_photo and self._info["hasAdjustments"]:
library = self._db._library_path
directory = self._uuid[0] # first char of uuid
filename = f"{self._uuid}_2_100_a.mov"
photopath = os.path.join(
library, "resources", "renders", directory, filename
)
if not os.path.isfile(photopath):
photopath = None
else:
photopath = None
return photopath
@property
def path_raw(self):
""" absolute path of associated RAW image or None if there is not one """
"""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
@@ -369,60 +402,72 @@ class PhotoInfo:
# 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:
return self._path_raw_4()
if not self.isreference:
filestem = pathlib.Path(self._info["filename"]).stem
raw_ext = get_preferred_uti_extension(self._info["UTI_raw"])
# 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 files have same name as original but with _4.raw_ext appended
# I believe the _4 maps to PHAssetResourceTypeAlternatePhoto = 4
# see: https://developer.apple.com/documentation/photokit/phassetresourcetype/phassetresourcetypealternatephoto?language=objc
glob_str = f"{filestem}_4*"
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}")
if not raw_file:
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
photopath = pathlib.Path(filepath) / raw_file[0]
photopath = str(photopath) if photopath.is_file() else None
else:
# is a reference
try:
photopath = (
pathlib.Path("/Volumes")
/ self._info["raw_volume"]
/ self._info["raw_relative_path"]
)
photopath = str(photopath) if photopath.is_file() else None
except KeyError:
# don't have the path details
photopath = None
return photopath
def _path_raw_4(self):
"""Return path_raw for Photos <= version 4"""
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
@property
def description(self):
""" long / extended description of picture """
"""long / extended description of picture"""
return self._info["extendedDescription"]
@property
def persons(self):
""" list of persons in picture """
"""list of persons in picture"""
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 """
"""list of PersonInfo objects for person in picture"""
try:
return self._personinfo
except AttributeError:
@@ -433,7 +478,7 @@ class PhotoInfo:
@property
def face_info(self):
""" list of FaceInfo objects for faces in picture """
"""list of FaceInfo objects for faces in picture"""
try:
return self._faceinfo
except AttributeError:
@@ -447,7 +492,7 @@ class PhotoInfo:
@property
def albums(self):
""" list of albums picture is contained in """
"""list of albums picture is contained in"""
try:
return self._albums
except AttributeError:
@@ -459,7 +504,7 @@ class PhotoInfo:
@property
def burst_albums(self):
"""If photo is burst photo, list of albums it is contained in as well as any albums the key photo is contained in, otherwise returns self.albums """
"""If photo is burst photo, list of albums it is contained in as well as any albums the key photo is contained in, otherwise returns self.albums"""
try:
return self._burst_albums
except AttributeError:
@@ -472,7 +517,7 @@ class PhotoInfo:
@property
def album_info(self):
""" list of AlbumInfo objects representing albums the photo is contained in """
"""list of AlbumInfo objects representing albums the photo is contained in"""
try:
return self._album_info
except AttributeError:
@@ -484,7 +529,7 @@ class PhotoInfo:
@property
def burst_album_info(self):
""" If photo is a burst photo, returns list of AlbumInfo objects representing albums the photo is contained in as well as albums the burst key photo is contained in, otherwise returns self.album_info. """
"""If photo is a burst photo, returns list of AlbumInfo objects representing albums the photo is contained in as well as albums the burst key photo is contained in, otherwise returns self.album_info."""
try:
return self._burst_album_info
except AttributeError:
@@ -497,7 +542,7 @@ class PhotoInfo:
@property
def import_info(self):
""" ImportInfo object representing import session for the photo or None if no import session """
"""ImportInfo object representing import session for the photo or None if no import session"""
try:
return self._import_info
except AttributeError:
@@ -510,17 +555,17 @@ class PhotoInfo:
@property
def keywords(self):
""" list of keywords for picture """
"""list of keywords for picture"""
return self._info["keywords"]
@property
def title(self):
""" name / title of picture """
"""name / title of picture"""
return self._info["name"]
@property
def uuid(self):
""" UUID of picture """
"""UUID of picture"""
return self._uuid
@property
@@ -538,12 +583,12 @@ class PhotoInfo:
@property
def hasadjustments(self):
""" True if picture has adjustments / edits """
"""True if picture has adjustments / edits"""
return self._info["hasAdjustments"] == 1
@property
def adjustments(self):
""" Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only """
"""Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
@@ -567,32 +612,32 @@ class PhotoInfo:
@property
def external_edit(self):
""" Returns True if picture was edited outside of Photos using external editor """
"""Returns True if picture was edited outside of Photos using external editor"""
return self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit"
@property
def favorite(self):
""" True if picture is marked as favorite """
"""True if picture is marked as favorite"""
return self._info["favorite"] == 1
@property
def hidden(self):
""" True if picture is hidden """
"""True if picture is hidden"""
return self._info["hidden"] == 1
@property
def visible(self):
""" True if picture is visble """
"""True if picture is visble"""
return self._info["visible"]
@property
def intrash(self):
""" True if picture is in trash ('Recently Deleted' folder)"""
"""True if picture is in trash ('Recently Deleted' folder)"""
return self._info["intrash"]
@property
def date_trashed(self):
""" Date asset was placed in the trash or None """
"""Date asset was placed in the trash or None"""
# TODO: add add_timezone(dt, offset_seconds) to datetime_utils
# also update date_modified
trasheddate = self._info["trasheddate"]
@@ -606,7 +651,7 @@ class PhotoInfo:
@property
def date_added(self):
""" Date photo was added to the database """
"""Date photo was added to the database"""
try:
return self._date_added
except AttributeError:
@@ -623,7 +668,7 @@ class PhotoInfo:
@property
def location(self):
""" returns (latitude, longitude) as float in degrees or None """
"""returns (latitude, longitude) as float in degrees or None"""
return (self._latitude, self._longitude)
@property
@@ -657,13 +702,23 @@ class PhotoInfo:
"""Returns Uniform Type Identifier (UTI) for the original image
for example: public.jpeg or com.apple.quicktime-movie
"""
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
return self._info["raw_pair_info"]["UTI"]
elif self.shared:
# TODO: need reliable way to get original UTI for shared
return self.uti
else:
return self._info["UTI_original"]
try:
return self._uti_original
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
self._uti_original = self._info["raw_pair_info"]["UTI"]
elif self.shared:
# TODO: need reliable way to get original UTI for shared
self._uti_original = self.uti
elif self._db._photos_ver >= 7:
# Monterey+
self._uti_original = get_uti_for_extension(
pathlib.Path(self.original_filename).suffix
)
else:
self._uti_original = self._info["UTI_original"]
return self._uti_original
@property
def uti_edited(self):
@@ -682,7 +737,14 @@ class PhotoInfo:
for example: com.canon.cr2-raw-image
Returns None if no associated RAW image
"""
return self._info["UTI_raw"]
if self._db._photos_ver < 7:
return self._info["UTI_raw"]
rawpath = self.path_raw
if rawpath:
return get_uti_for_extension(pathlib.Path(rawpath).suffix)
else:
return None
@property
def ismovie(self):
@@ -719,27 +781,27 @@ class PhotoInfo:
@property
def isreference(self):
""" Returns True if photo is a reference (not copied to the Photos library), otherwise False """
"""Returns True if photo is a reference (not copied to the Photos library), otherwise False"""
return self._info["isreference"]
@property
def burst(self):
""" Returns True if photo is part of a Burst photo set, otherwise False """
"""Returns True if photo is part of a Burst photo set, otherwise False"""
return self._info["burst"]
@property
def burst_selected(self):
""" Returns True if photo is a burst photo and has been selected from the burst set by the user, otherwise False """
"""Returns True if photo is a burst photo and has been selected from the burst set by the user, otherwise False"""
return bool(self._info["burstPickType"] & BURST_SELECTED)
@property
def burst_key(self):
""" Returns True if photo is a burst photo and is the key image for the burst set (the image that Photos shows on top of the burst stack), otherwise False """
"""Returns True if photo is a burst photo and is the key image for the burst set (the image that Photos shows on top of the burst stack), otherwise False"""
return bool(self._info["burstPickType"] & BURST_KEY)
@property
def burst_default_pick(self):
""" Returns True if photo is a burst image and is the photo that Photos selected as the default image for the burst set, otherwise False """
"""Returns True if photo is a burst image and is the photo that Photos selected as the default image for the burst set, otherwise False"""
return bool(self._info["burstPickType"] & BURST_DEFAULT_PICK)
@property
@@ -759,7 +821,7 @@ class PhotoInfo:
@property
def live_photo(self):
""" Returns True if photo is a live photo, otherwise False """
"""Returns True if photo is a live photo, otherwise False"""
return self._info["live_photo"]
@property
@@ -820,24 +882,39 @@ class PhotoInfo:
@property
def path_derivatives(self):
""" Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first) """
if self._db._db_version <= _PHOTOS_4_VERSION:
return self._path_derivatives_4()
"""Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)"""
try:
return self._path_derivatives
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
self._path_derivatives = self._path_derivatives_4()
return self._path_derivatives
directory = self._uuid[0] # first char of uuid
derivative_path = (
pathlib.Path(self._db._library_path)
/ "resources"
/ "derivatives"
/ directory
)
files = derivative_path.glob(f"{self.uuid}*.*")
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
return [str(filename) for filename in files if filename.suffix != ".THM"]
directory = self._uuid[0] # first char of uuid
derivative_path = (
pathlib.Path(self._db._library_path)
/ "resources"
/ "derivatives"
/ directory
)
files = derivative_path.glob(f"{self.uuid}*.*")
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
derivatives = [
str(filename) for filename in files if filename.suffix != ".THM"
]
if (
self.isphoto
and len(derivatives) > 1
and derivatives[0].endswith(".mov")
):
derivatives[1], derivatives[0] = derivatives[0], derivatives[1]
self._path_derivatives = derivatives
return self._path_derivatives
def _path_derivatives_4(self):
""" Return paths to all derivative (preview) files for Photos <= 4"""
"""Return paths to all derivative (preview) files for Photos <= 4"""
modelid = self._info["modelID"]
if modelid is None:
return []
@@ -874,42 +951,42 @@ class PhotoInfo:
@property
def panorama(self):
""" Returns True if photo is a panorama, otherwise False """
"""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 """
"""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 """
"""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 """
"""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 """
"""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 """
"""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 """
"""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 """
"""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
@@ -937,12 +1014,12 @@ class PhotoInfo:
@property
def has_raw(self):
""" returns True if photo has an associated raw image (that is, it's a RAW+JPEG pair), otherwise False """
"""returns True if photo has an associated raw image (that is, it's a RAW+JPEG pair), otherwise False"""
return self._info["has_raw"]
@property
def israw(self):
""" returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw """
"""returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw"""
return "raw-image" in self.uti_original
@property
@@ -954,17 +1031,17 @@ class PhotoInfo:
@property
def height(self):
""" returns height of the current photo version in pixels """
"""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 """
"""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 or 0 if current orientation cannot be determined """
"""returns EXIF orientation of the current photo version as int or 0 if current orientation cannot be determined"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return self._info["orientation"]
@@ -980,76 +1057,63 @@ class PhotoInfo:
@property
def original_height(self):
""" returns height of the original photo version in pixels """
"""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 """
"""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 """
"""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 """
"""returns filesize of original photo in bytes as int"""
return self._info["original_filesize"]
@property
def duplicates(self):
"""return list of PhotoInfo objects for possible duplicates (matching signature of original size, date, height, width) or empty list if no matching duplicates"""
signature = self._db._duplicate_signature(self.uuid)
duplicates = []
try:
for uuid in self._db._db_signatures[signature]:
if uuid != self.uuid:
# found a possible duplicate
duplicates.append(self._db.get_photo(uuid))
except KeyError:
# don't expect this to happen as the signature should be in db
logging.warning(f"Did not find signature for {self.uuid} in _db_signatures")
return duplicates
def render_template(
self,
template_str,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
strip=False,
edited=False,
self, template_str: str, options: Optional[RenderOptions] = None
):
"""Renders a template string for PhotoInfo instance using PhotoTemplate
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 ','
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
strip: if True, strips leading/trailing white space from resulting template
edited: if True, sets {edited_version} field to True, otherwise it gets set to False; set if you want template evaluated for edited version
options: a RenderOptions instance
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
options = options or RenderOptions()
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
return template.render(
template_str,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
strip=strip,
edited_version=edited,
)
return template.render(template_str, options)
@property
def _longitude(self):
""" Returns longitude, in degrees """
"""Returns longitude, in degrees"""
return self._info["longitude"]
@property
def _latitude(self):
""" Returns latitude, in degrees """
"""Returns latitude, in degrees"""
return self._info["latitude"]
def _get_album_uuids(self):
@@ -1087,7 +1151,7 @@ class PhotoInfo:
return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})"
def __str__(self):
""" string representation of PhotoInfo object """
"""string representation of PhotoInfo object"""
date_iso = self.date.isoformat()
date_modified_iso = (
@@ -1150,7 +1214,7 @@ class PhotoInfo:
return yaml.dump(info, sort_keys=False)
def asdict(self):
""" return dict representation """
"""return dict representation"""
folders = {album.title: album.folder_names for album in self.album_info}
exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
@@ -1226,7 +1290,7 @@ class PhotoInfo:
}
def json(self):
""" Return JSON representation """
"""Return JSON representation"""
def default(o):
if isinstance(o, (datetime.date, datetime.datetime)):
@@ -1235,7 +1299,7 @@ class PhotoInfo:
return json.dumps(self.asdict(), sort_keys=True, default=default)
def __eq__(self, other):
""" Compare two PhotoInfo objects for equality """
"""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__):
@@ -1247,5 +1311,19 @@ class PhotoInfo:
return False
def __ne__(self, other):
""" Compare two PhotoInfo objects for inequality """
"""Compare two PhotoInfo objects for inequality"""
return not self.__eq__(other)
def __hash__(self):
"""Make PhotoInfo hashable"""
return hash(self.uuid)
class PhotoInfoNone:
"""mock class that returns None for all attributes"""
def __init__(self):
pass
def __getattribute__(self, name):
return None

View File

@@ -34,7 +34,8 @@ from Foundation import NSNotificationCenter, NSObject
from PyObjCTools import AppHelper
from .fileutil import FileUtil
from .utils import _get_os_version, get_preferred_uti_extension, increment_filename
from .uti import get_preferred_uti_extension
from .utils import _get_os_version, increment_filename
# NOTE: This requires user have granted access to the terminal (e.g. Terminal.app or iTerm)
# to access Photos. This should happen automatically the first time it's called. I've
@@ -64,7 +65,7 @@ MIN_SLEEP = 0.015
### utility functions
def NSURL_to_path(url):
""" Convert URL string as represented by NSURL to a path string """
"""Convert URL string as represented by NSURL to a path string"""
nsurl = Foundation.NSURL.alloc().initWithString_(
Foundation.NSString.alloc().initWithString_(str(url))
)
@@ -74,7 +75,7 @@ def NSURL_to_path(url):
def path_to_NSURL(path):
""" Convert path string to NSURL """
"""Convert path string to NSURL"""
pathstr = Foundation.NSString.alloc().initWithString_(str(path))
url = Foundation.NSURL.fileURLWithPath_(pathstr)
pathstr.dealloc()
@@ -82,10 +83,10 @@ def path_to_NSURL(path):
def check_photokit_authorization():
""" Check authorization to use user's Photos Library
"""Check authorization to use user's Photos Library
Returns:
True if user has authorized access to the Photos library, otherwise False
True if user has authorized access to the Photos library, otherwise False
"""
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
@@ -93,7 +94,7 @@ def check_photokit_authorization():
def request_photokit_authorization():
""" Request authorization to user's Photos Library
"""Request authorization to user's Photos Library
Returns:
authorization status
@@ -135,39 +136,39 @@ def request_photokit_authorization():
### exceptions
class PhotoKitError(Exception):
"""Base class for exceptions in this module. """
"""Base class for exceptions in this module."""
pass
class PhotoKitFetchFailed(PhotoKitError):
"""Exception raised for errors in the input. """
"""Exception raised for errors in the input."""
pass
class PhotoKitAuthError(PhotoKitError):
"""Exception raised if unable to authorize use of PhotoKit. """
"""Exception raised if unable to authorize use of PhotoKit."""
pass
class PhotoKitExportError(PhotoKitError):
"""Exception raised if unable to export asset. """
"""Exception raised if unable to export asset."""
pass
class PhotoKitMediaTypeError(PhotoKitError):
""" Exception raised if an unknown mediaType() is encountered """
"""Exception raised if an unknown mediaType() is encountered"""
pass
### helper classes
class ImageData:
""" Simple class to hold the data passed to the handler for
requestImageDataAndOrientationForAsset_options_resultHandler_
"""Simple class to hold the data passed to the handler for
requestImageDataAndOrientationForAsset_options_resultHandler_
"""
def __init__(
@@ -181,8 +182,7 @@ class ImageData:
class AVAssetData:
""" Simple class to hold the data passed to the handler for
"""
"""Simple class to hold the data passed to the handler for"""
def __init__(self):
self.asset = None
@@ -192,7 +192,7 @@ class AVAssetData:
class PHAssetResourceData:
""" Simple class to hold data from
"""Simple class to hold data from
requestDataForAssetResource:options:dataReceivedHandler:completionHandler:
"""
@@ -211,8 +211,8 @@ class PHAssetResourceData:
class PhotoKitNotificationDelegate(NSObject):
""" Handles notifications from NotificationCenter;
used with asynchronous PhotoKit requests to stop event loop when complete
"""Handles notifications from NotificationCenter;
used with asynchronous PhotoKit requests to stop event loop when complete
"""
def liveNotification_(self, note):
@@ -226,11 +226,11 @@ class PhotoKitNotificationDelegate(NSObject):
### main class implementation
class PhotoAsset:
""" PhotoKit PHAsset representation """
"""PhotoKit PHAsset representation"""
def __init__(self, manager, phasset):
""" Return a PhotoAsset object
"""Return a PhotoAsset object
Args:
manager = ImageManager object
phasset: a PHAsset object
@@ -241,32 +241,32 @@ class PhotoAsset:
@property
def phasset(self):
""" Return PHAsset instance """
"""Return PHAsset instance"""
return self._phasset
@property
def uuid(self):
""" Return local identifier (UUID) of PHAsset """
"""Return local identifier (UUID) of PHAsset"""
return self._phasset.localIdentifier()
@property
def isphoto(self):
""" Return True if asset is photo (image), otherwise False """
"""Return True if asset is photo (image), otherwise False"""
return self.media_type == Photos.PHAssetMediaTypeImage
@property
def ismovie(self):
""" Return True if asset is movie (video), otherwise False """
"""Return True if asset is movie (video), otherwise False"""
return self.media_type == Photos.PHAssetMediaTypeVideo
@property
def isaudio(self):
""" Return True if asset is audio, otherwise False """
"""Return True if asset is audio, otherwise False"""
return self.media_type == Photos.PHAssetMediaTypeAudio
@property
def original_filename(self):
""" Return original filename asset was imported with """
"""Return original filename asset was imported with"""
resources = self._resources()
for resource in resources:
if (
@@ -278,10 +278,22 @@ class PhotoAsset:
return resource.originalFilename()
return None
@property
def raw_filename(self):
"""Return RAW filename for RAW+JPEG photos or None if no RAW asset"""
resources = self._resources()
for resource in resources:
if (
self.isphoto
and resource.type() == Photos.PHAssetResourceTypeAlternatePhoto
):
return resource.originalFilename()
return None
@property
def hasadjustments(self):
""" Check to see if a PHAsset has adjustment data associated with it
Returns False if no adjustments, True if any adjustments """
"""Check to see if a PHAsset has adjustment data associated with it
Returns False if no adjustments, True if any adjustments"""
# reference: https://developer.apple.com/documentation/photokit/phassetresource/1623988-assetresourcesforasset?language=objc
@@ -298,112 +310,112 @@ class PhotoAsset:
@property
def media_type(self):
""" media type such as image or video """
"""media type such as image or video"""
return self.phasset.mediaType()
@property
def media_subtypes(self):
""" media subtype """
"""media subtype"""
return self.phasset.mediaSubtypes()
@property
def panorama(self):
""" return True if asset is panorama, otherwise False """
"""return True if asset is panorama, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoPanorama)
@property
def hdr(self):
""" return True if asset is HDR, otherwise False """
"""return True if asset is HDR, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoHDR)
@property
def screenshot(self):
""" return True if asset is screenshot, otherwise False """
"""return True if asset is screenshot, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoScreenshot)
@property
def live(self):
""" return True if asset is live, otherwise False """
"""return True if asset is live, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoLive)
@property
def streamed(self):
""" return True if asset is streamed video, otherwise False """
"""return True if asset is streamed video, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoStreamed)
@property
def slow_mo(self):
""" return True if asset is slow motion (high frame rate) video, otherwise False """
"""return True if asset is slow motion (high frame rate) video, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoHighFrameRate)
@property
def time_lapse(self):
""" return True if asset is time lapse video, otherwise False """
"""return True if asset is time lapse video, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoTimelapse)
@property
def portrait(self):
""" return True if asset is portrait (depth effect), otherwise False """
"""return True if asset is portrait (depth effect), otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoDepthEffect)
@property
def burstid(self):
""" return burstIdentifier of image if image is burst photo otherwise None """
"""return burstIdentifier of image if image is burst photo otherwise None"""
return self.phasset.burstIdentifier()
@property
def burst(self):
""" return True if image is burst otherwise False """
"""return True if image is burst otherwise False"""
return bool(self.burstid)
@property
def source_type(self):
""" the means by which the asset entered the user's library """
"""the means by which the asset entered the user's library"""
return self.phasset.sourceType()
@property
def pixel_width(self):
""" width in pixels """
"""width in pixels"""
return self.phasset.pixelWidth()
@property
def pixel_height(self):
""" height in pixels """
"""height in pixels"""
return self.phasset.pixelHeight()
@property
def date(self):
""" date asset was created """
"""date asset was created"""
return self.phasset.creationDate()
@property
def date_modified(self):
""" date asset was modified """
"""date asset was modified"""
return self.phasset.modificationDate()
@property
def location(self):
""" location of the asset """
"""location of the asset"""
return self.phasset.location()
@property
def duration(self):
""" duration of the asset """
"""duration of the asset"""
return self.phasset.duration()
@property
def favorite(self):
""" True if asset is favorite, otherwise False """
"""True if asset is favorite, otherwise False"""
return self.phasset.isFavorite()
@property
def hidden(self):
""" True if asset is hidden, otherwise False """
"""True if asset is hidden, otherwise False"""
return self.phasset.isHidden()
def metadata(self, version=PHOTOS_VERSION_CURRENT):
""" Return dict of asset metadata
"""Return dict of asset metadata
Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
"""
@@ -411,17 +423,28 @@ class PhotoAsset:
return imagedata.metadata
def uti(self, version=PHOTOS_VERSION_CURRENT):
""" Return UTI of asset
"""Return UTI of asset
Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
"""
imagedata = self._request_image_data(version=version)
return imagedata.uti
def uti_raw(self):
"""Return UTI of RAW component of RAW+JPEG pair"""
resources = self._resources()
for resource in resources:
if (
self.isphoto
and resource.type() == Photos.PHAssetResourceTypeAlternatePhoto
):
return resource.uniformTypeIdentifier()
return None
def url(self, version=PHOTOS_VERSION_CURRENT):
""" Return URL of asset
"""Return URL of asset
Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
"""
@@ -429,8 +452,8 @@ class PhotoAsset:
return str(imagedata.info["PHImageFileURLKey"])
def path(self, version=PHOTOS_VERSION_CURRENT):
""" Return path of asset
"""Return path of asset
Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
"""
@@ -439,8 +462,8 @@ class PhotoAsset:
return url.fileSystemRepresentation().decode("utf-8")
def orientation(self, version=PHOTOS_VERSION_CURRENT):
""" Return orientation of asset
"""Return orientation of asset
Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
"""
@@ -449,8 +472,8 @@ class PhotoAsset:
@property
def degraded(self, version=PHOTOS_VERSION_CURRENT):
""" Return True if asset is degraded version
"""Return True if asset is degraded version
Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
"""
@@ -458,15 +481,21 @@ class PhotoAsset:
return imagedata.info["PHImageResultIsDegradedKey"]
def export(
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
self,
dest,
filename=None,
version=PHOTOS_VERSION_CURRENT,
overwrite=False,
raw=False,
):
""" Export image to path
"""Export image to path
Args:
dest: str, path to destination directory
filename: str, optional name of exported file; if not provided, defaults to asset's original filename
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
overwrite: bool, if True, overwrites destination file if it already exists; default is False
raw: bool, if True, export RAW component of RAW+JPEG pair, default is False
Returns:
List of path to exported image(s)
@@ -491,11 +520,28 @@ class PhotoAsset:
output_file = None
if self.isphoto:
imagedata = self._request_image_data(version=version)
if not imagedata.image_data:
raise PhotoKitExportError("Could not get image data")
ext = get_preferred_uti_extension(imagedata.uti)
# will hold exported image data and needs to be cleaned up at end
imagedata = None
if raw:
# export the raw component
resources = self._resources()
for resource in resources:
if resource.type() == Photos.PHAssetResourceTypeAlternatePhoto:
data = self._request_resource_data(resource)
ext = pathlib.Path(self.raw_filename).suffix[1:]
break
else:
raise PhotoKitExportError(
"Could not get image data for RAW photo"
)
else:
# TODO: if user has selected use RAW as original, this returns the RAW
# can get the jpeg with resource.type() == Photos.PHAssetResourceTypePhoto
imagedata = self._request_image_data(version=version)
if not imagedata.image_data:
raise PhotoKitExportError("Could not get image data")
ext = get_preferred_uti_extension(imagedata.uti)
data = imagedata.image_data
output_file = dest / f"{filename.stem}.{ext}"
@@ -503,7 +549,9 @@ class PhotoAsset:
output_file = pathlib.Path(increment_filename(output_file))
with open(output_file, "wb") as fd:
fd.write(imagedata.image_data)
fd.write(data)
if imagedata:
del imagedata
elif self.ismovie:
videodata = self._request_video_data(version=version)
@@ -525,14 +573,14 @@ class PhotoAsset:
return [str(output_file)]
def _request_image_data(self, version=PHOTOS_VERSION_ORIGINAL):
""" Request image data and metadata for self._phasset
"""Request image data and metadata for self._phasset
Args:
version: which version to request
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
PHOTOS_VERSION_CURRENT, request current version with all edits
PHOTOS_VERSION_UNADJUSTED, request highest quality unadjusted version
Returns:
ImageData instance
@@ -562,8 +610,8 @@ class PhotoAsset:
event = threading.Event()
def handler(imageData, dataUTI, orientation, info):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
"""result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object)"""
nonlocal requestdata
@@ -593,19 +641,63 @@ class PhotoAsset:
del requestdata
return data
def _request_resource_data(self, resource):
"""Request asset resource data (either photo or video component)
Args:
resource: PHAssetResource to request
Raises:
"""
with objc.autorelease_pool():
resource_manager = Photos.PHAssetResourceManager.defaultManager()
options = Photos.PHAssetResourceRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
requestdata = PHAssetResourceData()
event = threading.Event()
def handler(data):
"""result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object)"""
nonlocal requestdata
requestdata.data += data
def completion_handler(error):
if error:
raise PhotoKitExportError(
"Error requesting data for asset resource"
)
event.set()
resource_manager.requestDataForAssetResource_options_dataReceivedHandler_completionHandler_(
resource, options, handler, completion_handler
)
event.wait()
# not sure why this is needed -- some weird ref count thing maybe
# if I don't do this, memory leaks
data = copy.copy(requestdata.data)
del requestdata
return data
def _make_result_handle_(self, data):
""" Make handler function and threading event to use with
requestImageDataAndOrientationForAsset_options_resultHandler_
data: Fetchdata class to hold resulting metadata
returns: handler function, threading.Event() instance
Following call to requestImageDataAndOrientationForAsset_options_resultHandler_,
data will hold data from the fetch """
"""Make handler function and threading event to use with
requestImageDataAndOrientationForAsset_options_resultHandler_
data: Fetchdata class to hold resulting metadata
returns: handler function, threading.Event() instance
Following call to requestImageDataAndOrientationForAsset_options_resultHandler_,
data will hold data from the fetch"""
event = threading.Event()
def handler(imageData, dataUTI, orientation, info):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
"""result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object)"""
nonlocal data
@@ -626,14 +718,14 @@ class PhotoAsset:
return handler, event
def _resources(self):
""" Return list of PHAssetResource for object """
"""Return list of PHAssetResource for object"""
resources = Photos.PHAssetResource.assetResourcesForAsset_(self.phasset)
return [resources.objectAtIndex_(idx) for idx in range(resources.count())]
class SlowMoVideoExporter(NSObject):
def initWithAVAsset_path_(self, avasset, path):
""" init helper class for exporting slow-mo video
"""init helper class for exporting slow-mo video
Args:
avasset: AVAsset
@@ -648,15 +740,17 @@ class SlowMoVideoExporter(NSObject):
return self
def exportSlowMoVideo(self):
""" export slow-mo video with AVAssetExportSession
"""export slow-mo video with AVAssetExportSession
Returns:
path to exported file
"""
with objc.autorelease_pool():
exporter = AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_(
self.avasset, AVFoundation.AVAssetExportPresetHighestQuality
exporter = (
AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_(
self.avasset, AVFoundation.AVAssetExportPresetHighestQuality
)
)
exporter.setOutputURL_(self.url)
exporter.setOutputFileType_(AVFoundation.AVFileTypeQuickTimeMovie)
@@ -665,7 +759,7 @@ class SlowMoVideoExporter(NSObject):
self.done = False
def handler():
""" result handler for exportAsynchronouslyWithCompletionHandler """
"""result handler for exportAsynchronouslyWithCompletionHandler"""
self.done = True
exporter.exportAsynchronouslyWithCompletionHandler_(handler)
@@ -699,7 +793,7 @@ class SlowMoVideoExporter(NSObject):
class VideoAsset(PhotoAsset):
""" PhotoKit PHAsset representation of video asset """
"""PhotoKit PHAsset representation of video asset"""
# TODO: doesn't work for slow-mo videos
# see https://stackoverflow.com/questions/26152396/how-to-access-nsdata-nsurl-of-slow-motion-videos-using-photokit
@@ -709,7 +803,7 @@ class VideoAsset(PhotoAsset):
def export(
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
):
""" Export video to path
"""Export video to path
Args:
dest: str, path to destination directory
@@ -765,7 +859,7 @@ class VideoAsset(PhotoAsset):
def _export_slow_mo(
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
):
""" Export slow-motion video to path
"""Export slow-motion video to path
Args:
dest: str, path to destination directory
@@ -814,14 +908,14 @@ class VideoAsset(PhotoAsset):
# todo: rewrite this with NotificationCenter and App event loop?
def _request_video_data(self, version=PHOTOS_VERSION_ORIGINAL):
""" Request video data for self._phasset
"""Request video data for self._phasset
Args:
version: which version to request
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
PHOTOS_VERSION_CURRENT, request current version with all edits
PHOTOS_VERSION_UNADJUSTED, request highest quality unadjusted version
Raises:
ValueError if passed invalid value for version
"""
@@ -843,7 +937,7 @@ class VideoAsset(PhotoAsset):
event = threading.Event()
def handler(asset, audiomix, info):
""" result handler for requestAVAssetForVideo:asset options:options resultHandler """
"""result handler for requestAVAssetForVideo:asset options:options resultHandler"""
nonlocal requestdata
requestdata.asset = asset
@@ -865,8 +959,8 @@ class VideoAsset(PhotoAsset):
class LivePhotoRequest(NSObject):
""" Manage requests for live photo assets
See: https://developer.apple.com/documentation/photokit/phimagemanager/1616984-requestlivephotoforasset?language=objc
"""Manage requests for live photo assets
See: https://developer.apple.com/documentation/photokit/phimagemanager/1616984-requestlivephotoforasset?language=objc
"""
def initWithManager_Asset_(self, manager, asset):
@@ -879,7 +973,7 @@ class LivePhotoRequest(NSObject):
return self
def requestLivePhotoResources(self, version=PHOTOS_VERSION_CURRENT):
""" return the photos and video components of a live video as [PHAssetResource] """
"""return the photos and video components of a live video as [PHAssetResource]"""
with objc.autorelease_pool():
options = Photos.PHLivePhotoRequestOptions.alloc().init()
@@ -897,7 +991,7 @@ class LivePhotoRequest(NSObject):
self.live_photo = None
def handler(result, info):
""" result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler: """
"""result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler:"""
if not info["PHImageResultIsDegradedKey"]:
self.live_photo = result
self.info = info
@@ -939,7 +1033,7 @@ class LivePhotoRequest(NSObject):
class LivePhotoAsset(PhotoAsset):
""" Represents a live photo """
"""Represents a live photo"""
def export(
self,
@@ -950,7 +1044,7 @@ class LivePhotoAsset(PhotoAsset):
photo=True,
video=True,
):
""" Export image to path
"""Export image to path
Args:
dest: str, path to destination directory
@@ -1061,50 +1155,6 @@ class LivePhotoAsset(PhotoAsset):
request.dealloc()
return exported
def _request_resource_data(self, resource):
""" Request asset resource data (either photo or video component)
Args:
resource: PHAssetResource to request
Raises:
"""
with objc.autorelease_pool():
resource_manager = Photos.PHAssetResourceManager.defaultManager()
options = Photos.PHAssetResourceRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
requestdata = PHAssetResourceData()
event = threading.Event()
def handler(data):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
nonlocal requestdata
requestdata.data += data
def completion_handler(error):
if error:
raise PhotoKitExportError(
"Error requesting data for asset resource"
)
event.set()
resource_manager.requestDataForAssetResource_options_dataReceivedHandler_completionHandler_(
resource, options, handler, completion_handler
)
event.wait()
# not sure why this is needed -- some weird ref count thing maybe
# if I don't do this, memory leaks
data = copy.copy(requestdata.data)
del requestdata
return data
# def request_image_data(self, version=PHOTOS_VERSION_CURRENT):
# # Returns an NSImage which isn't overly useful
# # https://developer.apple.com/documentation/photokit/phimagemanager/1616964-requestimageforasset?language=objc
@@ -1142,12 +1192,12 @@ class LivePhotoAsset(PhotoAsset):
class PhotoLibrary:
""" Interface to PhotoKit PHImageManager and PHPhotoLibrary """
"""Interface to PhotoKit PHImageManager and PHPhotoLibrary"""
def __init__(self):
""" Initialize ImageManager instance. Requests authorization to use the
"""Initialize ImageManager instance. Requests authorization to use the
Photos library if authorization has not already been granted.
Raises:
PhotoKitAuthError if unable to authorize access to PhotoKit
"""
@@ -1166,7 +1216,7 @@ class PhotoLibrary:
self._phimagemanager = Photos.PHCachingImageManager.defaultManager()
def request_authorization(self):
""" Request authorization to user's Photos Library
"""Request authorization to user's Photos Library
Returns:
authorization status
@@ -1176,7 +1226,7 @@ class PhotoLibrary:
return self.auth_status
def fetch_uuid_list(self, uuid_list):
""" fetch PHAssets with uuids in uuid_list
"""fetch PHAssets with uuids in uuid_list
Args:
uuid_list: list of str (UUID of image assets to fetch)
@@ -1205,7 +1255,7 @@ class PhotoLibrary:
)
def fetch_uuid(self, uuid):
""" fetch PHAsset with uuid = uuid
"""fetch PHAsset with uuid = uuid
Args:
uuid: str; UUID of image asset to fetch
@@ -1223,8 +1273,8 @@ class PhotoLibrary:
raise PhotoKitFetchFailed(f"Fetch did not return result for uuid {uuid}")
def fetch_burst_uuid(self, burstid, all=False):
""" fetch PhotoAssets with burst ID = burstid
"""fetch PhotoAssets with burst ID = burstid
Args:
burstid: str, burst UUID
all: return all burst assets; if False returns only those selected by the user (including the "key photo" even if user hasn't manually selected it)
@@ -1253,11 +1303,11 @@ class PhotoLibrary:
)
def _asset_factory(self, phasset):
""" creates a PhotoAsset, VideoAsset, or LivePhotoAsset
"""creates a PhotoAsset, VideoAsset, or LivePhotoAsset
Args:
phasset: PHAsset object
phasset: PHAsset object
Returns:
PhotoAsset, VideoAsset, or LivePhotoAsset depending on type of PHAsset
"""

View File

@@ -1,7 +1,10 @@
""" PhotosAlbum class to create an album in default Photos library and add photos to it """
from typing import Optional, List
from typing import List, Optional
import photoscript
from more_itertools import chunked
from .photoinfo import PhotoInfo
from .utils import noop
@@ -26,8 +29,14 @@ class PhotosAlbum:
)
def add_list(self, photo_list: List[PhotoInfo]):
photos = [photoscript.Photo(p.uuid) for p in photo_list]
self.album.add(photos)
photos = []
for p in photo_list:
try:
photos.append(photoscript.Photo(p.uuid))
except Exception as e:
self.verbose(f"Error creating Photo object for photo {p.uuid}: {e}")
for photolist in chunked(photos, 10):
self.album.add(photolist)
photo_len = len(photos)
photo_word = "photos" if photo_len > 1 else "photo"
self.verbose(f"Added {photo_len} {photo_word} to album {self.name}")

View File

@@ -11,6 +11,7 @@ import platform
import re
import sys
import tempfile
from collections import OrderedDict
from datetime import datetime, timedelta, timezone
from pprint import pformat
from typing import List
@@ -43,6 +44,7 @@ from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..fileutil import FileUtil
from ..personinfo import PersonInfo
from ..photoinfo import PhotoInfo
from ..phototemplate import RenderOptions
from ..queryoptions import QueryOptions
from ..utils import (
_check_file_exists,
@@ -62,29 +64,29 @@ from .photosdb_utils import get_db_model_version, get_db_version
class PhotosDB:
""" Processes a Photos.app library database to extract information about photos """
"""Processes a Photos.app library database to extract information about photos"""
# import additional methods
from ._photosdb_process_comments import _process_comments
from ._photosdb_process_exif import _process_exifinfo
from ._photosdb_process_faceinfo import _process_faceinfo
from ._photosdb_process_scoreinfo import _process_scoreinfo
from ._photosdb_process_searchinfo import (
_process_searchinfo,
labels,
labels_normalized,
labels_as_dict,
labels_normalized,
labels_normalized_as_dict,
)
from ._photosdb_process_scoreinfo import _process_scoreinfo
from ._photosdb_process_comments import _process_comments
def __init__(self, dbfile=None, verbose=None, exiftool=None):
""" Create a new PhotosDB object.
"""Create a new PhotosDB object.
Args:
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
Raises:
FileNotFoundError if dbfile is not a valid Photos library.
TypeError if verbose is not None and not callable.
@@ -240,6 +242,13 @@ class PhotosDB:
# Will hold the primary key of root folder
self._folder_root_pk = None
# Dict to hold signatures for finding possible duplicates
# key is tuple of (original_filesize, date) and value is list of uuids that match that signature
self._db_signatures = {}
# Dict to hold information on volume names (Photos 5+)
self._db_filesystem_volumes = {}
if _debug():
logging.debug(f"dbfile = {dbfile}")
@@ -322,7 +331,7 @@ class PhotosDB:
@property
def keywords_as_dict(self):
""" return keywords as dict of keyword, count in reverse sorted order (descending) """
"""return keywords as dict of keyword, count in reverse sorted order (descending)"""
keywords = {
k: len(self._dbkeywords_keyword[k]) for k in self._dbkeywords_keyword.keys()
}
@@ -332,7 +341,7 @@ class PhotosDB:
@property
def persons_as_dict(self):
""" return persons as dict of person, count in reverse sorted order (descending) """
"""return persons as dict of person, count in reverse sorted order (descending)"""
persons = {}
for pk in self._dbfaces_pk:
fullname = self._dbpersons_pk[pk]["fullname"]
@@ -345,7 +354,7 @@ class PhotosDB:
@property
def albums_as_dict(self):
""" return albums as dict of albums, count in reverse sorted order (descending) """
"""return albums as dict of albums, count in reverse sorted order (descending)"""
albums = {}
album_keys = self._get_album_uuids(shared=False)
for album in album_keys:
@@ -362,8 +371,8 @@ class PhotosDB:
@property
def albums_shared_as_dict(self):
""" returns shared albums as dict of albums, count in reverse sorted order (descending)
valid only on Photos 5; on Photos <= 4, prints warning and returns empty dict """
"""returns shared albums as dict of albums, count in reverse sorted order (descending)
valid only on Photos 5; on Photos <= 4, prints warning and returns empty dict"""
albums = {}
album_keys = self._get_album_uuids(shared=True)
@@ -381,19 +390,19 @@ class PhotosDB:
@property
def keywords(self):
""" return list of keywords found in photos database """
"""return list of keywords found in photos database"""
keywords = self._dbkeywords_keyword.keys()
return list(keywords)
@property
def persons(self):
""" return list of persons found in photos database """
"""return list of persons found in photos database"""
persons = {self._dbpersons_pk[k]["fullname"] for k in self._dbfaces_pk}
return list(persons)
@property
def person_info(self):
""" return list of PersonInfo objects for each person in the photos database """
"""return list of PersonInfo objects for each person in the photos database"""
try:
return self._person_info
except AttributeError:
@@ -404,7 +413,7 @@ class PhotosDB:
@property
def folder_info(self):
""" return list FolderInfo objects representing top-level folders in the photos database """
"""return list FolderInfo objects representing top-level folders in the photos database"""
if self._db_version <= _PHOTOS_4_VERSION:
folders = [
FolderInfo(db=self, uuid=folder)
@@ -425,7 +434,7 @@ class PhotosDB:
@property
def folders(self):
""" return list of top-level folder names in the photos database """
"""return list of top-level folder names in the photos database"""
if self._db_version <= _PHOTOS_4_VERSION:
folder_names = [
folder["name"]
@@ -446,7 +455,7 @@ class PhotosDB:
@property
def album_info(self):
""" return list of AlbumInfo objects for each album in the photos database """
"""return list of AlbumInfo objects for each album in the photos database"""
try:
return self._album_info
except AttributeError:
@@ -458,8 +467,8 @@ class PhotosDB:
@property
def album_info_shared(self):
""" return list of AlbumInfo objects for each shared album in the photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list """
"""return list of AlbumInfo objects for each shared album in the photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list"""
# if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
try:
return self._album_info_shared
@@ -472,7 +481,7 @@ class PhotosDB:
@property
def albums(self):
""" return list of albums found in photos database """
"""return list of albums found in photos database"""
# Could be more than one album with same name
# Right now, they are treated as same album and photos are combined from albums with same name
@@ -485,8 +494,8 @@ class PhotosDB:
@property
def albums_shared(self):
""" return list of shared albums found in photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list """
"""return list of shared albums found in photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list"""
# Could be more than one album with same name
# Right now, they are treated as same album and photos are combined from albums with same name
@@ -501,7 +510,7 @@ class PhotosDB:
@property
def import_info(self):
""" return list of ImportInfo objects for each import session in the database """
"""return list of ImportInfo objects for each import session in the database"""
try:
return self._import_info
except AttributeError:
@@ -513,21 +522,21 @@ class PhotosDB:
@property
def db_version(self):
""" return the database version as stored in LiGlobals table """
"""return the database version as stored in LiGlobals table"""
return self._db_version
@property
def db_path(self):
""" returns path to the Photos library database PhotosDB was initialized with """
"""returns path to the Photos library database PhotosDB was initialized with"""
return os.path.abspath(self._dbfile)
@property
def library_path(self):
""" returns path to the Photos library PhotosDB was initialized with """
"""returns path to the Photos library PhotosDB was initialized with"""
return self._library_path
def get_db_connection(self):
""" Get connection to the working copy of the Photos database
"""Get connection to the working copy of the Photos database
Returns:
tuple of (connection, cursor) to sqlite3 database
@@ -535,7 +544,7 @@ class PhotosDB:
return _open_sql_file(self._tmp_db)
def _copy_db_file(self, fname):
""" copies the sqlite database file to a temp file """
"""copies the sqlite database file to a temp file"""
""" returns the name of the temp file """
""" If sqlite shared memory and write-ahead log files exist, those are copied too """
# required because python's sqlite3 implementation can't read a locked file
@@ -587,13 +596,15 @@ class PhotosDB:
# return dest_path
def _process_database4(self):
""" process the Photos database to extract info
works on Photos version <= 4.0 """
"""process the Photos database to extract info
works on Photos version <= 4.0"""
verbose = self._verbose
verbose("Processing database.")
verbose(f"Database version: {self._db_version}.")
self._photos_ver = 4 # only used in Photos 5+
(conn, c) = _open_sql_file(self._tmp_db)
# get info to associate persons with photos
@@ -1180,6 +1191,13 @@ class PhotosDB:
self._dbphotos[uuid]["import_uuid"] = row[44]
self._dbphotos[uuid]["fok_import_session"] = None
# compute signatures for finding possible duplicates
signature = self._duplicate_signature(uuid)
try:
self._db_signatures[signature].append(uuid)
except KeyError:
self._db_signatures[signature] = [uuid]
# get additional details from RKMaster, needed for RAW processing
verbose("Processing additional photo details.")
c.execute(
@@ -1532,15 +1550,15 @@ class PhotosDB:
logging.debug(pformat(self._dbphotos_burst))
def _build_album_folder_hierarchy_4(self, uuid, folders=None):
""" recursively build folder/album hierarchy
uuid: parent uuid of the album being processed
(parent uuid is a folder in RKFolders)
folders: dict holding the folder hierarchy
NOTE: This implementation is different than _build_album_folder_hierarchy_5
which takes the uuid of the album being processed. Here uuid is the parent uuid
of the parent folder album because in Photos <=4, folders are in RKFolders and
albums in RKAlbums. In Photos 5, folders are just special albums
with kind = _PHOTOS_5_FOLDER_KIND """
"""recursively build folder/album hierarchy
uuid: parent uuid of the album being processed
(parent uuid is a folder in RKFolders)
folders: dict holding the folder hierarchy
NOTE: This implementation is different than _build_album_folder_hierarchy_5
which takes the uuid of the album being processed. Here uuid is the parent uuid
of the parent folder album because in Photos <=4, folders are in RKFolders and
albums in RKAlbums. In Photos 5, folders are just special albums
with kind = _PHOTOS_5_FOLDER_KIND"""
parent_uuid = self._dbfolder_details[uuid]["parentFolderUuid"]
@@ -1563,11 +1581,11 @@ class PhotosDB:
return folders
def _process_database5(self):
""" process the Photos database to extract info
works on Photos version 5 and version 6
"""process the Photos database to extract info
works on Photos version 5 and version 6
This is a big hairy 700 line function that should probably be refactored
but it works so don't touch it.
This is a big hairy 700 line function that should probably be refactored
but it works so don't touch it.
"""
if _debug():
@@ -1582,10 +1600,14 @@ class PhotosDB:
verbose(f"Database version: {self._db_version}, {photos_ver}.")
asset_table = _DB_TABLE_NAMES[photos_ver]["ASSET"]
keyword_join = _DB_TABLE_NAMES[photos_ver]["KEYWORD_JOIN"]
asset_album_table = _DB_TABLE_NAMES[photos_ver]["ASSET_ALBUM_TABLE"]
album_join = _DB_TABLE_NAMES[photos_ver]["ALBUM_JOIN"]
album_sort = _DB_TABLE_NAMES[photos_ver]["ALBUM_SORT_ORDER"]
asset_album_join = _DB_TABLE_NAMES[photos_ver]["ASSET_ALBUM_JOIN"]
import_fok = _DB_TABLE_NAMES[photos_ver]["IMPORT_FOK"]
depth_state = _DB_TABLE_NAMES[photos_ver]["DEPTH_STATE"]
uti_original_column = _DB_TABLE_NAMES[photos_ver]["UTI_ORIGINAL"]
hdr_type_column = _DB_TABLE_NAMES[photos_ver]["HDR_TYPE"]
# Look for all combinations of persons and pictures
if _debug():
@@ -1705,8 +1727,8 @@ class PhotosDB:
{asset_table}.ZUUID,
{album_sort}
FROM {asset_table}
JOIN Z_26ASSETS ON {album_join} = {asset_table}.Z_PK
JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = Z_26ASSETS.Z_26ALBUMS
JOIN {asset_album_table} ON {album_join} = {asset_table}.Z_PK
JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = {asset_album_join}
"""
)
@@ -1873,7 +1895,7 @@ class PhotosDB:
{asset_table}.ZAVALANCHEUUID,
{asset_table}.ZAVALANCHEPICKTYPE,
{asset_table}.ZKINDSUBTYPE,
{asset_table}.ZCUSTOMRENDEREDVALUE,
{asset_table}.{hdr_type_column},
ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE,
{asset_table}.ZCLOUDASSETGUID,
ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA,
@@ -2145,6 +2167,13 @@ class PhotosDB:
self._dbphotos[uuid] = info
# compute signatures for finding possible duplicates
signature = self._duplicate_signature(uuid)
try:
self._db_signatures[signature].append(uuid)
except KeyError:
self._db_signatures[signature] = [uuid]
# # if row[19] is not None and ((row[20] == 2) or (row[20] == 4)):
# # burst photo
# if row[19] is not None:
@@ -2230,20 +2259,33 @@ class PhotosDB:
# Get info on remote/local availability for photos in shared albums
# Also get UTI of original image (zdatastoresubtype = 1)
c.execute(
f""" SELECT
if self._photos_ver >= 7:
sql_missing = f""" SELECT
{asset_table}.ZUUID,
ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
ZINTERNALRESOURCE.ZREMOTEAVAILABILITY,
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER,
{uti_original_column},
null
FROM {asset_table}
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
else:
sql_missing = f""" SELECT
{asset_table}.ZUUID,
ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
ZINTERNALRESOURCE.ZREMOTEAVAILABILITY,
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
{uti_original_column},
ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER
FROM {asset_table}
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK = ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER
WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
)
c.execute(sql_missing)
# Order of results:
# 0 {asset_table}.ZUUID,
@@ -2303,20 +2345,36 @@ class PhotosDB:
# get information about associted RAW images
# RAW images have ZDATASTORESUBTYPE = 17
c.execute(
f""" SELECT
if self._photos_ver >= 7:
sql_raw = f""" SELECT
{asset_table}.ZUUID,
ZINTERNALRESOURCE.ZDATALENGTH,
null,
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
ZINTERNALRESOURCE.ZRESOURCETYPE,
ZINTERNALRESOURCE.ZFILESYSTEMBOOKMARK
FROM {asset_table}
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
"""
else:
sql_raw = f""" SELECT
{asset_table}.ZUUID,
ZINTERNALRESOURCE.ZDATALENGTH,
ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER,
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
ZINTERNALRESOURCE.ZRESOURCETYPE
ZINTERNALRESOURCE.ZRESOURCETYPE,
ZINTERNALRESOURCE.ZFILESYSTEMBOOKMARK
FROM {asset_table}
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK = ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER
WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
"""
)
"""
c.execute(sql_raw)
for row in c:
uuid = row[0]
if uuid in self._dbphotos:
@@ -2325,6 +2383,33 @@ class PhotosDB:
self._dbphotos[uuid]["UTI_raw"] = row[2]
self._dbphotos[uuid]["datastore_subtype"] = row[3]
self._dbphotos[uuid]["resource_type"] = row[4]
self._dbphotos[uuid]["raw_bookmark"] = row[5]
# get paths for the relative imports for RAW+JPEG images
c.execute(
f""" SELECT
{asset_table}.ZUUID,
ZFILESYSTEMVOLUME.ZNAME,
ZFILESYSTEMBOOKMARK.ZPATHRELATIVETOVOLUME
FROM {asset_table}
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
JOIN ZFILESYSTEMBOOKMARK ON ZFILESYSTEMBOOKMARK.ZRESOURCE = ZINTERNALRESOURCE.Z_PK
JOIN ZFILESYSTEMVOLUME ON ZFILESYSTEMVOLUME.Z_PK = ZINTERNALRESOURCE.ZFILESYSTEMVOLUME
WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
"""
)
# path to the raw image will be /Volumes/ZFILESYSTEMVOLUME.ZNAME/ZFILESYSTEMBOOKMARK.ZPATHRELATIVETOVOLUME
# 0: {asset_table}.ZUUID, -- UUID
# 1: ZFILESYSTEMVOLUME.ZNAME, -- name of the volume
# 2: ZFILESYSTEMBOOKMARK.ZPATHRELATIVETOVOLUME -- path to the raw image
for row in c:
uuid = row[0]
if uuid in self._dbphotos:
self._dbphotos[uuid]["raw_volume"] = row[1]
self._dbphotos[uuid]["raw_relative_path"] = row[2]
# add faces and keywords to photo data
for uuid in self._dbphotos:
@@ -2430,9 +2515,9 @@ class PhotosDB:
logging.debug(pformat(self._dbphotos_burst))
def _build_album_folder_hierarchy_5(self, uuid, folders=None):
""" recursively build folder/album hierarchy
uuid: uuid of the album/folder being processed
folders: dict holding the folder hierarchy """
"""recursively build folder/album hierarchy
uuid: uuid of the album/folder being processed
folders: dict holding the folder hierarchy"""
# get parent uuid
parent = self._dbalbum_details[uuid]["parentfolder"]
@@ -2453,17 +2538,17 @@ class PhotosDB:
return folders
def _album_folder_hierarchy_list(self, album_uuid):
""" return appropriate album_folder_hierarchy_list for the _db_version """
"""return appropriate album_folder_hierarchy_list for the _db_version"""
if self._db_version <= _PHOTOS_4_VERSION:
return self._album_folder_hierarchy_list_4(album_uuid)
else:
return self._album_folder_hierarchy_list_5(album_uuid)
def _album_folder_hierarchy_list_4(self, album_uuid):
""" return hierarchical list of folder names album_uuid is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders """
"""return hierarchical list of folder names album_uuid is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders"""
try:
folders = self._dbalbum_folders[album_uuid]
except KeyError:
@@ -2471,7 +2556,7 @@ class PhotosDB:
return []
def _recurse_folder_hierarchy(folders, hierarchy=[]):
""" recursively walk the folders dict to build list of folder hierarchy """
"""recursively walk the folders dict to build list of folder hierarchy"""
if not folders:
# empty folder dict (album has no folder hierarchy)
return []
@@ -2497,10 +2582,10 @@ class PhotosDB:
return hierarchy
def _album_folder_hierarchy_list_5(self, album_uuid):
""" return hierarchical list of folder names album_uuid is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders """
"""return hierarchical list of folder names album_uuid is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders"""
try:
folders = self._dbalbum_folders[album_uuid]
except KeyError:
@@ -2508,7 +2593,7 @@ class PhotosDB:
return []
def _recurse_folder_hierarchy(folders, hierarchy=[]):
""" recursively walk the folders dict to build list of folder hierarchy """
"""recursively walk the folders dict to build list of folder hierarchy"""
if not folders:
# empty folder dict (album has no folder hierarchy)
@@ -2540,15 +2625,15 @@ class PhotosDB:
return self._album_folder_hierarchy_folderinfo_5(album_uuid)
def _album_folder_hierarchy_folderinfo_4(self, album_uuid):
""" return hierarchical list of FolderInfo objects album_uuid is contained in
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders """
"""return hierarchical list of FolderInfo objects album_uuid is contained in
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders"""
# title = photosdb._dbalbum_details[album_uuid]["title"]
folders = self._dbalbum_folders[album_uuid]
# logging.warning(f"uuid = {album_uuid}, folder = {folders}")
def _recurse_folder_hierarchy(folders, hierarchy=[]):
""" recursively walk the folders dict to build list of folder hierarchy """
"""recursively walk the folders dict to build list of folder hierarchy"""
# logging.warning(f"folders={folders},hierarchy = {hierarchy}")
if not folders:
# empty folder dict (album has no folder hierarchy)
@@ -2574,14 +2659,14 @@ class PhotosDB:
return hierarchy
def _album_folder_hierarchy_folderinfo_5(self, album_uuid):
""" return hierarchical list of FolderInfo objects album_uuid is contained in
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders """
"""return hierarchical list of FolderInfo objects album_uuid is contained in
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders"""
# title = photosdb._dbalbum_details[album_uuid]["title"]
folders = self._dbalbum_folders[album_uuid]
def _recurse_folder_hierarchy(folders, hierarchy=[]):
""" recursively walk the folders dict to build list of folder hierarchy """
"""recursively walk the folders dict to build list of folder hierarchy"""
if not folders:
# empty folder dict (album has no folder hierarchy)
@@ -2606,19 +2691,19 @@ class PhotosDB:
return hierarchy
def _get_album_uuids(self, shared=False, import_session=False):
""" Return list of album UUIDs found in photos database
"""Return list of album UUIDs found in photos database
Filters out albums in the trash and any special album types
Args:
shared: boolean; if True, returns shared albums, else normal albums
import_session: boolean, if True, returns import session albums, else normal or shared albums
Note: flags (shared, import_session) are mutually exclusive
Raises:
ValueError: raised if mutually exclusive flags passed
Returns: list of album UUIDs
Returns: list of album UUIDs
"""
if shared and import_session:
raise ValueError(
@@ -2670,14 +2755,14 @@ class PhotosDB:
return album_list
def _get_albums(self, shared=False):
""" Return list of album titles found in photos database
"""Return list of album titles found in photos database
Albums may have duplicate titles -- these will be treated as a single album.
Filters out albums in the trash and any special album types
Args:
shared: boolean; if True, returns shared albums, else normal albums
Returns: list of album names
"""
@@ -2696,7 +2781,7 @@ class PhotosDB:
to_date=None,
intrash=False,
):
""" Return a list of PhotoInfo objects
"""Return a list of PhotoInfo objects
If called with no args, returns the entire database of photos
If called with args, returns photos matching the args (e.g. keywords, persons, etc.)
If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons)
@@ -2711,10 +2796,10 @@ class PhotosDB:
persons: list of persons to search for
albums: list of album names to search for
images: if True, returns image files, if False, does not return images; default is True
movies: if True, returns movie files, if False, does not return movies; default is True
movies: if True, returns movie files, if False, does not return movies; default is True
from_date: return photos with creation date >= from_date (datetime.datetime object, default None)
to_date: return photos with creation date <= to_date (datetime.datetime object, default None)
intrash: if True, returns only images in "Recently deleted items" folder,
intrash: if True, returns only images in "Recently deleted items" folder,
if False returns only photos that aren't deleted; default is False
Returns:
@@ -2821,7 +2906,7 @@ class PhotosDB:
return photoinfo
def get_photo(self, uuid):
""" Returns a single photo matching uuid
"""Returns a single photo matching uuid
Arguments:
uuid: the UUID of photo to get
@@ -2836,7 +2921,7 @@ class PhotosDB:
# TODO: add to docs and test
def photos_by_uuid(self, uuids):
""" Returns a list of photos with UUID in uuids.
"""Returns a list of photos with UUID in uuids.
Does not generate error if invalid or missing UUID passed.
This is faster than using PhotosDB.photos if you have list of UUIDs.
Returns photos regardless of intrash state.
@@ -3188,11 +3273,12 @@ class PhotosDB:
if options.regex:
flags = re.IGNORECASE if options.ignore_case else 0
render_options = RenderOptions(none_str="")
for regex, template in options.regex:
regex = re.compile(regex, flags)
photo_list = []
for p in photos:
rendered, _ = p.render_template(template, none_str="")
rendered, _ = p.render_template(template, render_options)
for value in rendered:
if regex.search(value):
photo_list.append(p)
@@ -3207,8 +3293,54 @@ class PhotosDB:
except Exception as e:
raise ValueError(f"Invalid query_eval CRITERIA: {e}")
if options.duplicate:
no_date = datetime(1970, 1, 1)
tz = timezone(timedelta(0))
no_date = no_date.astimezone(tz=tz)
photos = sorted(
[p for p in photos if p.duplicates],
key=lambda x: x.date_added or no_date,
)
# gather all duplicates but ensure each uuid is only represented once
photodict = OrderedDict()
for p in photos:
if p.uuid not in photodict:
photodict[p.uuid] = p
for d in sorted(
p.duplicates, key=lambda x: x.date_added or no_date
):
if d.uuid not in photodict:
photodict[d.uuid] = d
photos = list(photodict.values())
# filter for deleted as photo.duplicates will include photos in the trash
if not (options.deleted or options.deleted_only):
photos = [p for p in photos if not p.intrash]
if options.deleted_only:
photos = [p for p in photos if p.intrash]
if options.location:
photos = [p for p in photos if p.location != (None, None)]
elif options.no_location:
photos = [p for p in photos if p.location == (None, None)]
if options.function:
for function in options.function:
photos = function[0](photos)
return photos
def _duplicate_signature(self, uuid):
"""Compute a signature for finding possible duplicates"""
return (
self._dbphotos[uuid]["original_filesize"],
self._dbphotos[uuid]["imageDate"],
self._dbphotos[uuid]["height"],
self._dbphotos[uuid]["width"],
self._dbphotos[uuid]["UTI"],
self._dbphotos[uuid]["hasAdjustments"],
)
def __repr__(self):
return f"osxphotos.{self.__class__.__name__}(dbfile='{self.db_path}')"
@@ -3220,8 +3352,8 @@ class PhotosDB:
return False
def __len__(self):
""" Returns number of photos in the database
Includes recently deleted photos and non-selected burst images
"""Returns number of photos in the database
Includes recently deleted photos and non-selected burst images
"""
return len(self._dbphotos)
@@ -3251,4 +3383,4 @@ def _get_photos_by_attribute(photos, attribute, values, ignore_case):
else:
for x in values:
photos_search.extend(p for p in photos if x in getattr(p, attribute))
return photos_search
return list(set(photos_search))

View File

@@ -6,6 +6,7 @@ import plistlib
from .._constants import (
_PHOTOS_5_MODEL_VERSION,
_PHOTOS_6_MODEL_VERSION,
_PHOTOS_7_MODEL_VERSION,
_TESTED_DB_VERSIONS,
)
from ..utils import _open_sql_file
@@ -73,12 +74,12 @@ def get_db_model_version(db_file):
model_ver = get_model_version(db_file)
if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]:
db_ver = 5
return 5
elif _PHOTOS_6_MODEL_VERSION[0] <= model_ver <= _PHOTOS_6_MODEL_VERSION[1]:
db_ver = 6
return 6
elif _PHOTOS_7_MODEL_VERSION[0] <= model_ver <= _PHOTOS_7_MODEL_VERSION[1]:
return 7
else:
logging.warning(f"Unknown model version: {model_ver}")
# cross our fingers and try latest version
db_ver = 6
return db_ver
return 7

View File

@@ -39,6 +39,7 @@ Valid filters are:
- braces: Enclose value in curly braces, e.g. 'value => '{value}'.
- parens: Enclose value in parentheses, e.g. 'value' => '(value')
- brackets: Enclose value in brackets, e.g. 'value' => '[value]'
- shell_quote: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.
- function: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py
<!-- OSXPHOTOS-FILTER-TABLE:END -->

View File

@@ -4,7 +4,10 @@ import datetime
import locale
import os
import pathlib
import shlex
import sys
from dataclasses import dataclass
from typing import Optional
from textx import TextXSyntaxError, metamodel_from_file
@@ -13,7 +16,9 @@ from ._version import __version__
from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifToolCaching
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
from .utils import load_function
from .utils import expand_and_validate_filepath, load_function
# TODO: a lot of values are passed from function to function like path_sep--make these all class properties
# ensure locale set to user's locale
locale.setlocale(locale.LC_ALL, "")
@@ -137,7 +142,12 @@ TEMPLATE_SUBSTITUTIONS = {
"{cr}": r"A carriage return: '\r'",
"{crlf}": r"a carriage return + line feed: '\r\n'",
"{osxphotos_version}": f"The osxphotos version, e.g. '{__version__}'",
"{osxphotos_cmd_line}": "The full command line used to run osxphotos"
"{osxphotos_cmd_line}": "The full command line used to run osxphotos",
}
TEMPLATE_SUBSTITUTIONS_PATHLIB = {
"{export_dir}": "The full path to the export directory",
"{filepath}": "The full path to the exported file",
}
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
@@ -164,6 +174,7 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
+ "For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. "
+ "'{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of "
+ "the underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
"{shell_quote}": "Use in form '{shell_quote,TEMPLATE}'; quotes the rendered TEMPLATE value(s) for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.",
"{function}": "Execute a python function from an external file and use return value as template substitution. "
+ "Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. "
+ "The function will be passed the PhotoInfo object for the photo. "
@@ -179,7 +190,8 @@ FILTER_VALUES = {
"braces": "Enclose value in curly braces, e.g. 'value => '{value}'.",
"parens": "Enclose value in parentheses, e.g. 'value' => '(value')",
"brackets": "Enclose value in brackets, e.g. 'value' => '[value]'",
"function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py"
"shell_quote": "Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.",
"function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py",
}
# Just the substitutions without the braces
@@ -187,13 +199,18 @@ SINGLE_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS
]
# Just the multi-valued substitution names without the braces
PATHLIB_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS_PATHLIB
]
MULTI_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "")
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
]
FIELD_NAMES = SINGLE_VALUE_SUBSTITUTIONS + MULTI_VALUE_SUBSTITUTIONS
FIELD_NAMES = (
SINGLE_VALUE_SUBSTITUTIONS + MULTI_VALUE_SUBSTITUTIONS + PATHLIB_SUBSTITUTIONS
)
# default values for string manipulation template options
INPLACE_DEFAULT = ","
@@ -217,20 +234,53 @@ PUNCTUATION = {
}
@dataclass
class RenderOptions:
"""Options for PhotoTemplate.render
template: str template
none_str: str to use default for None values, default is '_'
path_sep: optional string to use as path separator, default is os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
strip: if True, strips leading/trailing whitespace from rendered templates
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
export_dir: set to the export directory if you want to evalute {export_dir} template
filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template
quote: quote path templates for execution in the shell
"""
none_str: str = "_"
path_sep: Optional[str] = PATH_SEP_DEFAULT
expand_inplace: bool = False
inplace_sep: Optional[str] = INPLACE_DEFAULT
filename: bool = False
dirname: bool = False
strip: bool = False
edited_version: bool = False
export_dir: Optional[str] = None
filepath: Optional[str] = None
quote: bool = False
class PhotoTemplateParser:
"""Parser for PhotoTemplate """
"""Parser for PhotoTemplate"""
# implemented as Singleton
def __new__(cls, *args, **kwargs):
""" create new object or return instance of already created singleton """
"""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):
""" return existing singleton or create a new one """
"""return existing singleton or create a new one"""
if hasattr(self, "metamodel"):
return
@@ -238,15 +288,15 @@ class PhotoTemplateParser:
self.metamodel = metamodel_from_file(OTL_GRAMMAR_MODEL, skipws=False)
def parse(self, template_statement):
"""Parse a template_statement string """
"""Parse a template_statement string"""
return self.metamodel.model_from_str(template_statement)
class PhotoTemplate:
""" PhotoTemplate class to render a template string from a PhotoInfo object """
"""PhotoTemplate class to render a template string from a PhotoInfo object"""
def __init__(self, photo, exiftool_path=None):
""" Inits PhotoTemplate class with photo
"""Inits PhotoTemplate class with photo
Args:
photo: a PhotoInfo instance.
@@ -262,49 +312,51 @@ class PhotoTemplate:
# get parser singleton
self.parser = PhotoTemplateParser()
# should {edited_version} render True?
self.edited_version = False
# initialize render options
# this will be done in render() but for testing, some of the lookup functions are called directly
options = RenderOptions()
self.path_sep = options.path_sep
self.inplace_sep = options.inplace_sep
self.edited_version = options.edited_version
self.none_str = options.none_str
self.expand_inplace = options.expand_inplace
self.filename = options.filename
self.dirname = options.dirname
self.strip = options.strip
self.export_dir = options.export_dir
self.filepath = options.filepath
self.quote = options.quote
def render(
self,
template,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
strip=False,
edited_version=False,
template: str,
options: RenderOptions,
):
""" Render a filename or directory template
"""Render a filename or directory template
Args:
template: str template
none_str: str to use default for None values, default is '_'
path_sep: optional string to use as path separator, default is os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
strip: if True, strips leading/trailing whitespace from rendered templates
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
template: str template
options: a RenderOptions instance
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
if path_sep is None:
path_sep = PATH_SEP_DEFAULT
if inplace_sep is None:
inplace_sep = INPLACE_DEFAULT
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
self.path_sep = options.path_sep
self.inplace_sep = options.inplace_sep
self.edited_version = options.edited_version
self.none_str = options.none_str
self.expand_inplace = options.expand_inplace
self.filename = options.filename
self.dirname = options.dirname
self.strip = options.strip
self.export_dir = options.export_dir
self.filepath = options.filepath
self.quote = options.quote
try:
model = self.parser.parse(template)
except TextXSyntaxError as e:
@@ -314,53 +366,29 @@ class PhotoTemplate:
# empty string
return [], []
self.edited_version = edited_version
return self._render_statement(
model,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
strip=strip,
)
return self._render_statement(model)
def _render_statement(
self,
statement,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
strip=False,
):
path_sep = path_sep or self.path_sep
results = []
unmatched = []
for ts in statement.template_strings:
results, unmatched = self._render_template_string(
ts,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
results=results,
unmatched=unmatched,
ts, results=results, unmatched=unmatched, path_sep=path_sep
)
rendered_strings = results
if filename:
if self.filename:
rendered_strings = [
sanitize_filename(rendered_str) for rendered_str in rendered_strings
]
if strip:
if self.strip:
rendered_strings = [
rendered_str.strip() for rendered_str in rendered_strings
]
@@ -370,16 +398,11 @@ class PhotoTemplate:
def _render_template_string(
self,
ts,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
path_sep,
results=None,
unmatched=None,
):
"""Render a TemplateString object """
"""Render a TemplateString object"""
results = results or [""]
unmatched = unmatched or []
@@ -387,7 +410,8 @@ class PhotoTemplate:
if ts.template:
# have a template field to process
field = ts.template.field
if field not in FIELD_NAMES and not field.startswith("photo"):
field_part = field.split(".")[0]
if field not in FIELD_NAMES and field_part not in FIELD_NAMES:
unmatched.append(field)
return [], unmatched
@@ -414,12 +438,7 @@ class PhotoTemplate:
if ts.template.bool.value is not None:
bool_val, u = self._render_statement(
ts.template.bool.value,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
)
unmatched.extend(u)
else:
@@ -435,12 +454,7 @@ class PhotoTemplate:
if ts.template.default.value is not None:
default, u = self._render_statement(
ts.template.default.value,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
)
unmatched.extend(u)
else:
@@ -457,12 +471,7 @@ class PhotoTemplate:
# conditional value is also a TemplateString
conditional_value, u = self._render_statement(
ts.template.conditional.value,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
)
unmatched.extend(u)
else:
@@ -478,10 +487,8 @@ class PhotoTemplate:
vals = self.get_template_value(
field,
default=default,
delim=delim or inplace_sep,
path_sep=path_sep,
filename=filename,
dirname=dirname,
# delim=delim or self.inplace_sep,
# path_sep=path_sep,
)
elif field == "exiftool":
if subfield is None:
@@ -489,7 +496,7 @@ class PhotoTemplate:
"SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'"
)
vals = self.get_template_value_exiftool(
subfield, filename=filename, dirname=dirname
subfield,
)
elif field == "function":
if subfield is None:
@@ -497,20 +504,22 @@ class PhotoTemplate:
"SyntaxError: filename and function must not be null with {function::filename.py:function_name}"
)
vals = self.get_template_value_function(
subfield, filename=filename, dirname=dirname
subfield,
)
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
vals = self.get_template_value_multi(
field, path_sep=path_sep, filename=filename, dirname=dirname
field, path_sep=path_sep, default=default
)
elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS:
vals = self.get_template_value_pathlib(field)
else:
unmatched.append(field)
return [], unmatched
vals = [val for val in vals if val is not None]
if expand_inplace or delim is not None:
sep = delim if delim is not None else inplace_sep
if self.expand_inplace or delim is not None:
sep = delim if delim is not None else self.inplace_sep
vals = [sep.join(sorted(vals))]
for filter_ in filters:
@@ -531,7 +540,7 @@ class PhotoTemplate:
# have a conditional operator
def string_test(test_function):
""" Perform string comparison using test_function; closure to capture conditional_value, vals, negation """
"""Perform string comparison using test_function; closure to capture conditional_value, vals, negation"""
match = False
for c in conditional_value:
for v in vals:
@@ -546,7 +555,7 @@ class PhotoTemplate:
return []
def comparison_test(test_function):
""" Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation """
"""Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation"""
if len(vals) != 1 or len(conditional_value) != 1:
raise ValueError(
f"comparison operators may only be used with a single value: {vals} {conditional_value}"
@@ -607,7 +616,7 @@ class PhotoTemplate:
if is_bool:
vals = default if not vals else bool_val
elif not vals:
vals = default or [none_str]
vals = default or [self.none_str]
pre = ts.pre or ""
post = ts.post or ""
@@ -632,29 +641,29 @@ class PhotoTemplate:
self,
field,
default,
bool_val=None,
delim=None,
path_sep=None,
filename=False,
dirname=False,
# bool_val=None,
# delim=None,
# path_sep=None,
):
"""lookup value for template field (single-value template substitutions)
Args:
field: template field to find value for.
default: the default value provided by the user
bool_val: True value if expression is boolean
bool_val: True value if expression is boolean
delim: delimiter for expand in place
path_sep: path separator for fields that are path-like
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
Returns:
The matching template value (which may be None).
Raises:
ValueError if no rule exists for field.
"""
if self.photo.uuid is None:
return []
if field not in FIELD_NAMES:
raise ValueError(f"SyntaxError: Unknown field: {field}")
@@ -920,9 +929,40 @@ class PhotoTemplate:
# if here, didn't get a match
raise ValueError(f"Unhandled template value: {field}")
if filename:
if self.filename:
value = sanitize_pathpart(value)
elif dirname:
elif self.dirname:
value = sanitize_dirname(value)
return [value]
def get_template_value_pathlib(self, field):
"""lookup value for template pathlib template fields
Args:
field: template field to find value for.
Returns:
The matching template value (which may be None).
Raises:
ValueError if no rule exists for field.
"""
field_stem = field.split(".")[0]
if field_stem not in PATHLIB_SUBSTITUTIONS:
raise ValueError(f"SyntaxError: Unknown field: {field}")
field_value = None
try:
field_value = getattr(self, field_stem)
except AttributeError:
raise ValueError(f"Unknown path-like field: {field_stem}")
value = _get_pathlib_value(field, field_value, self.quote)
if self.filename:
value = sanitize_pathpart(value)
elif self.dirname:
value = sanitize_dirname(value)
return [value]
@@ -968,20 +1008,25 @@ class PhotoTemplate:
value = ["[" + v + "]" for v in values]
else:
value = ["[" + values + "]"] if values else []
elif filter_ == "shell_quote":
if values and type(values) == list:
value = [shlex.quote(v) for v in values]
else:
value = [shlex.quote(values)] if values else []
elif filter_.startswith("function:"):
value = self.get_template_value_filter_function(filter_, values)
else:
value = []
return value
def get_template_value_multi(self, field, path_sep, filename=False, dirname=False):
def get_template_value_multi(self, field, path_sep, default):
"""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
dirname: if True, values will be sanitized to be valid directory names; default = False
default: value of default field
Returns:
List of the matching template values or [].
@@ -990,6 +1035,10 @@ class PhotoTemplate:
"""
""" return list of values for a multi-valued template field """
if self.photo.uuid is None:
return []
values = []
if field == "album":
values = self.photo.burst_albums if self.photo.burst else self.photo.albums
@@ -1013,7 +1062,7 @@ class PhotoTemplate:
for album in album_info:
if album.folder_names:
# album in folder
if dirname:
if self.dirname:
# being used as a filepath so sanitize each part
folder = path_sep.join(
sanitize_dirname(f) for f in album.folder_names
@@ -1025,7 +1074,7 @@ class PhotoTemplate:
values.append(folder)
else:
# album not in folder
if dirname:
if self.dirname:
values.append(sanitize_dirname(album.title))
else:
values.append(album.title)
@@ -1043,6 +1092,8 @@ class PhotoTemplate:
values = (
self.photo.search_info.venue_types if self.photo.search_info else []
)
elif field == "shell_quote":
values = [shlex.quote(v) for v in default if v]
elif field.startswith("photo"):
# provide access to PhotoInfo object
properties = field.split(".")
@@ -1073,9 +1124,9 @@ class PhotoTemplate:
raise ValueError(f"Unhandled template value: {field}")
# sanitize directory names if needed, folder_album handled differently above
if filename:
if self.filename:
values = [sanitize_pathpart(value) for value in values]
elif dirname and field != "folder_album":
elif self.dirname and field != "folder_album":
# skip folder_album because it would have been handled above
values = [sanitize_dirname(value) for value in values]
@@ -1083,9 +1134,15 @@ class PhotoTemplate:
values = values or []
return values
def get_template_value_exiftool(self, subfield, filename=None, dirname=None):
def get_template_value_exiftool(
self,
subfield,
):
"""Get template value for format "{exiftool:EXIF:Model}" """
if self.photo is None:
return []
if not self.photo.path:
return []
@@ -1098,17 +1155,20 @@ class PhotoTemplate:
values = [str(v) for v in values]
# sanitize directory names if needed
if filename:
if self.filename:
values = [sanitize_pathpart(value) for value in values]
elif dirname:
elif self.dirname:
values = [sanitize_dirname(value) for value in values]
else:
values = []
return values
def get_template_value_function(self, subfield, filename=None, dirname=None):
"""Get template value from external function """
def get_template_value_function(
self,
subfield,
):
"""Get template value from external function"""
if "::" not in subfield:
raise ValueError(
@@ -1117,10 +1177,11 @@ class PhotoTemplate:
filename, funcname = subfield.split("::")
if not pathlib.Path(filename).is_file():
filename_validated = expand_and_validate_filepath(filename)
if not filename_validated:
raise ValueError(f"'{filename}' does not appear to be a file")
template_func = load_function(filename, funcname)
template_func = load_function(filename_validated, funcname)
values = template_func(self.photo)
if not isinstance(values, (str, list)):
@@ -1131,17 +1192,18 @@ class PhotoTemplate:
values = [values]
# sanitize directory names if needed
if filename:
if self.filename:
values = [sanitize_pathpart(value) for value in values]
elif dirname:
values = [sanitize_dirname(value) for value in values]
elif self.dirname:
# sanitize but don't replace any "/" as user function may want to create sub directories
values = [sanitize_dirname(value, replacement=None) for value in values]
return values
def get_template_value_filter_function(self, filter_, values):
"""Filter template value from external function """
"""Filter template value from external function"""
filter_ = filter_.replace("function:","")
filter_ = filter_.replace("function:", "")
if "::" not in filter_:
raise ValueError(
@@ -1150,10 +1212,11 @@ class PhotoTemplate:
filename, funcname = filter_.split("::")
if not pathlib.Path(filename).is_file():
filename_validated = expand_and_validate_filepath(filename)
if not filename_validated:
raise ValueError(f"'{filename}' does not appear to be a file")
template_func = load_function(filename, funcname)
template_func = load_function(filename_validated, funcname)
if not isinstance(values, (list, tuple)):
values = [values]
@@ -1166,9 +1229,8 @@ class PhotoTemplate:
return values
def get_photo_video_type(self, default):
""" return media type, e.g. photo or video """
"""return media type, e.g. photo or video"""
default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS)
if self.photo.isphoto:
return default_dict["photo"]
@@ -1176,7 +1238,7 @@ class PhotoTemplate:
return default_dict["video"]
def get_media_type(self, default):
""" return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type """
"""return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type"""
default_dict = parse_default_kv(default, MEDIA_TYPE_DEFAULTS)
p = self.photo
if p.selfie:
@@ -1210,7 +1272,7 @@ class PhotoTemplate:
def parse_default_kv(default, default_dict):
""" parse a string in form key1=value1;key2=value2,... as used for some template fields
"""parse a string in form key1=value1;key2=value2,... as used for some template fields
Args:
default: str, in form 'photo=foto;video=vidéo'
@@ -1235,9 +1297,38 @@ def parse_default_kv(default, default_dict):
def get_template_help():
"""Return help for template system as markdown string """
"""Return help for template system as markdown string"""
# TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this
help_file = pathlib.Path(__file__).parent / "phototemplate.md"
with open(help_file, "r") as fd:
md = fd.read()
return md
def _get_pathlib_value(field, value, quote):
"""Get the value for a pathlib.Path type template
Args:
field: the path field, e.g. "filename.stem"
value: the value for the path component
quote: bool; if true, quotes the returned path for safe execution in the shell
"""
parts = field.split(".")
if len(parts) == 1:
return shlex.quote(value) if quote else value
if len(parts) > 2:
raise ValueError(f"Illegal value for path template: {field}")
path = parts[0]
attribute = parts[1]
path = pathlib.Path(value)
try:
val = getattr(path, attribute)
val_str = str(val)
if quote:
val_str = shlex.quote(val_str)
return val_str
except AttributeError:
raise ValueError("Illegal value for path template: {attribute}")

View File

@@ -63,7 +63,8 @@ SubField:
;
SUBFIELD_WORD:
/[\.\w:\/]+/
/[\.\w:\/\-\~\'\"\%\@\#\^\]+/
/\\\s/?
;
Filter:

View File

@@ -1,8 +1,9 @@
""" QueryOptions class for PhotosDB.query """
from dataclasses import dataclass
from typing import Optional, Iterable, Tuple
import datetime
from dataclasses import asdict, dataclass
from typing import Iterable, List, Optional, Tuple
import bitmath
@@ -30,7 +31,7 @@ class QueryOptions:
shared: Optional[bool] = None
not_shared: Optional[bool] = None
photos: Optional[bool] = True
movies: Optional[bool] = True
movies: Optional[bool] = True
uti: Optional[Iterable[str]] = None
burst: Optional[bool] = None
not_burst: Optional[bool] = None
@@ -78,6 +79,10 @@ class QueryOptions:
max_size: Optional[bitmath.Byte] = None
regex: Optional[Iterable[Tuple[str, str]]] = None
query_eval: Optional[Iterable[str]] = None
duplicate: Optional[bool] = None
location: Optional[bool] = None
no_location: Optional[bool] = None
function: Optional[List[Tuple[callable, str]]] = None
def asdict(self):
return asdict(self)

View File

@@ -315,6 +315,40 @@ Then the next to you run osxphotos, you can simply do this:
The configuration file is a plain text file in [TOML](https://toml.io/en/) format so the `.toml` extension is standard but you can name the file anything you like.
### Run commands on exported photos for post-processing
You can use the `--post-command` option to run one or more commands against exported files. The `--post-command` option takes two arguments: CATEGORY and COMMAND. CATEGORY is a string that describes which category of file to run the command against. The available categories are described in the help text available via: `osxphotos help export`. For example, the `exported` category includes all exported photos and the `skipped` category includes all photos that were skipped when running export with `--update`. COMMAND is an osxphotos template string which will be rendered then passed to the shell for execution.
For example, the following command generates a log of all exported files and their associated keywords:
`osxphotos export /path/to/export --post-command exported "echo {shell_quote,{filepath}{comma}{,+keyword,}} >> {shell_quote,{export_dir}/exported.txt}"`
The special template field `{shell_quote}` ensures a string is properly quoted for execution in the shell. For example, it's possible that a file path or keyword in this example has a space in the value and if not properly quoted, this would cause an error in the execution of the command. When running commands, the template `{filepath}` is set to the full path of the exported file and `{export_dir}` is set to the full path of the base export directory.
Explanation of the template string:
```txt
{shell_quote,{filepath}{comma}{,+keyword,}}
│ │ │ │ │
│ │ │ | │
└──> quote everything after comma for proper execution in the shell
│ │ │ │
└───> filepath of the exported file
│ │ │
└───> insert a comma
│ │
└───> join the list of keywords together with a ","
└───> if no keywords, insert nothing (empty string: "")
```
Another example: if you had `exiftool` installed and wanted to wipe all metadata from all exported files, you could use the following:
`osxphotos export /path/to/export --post-command exported "/usr/local/bin/exiftool -all= {filepath|shell_quote}"`
This command uses the `|shell_quote` template filter instead of the `{shell_quote}` template because the only thing that needs to be quoted is the path to the exported file. Template filters filter the value of the rendered template field. A number of other filters are available and are described in the help text.
### An example from an actual osxphotos user
Here's a comprehensive use case from an actual osxphotos user that integrates many of the concepts discussed in this tutorial (thank-you Philippe for contributing this!):

621
osxphotos/uti.py Normal file
View File

@@ -0,0 +1,621 @@
""" get UTI for a given file extension and the preferred extension for a given UTI """
""" Implementation note: runs only on macOS
On macOS <= 11 (Big Sur), uses objective C CoreServices methods
UTTypeCopyPreferredTagWithClass and UTTypeCreatePreferredIdentifierForTag to retrieve the
UTI and the extension. These are deprecated in 10.15 (Catalina) and no longer supported on Monterey.
On Monterey, these calls are replaced with Swift methods that I can't call from python so
this code uses a cached dict of UTI values. The code first checks to see if the extension or UTI
is available in the cache and if so, returns it. If not, it performs a subprocess call to `mdls` to
retrieve the UTI (by creating a temp file with the correct extension) and returns the UTI. This only
works for the extension -> UTI lookup. On Monterey, if there is no cached value for UTI -> extension lookup,
returns None.
It's a bit hacky but best I can think of to make this robust on different versions of macOS. PRs welcome.
"""
import csv
import re
import subprocess
import tempfile
import CoreServices
import objc
from .utils import _get_os_version
# cached values of all the UTIs (< 6 chars long) known to my Mac running macOS 10.15.7
UTI_CSV = """extension,UTI,preferred_extension,MIME_type
c,public.c-source,c,None
f,public.fortran-source,f,None
h,public.c-header,h,None
i,public.c-source.preprocessed,i,None
l,public.lex-source,l,None
m,public.objective-c-source,m,None
o,public.object-code,o,None
r,com.apple.rez-source,r,None
s,public.assembly-source,s,None
y,public.yacc-source,y,None
z,public.z-archive,z,application/x-compress
aa,com.audible.aa-audiobook,aa,audio/audible
ai,com.adobe.illustrator.ai-image,ai,None
as,com.apple.applesingle-archive,as,None
au,public.au-audio,au,audio/basic
bz,public.bzip2-archive,bz2,application/x-bzip2
cc,public.c-plus-plus-source,cp,None
cp,public.c-plus-plus-source,cp,None
dv,public.dv-movie,dv,video/x-dv
gz,org.gnu.gnu-zip-archive,gz,application/x-gzip
hh,public.c-plus-plus-header,hh,None
hp,public.c-plus-plus-header,hh,None
ii,public.c-plus-plus-source.preprocessed,ii,None
js,com.netscape.javascript-source,js,text/javascript
lm,public.lex-source,l,None
mi,public.objective-c-source.preprocessed,mi,None
mm,public.objective-c-plus-plus-source,mm,None
pf,com.apple.colorsync-profile,icc,None
pl,public.perl-script,pl,text/x-perl-script
pm,public.perl-script,pl,text/x-perl-script
ps,com.adobe.postscript,ps,application/postscript
py,public.python-script,py,text/x-python-script
qt,com.apple.quicktime-movie,mov,video/quicktime
ra,com.real.realaudio,ram,audio/vnd.rn-realaudio
rb,public.ruby-script,rb,text/x-ruby-script
rm,com.real.realmedia,rm,application/vnd.rn-realmedia
sh,public.shell-script,sh,None
ts,public.mpeg-2-transport-stream,ts,None
ul,public.ulaw-audio,ul,None
uu,public.uuencoded-archive,uu,text/x-uuencode
wm,com.microsoft.windows-media-wm,wm,video/x-ms-wm
ym,public.yacc-source,y,None
aac,public.aac-audio,aac,audio/aac
aae,com.apple.photos.apple-adjustment-envelope,aae,None
aaf,org.aafassociation.advanced-authoring-format,aaf,None
aax,com.audible.aax-audiobook,aax,audio/vnd.audible.aax
abc,public.alembic,abc,None
ac3,public.ac3-audio,ac3,audio/ac3
ada,public.ada-source,ada,None
adb,public.ada-source,ada,None
ads,public.ada-source,ada,None
aif,public.aifc-audio,aifc,audio/aiff
amr,org.3gpp.adaptive-multi-rate-audio,amr,audio/amr
app,com.apple.application-bundle,app,None
arw,com.sony.arw-raw-image,arw,None
asf,com.microsoft.advanced-systems-format,asf,video/x-ms-asf
asx,com.microsoft.advanced-stream-redirector,asx,video/x-ms-asx
avi,public.avi,avi,video/avi
bdm,public.avchd-content,bdm,None
bin,com.apple.macbinary-archive,bin,application/macbinary
bmp,com.microsoft.bmp,bmp,image/bmp
bwf,com.microsoft.waveform-audio,wav,audio/vnd.wave
bz2,public.bzip2-archive,bz2,application/x-bzip2
caf,com.apple.coreaudio-format,caf,None
cdr,com.apple.disk-image-cdr,dvdr,None
cel,public.flc-animation,flc,video/flc
cer,public.x509-certificate,cer,application/x-x509-ca-cert
cpp,public.c-plus-plus-source,cp,None
crt,public.x509-certificate,cer,application/x-x509-ca-cert
crw,com.canon.crw-raw-image,crw,image/x-canon-crw
cr2,com.canon.cr2-raw-image,cr2,None
cr3,com.canon.cr3-raw-image,cr3,None
csh,public.csh-script,csh,None
css,public.css,css,text/css
csv,public.comma-separated-values-text,csv,text/csv
cxx,public.c-plus-plus-source,cp,None
dae,org.khronos.collada.digital-asset-exchange,dae,None
dcm,org.nema.dicom,dcm,application/dicom
dcr,com.kodak.raw-image,dcr,None
dds,com.microsoft.dds,dds,None
der,public.x509-certificate,cer,application/x-x509-ca-cert
dif,public.dv-movie,dv,video/x-dv
dll,com.microsoft.windows-dynamic-link-library,dll,application/x-msdownload
dls,public.downloadable-sound,dls,audio/dls
dmg,com.apple.disk-image-udif,dmg,None
dng,com.adobe.raw-image,dng,image/x-adobe-dng
doc,com.microsoft.word.doc,doc,application/msword
dot,com.microsoft.word.dot,dot,application/msword
dxo,com.dxo.raw-image,dxo,image/x-dxo-dxo
ec3,public.enhanced-ac3-audio,eac3,audio/eac3
edn,com.adobe.edn,edn,None
efx,com.j2.efx-fax,efx,image/efax
eml,com.apple.mail.email,eml,message/rfc822
eps,com.adobe.encapsulated-postscript,eps,None
erf,com.epson.raw-image,erf,image/x-epson-erf
etd,com.adobe.etd,etd,None
exe,com.microsoft.windows-executable,exe,application/x-msdownload
exp,com.apple.symbol-export,exp,None
exr,com.ilm.openexr-image,exr,None
fdf,com.adobe.fdf,fdf,None
fff,com.hasselblad.fff-raw-image,fff,None
flc,public.flc-animation,flc,video/flc
fli,public.flc-animation,flc,video/flc
flv,com.adobe.flash.video,flv,video/x-flv
for,public.fortran-source,f,None
fpx,com.kodak.flashpix-image,fpx,image/fpx
f4a,com.adobe.flash.video,flv,video/x-flv
f4b,com.adobe.flash.video,flv,video/x-flv
f4p,com.adobe.flash.video,flv,video/x-flv
f4v,com.adobe.flash.video,flv,video/x-flv
f77,public.fortran-77-source,f77,None
f90,public.fortran-90-source,f90,None
f95,public.fortran-95-source,f95,None
gif,com.compuserve.gif,gif,image/gif
hdr,public.radiance,pic,None
hpp,public.c-plus-plus-header,hh,None
hqx,com.apple.binhex-archive,hqx,application/mac-binhex40
htm,public.html,html,text/html
hxx,public.c-plus-plus-header,hh,None
iba,com.apple.ibooksauthor.pkgbook,iba,None
icc,com.apple.colorsync-profile,icc,None
icm,com.apple.colorsync-profile,icc,None
ico,com.microsoft.ico,ico,image/vnd.microsoft.icon
ics,com.apple.ical.ics,ics,text/calendar
iig,com.apple.iig-source,iig,None
iiq,com.phaseone.raw-image,iiq,None
img,com.apple.disk-image-ndif,ndif,None
inl,public.c-plus-plus-inline-header,inl,None
ipa,com.apple.itunes.ipa,ipa,None
ipp,public.c-plus-plus-header,hh,None
ips,com.apple.ips,ips,None
iso,public.iso-image,iso,None
ite,com.apple.tv.ite,ite,None
itl,com.apple.itunes.db,itl,None
jar,com.sun.java-archive,jar,application/java-archive
jav,com.sun.java-source,java,None
jfx,com.j2.jfx-fax,jfx,None
jpe,public.jpeg,jpeg,image/jpeg
jpf,public.jpeg-2000,jp2,image/jp2
jpg,public.jpeg,jpeg,image/jpeg
jpx,public.jpeg-2000,jp2,image/jp2
jp2,public.jpeg-2000,jp2,image/jp2
j2c,public.jpeg-2000,jp2,image/jp2
j2k,public.jpeg-2000,jp2,image/jp2
kar,public.midi-audio,midi,audio/midi
key,com.apple.iwork.keynote.key,key,None
ksh,public.ksh-script,ksh,None
kth,com.apple.iwork.keynote.kth,kth,None
ktx,org.khronos.ktx,ktx,None
lid,public.dylan-source,dlyan,None
lmm,public.lex-source,l,None
log,com.apple.log,log,None
lpp,public.lex-source,l,None
lxx,public.lex-source,l,None
mid,public.midi-audio,midi,audio/midi
mig,public.mig-source,defs,None
mii,public.objective-c-plus-plus-source.preprocessed,mii,None
mjs,com.netscape.javascript-source,js,text/javascript
mnc,ca.mcgill.mni.bic.mnc,mnc,None
mos,com.leafamerica.raw-image,mos,None
mov,com.apple.quicktime-movie,mov,video/quicktime
mpe,public.mpeg,mpg,video/mpeg
mpg,public.mpeg,mpg,video/mpeg
mpo,public.mpo-image,mpo,None
mp2,public.mp2,mp2,None
mp3,public.mp3,mp3,audio/mpeg
mp4,public.mpeg-4,mp4,video/mp4
mrw,com.konicaminolta.raw-image,mrw,None
mts,public.avchd-mpeg-2-transport-stream,mts,None
mxf,org.smpte.mxf,mxf,application/mxf
m15,public.mpeg,mpg,video/mpeg
m2v,public.mpeg-2-video,m2v,video/mpeg2
m3u,public.m3u-playlist,m3u,audio/mpegurl
m4a,com.apple.m4a-audio,m4a,audio/x-m4a
m4b,com.apple.protected-mpeg-4-audio-b,m4b,None
m4p,com.apple.protected-mpeg-4-audio,m4p,None
m4r,com.apple.mpeg-4-ringtone,m4r,audio/x-m4r
m4v,com.apple.m4v-video,m4v,video/x-m4v
m75,public.mpeg,mpg,video/mpeg
nef,com.nikon.raw-image,nef,None
nii,gov.nih.nifti-1,nii,None
nrw,com.nikon.nrw-raw-image,nrw,image/x-nikon-nrw
obj,public.geometry-definition-format,obj,None
odb,org.oasis-open.opendocument.database,odb,application/vnd.oasis.opendocument.database
odc,org.oasis-open.opendocument.chart,odc,application/vnd.oasis.opendocument.chart
odf,org.oasis-open.opendocument.formula,odf,application/vnd.oasis.opendocument.formula
odg,org.oasis-open.opendocument.graphics,odg,application/vnd.oasis.opendocument.graphics
odi,org.oasis-open.opendocument.image,odi,application/vnd.oasis.opendocument.image
odm,org.oasis-open.opendocument.text-master,odm,application/vnd.oasis.opendocument.text-master
odp,org.oasis-open.opendocument.presentation,odp,application/vnd.oasis.opendocument.presentation
ods,org.oasis-open.opendocument.spreadsheet,ods,application/vnd.oasis.opendocument.spreadsheet
odt,org.oasis-open.opendocument.text,odt,application/vnd.oasis.opendocument.text
omf,com.avid.open-media-framework,omf,None
orf,com.olympus.raw-image,orf,None
otc,public.opentype-collection-font,otc,None
otf,public.opentype-font,otf,None
otg,org.oasis-open.opendocument.graphics-template,otg,application/vnd.oasis.opendocument.graphics-template
oth,org.oasis-open.opendocument.text-web,oth,application/vnd.oasis.opendocument.text-web
oti,org.oasis-open.opendocument.image-template,oti,application/vnd.oasis.opendocument.image-template
otp,org.oasis-open.opendocument.presentation-template,otp,application/vnd.oasis.opendocument.presentation-template
ots,org.oasis-open.opendocument.spreadsheet-template,ots,application/vnd.oasis.opendocument.spreadsheet-template
ott,org.oasis-open.opendocument.text-template,ott,application/vnd.oasis.opendocument.text-template
pas,public.pascal-source,pas,None
pax,public.cpio-archive,cpio,None
pbm,public.pbm,pbm,None
pch,public.precompiled-c-header,pch,None
pct,com.apple.pict,pict,image/pict
pdf,com.adobe.pdf,pdf,application/pdf
pef,com.pentax.raw-image,pef,None
pem,public.x509-certificate,cer,application/x-x509-ca-cert
pfa,com.adobe.postscript-pfa-font,pfa,None
pfb,com.adobe.postscript-pfb-font,pfb,None
pfm,public.pbm,pbm,None
pfx,com.rsa.pkcs-12,p12,application/x-pkcs12
pgm,public.pbm,pbm,None
pgn,com.apple.chess.pgn,pgn,None
php,public.php-script,php,text/php
ph3,public.php-script,php,text/php
ph4,public.php-script,php,text/php
pic,com.apple.pict,pict,image/pict
pkg,com.apple.installer-package-archive,pkg,None
pls,public.pls-playlist,pls,audio/x-scpls
ply,public.polygon-file-format,ply,None
png,public.png,png,image/png
pot,com.microsoft.powerpoint.pot,pot,application/vnd.ms-powerpoint
ppm,public.pbm,pbm,None
pps,com.microsoft.powerpoint.pps,pps,application/vnd.ms-powerpoint
ppt,com.microsoft.powerpoint.ppt,ppt,application/vnd.ms-powerpoint
psb,com.adobe.photoshop-large-image,psb,None
psd,com.adobe.photoshop-image,psd,image/vnd.adobe.photoshop
pvr,public.pvr,pvr,None
pvt,com.apple.private.live-photo-bundle,pvt,None
pwl,com.leica.pwl-raw-image,pwl,image/x-leica-pwl
p12,com.rsa.pkcs-12,p12,application/x-pkcs12
qti,com.apple.quicktime-image,qtif,image/x-quicktime
qtz,com.apple.quartz-composer-composition,qtz,application/x-quartzcomposer
raf,com.fuji.raw-image,raf,None
ram,com.real.realaudio,ram,audio/vnd.rn-realaudio
raw,com.panasonic.raw-image,raw,None
rbw,public.ruby-script,rb,text/x-ruby-script
rmp,com.apple.music.rmp-playlist,rmp,application/vnd.rn-rn_music_package
rss,public.rss,rss,application/rss+xml
rtf,public.rtf,rtf,text/rtf
rwl,com.leica.rwl-raw-image,rwl,None
rw2,com.panasonic.rw2-raw-image,rw2,None
scc,com.scenarist.closed-caption,scc,None
scn,com.apple.scenekit.scene,scn,None
sda,org.openoffice.graphics,sxd,application/vnd.sun.xml.draw
sdc,org.openoffice.spreadsheet,sxc,application/vnd.sun.xml.calc
sdd,org.openoffice.presentation,sxi,application/vnd.sun.xml.impress
sdp,org.openoffice.presentation,sxi,application/vnd.sun.xml.impress
sdv,public.3gpp,3gp,video/3gpp
sdw,org.openoffice.text,sxw,application/vnd.sun.xml.writer
sd2,com.digidesign.sd2-audio,sd2,None
sea,com.stuffit.archive.sit,sit,application/x-stuffit
sf2,com.soundblaster.soundfont,sf2,None
sgi,com.sgi.sgi-image,sgi,image/sgi
sit,com.stuffit.archive.sit,sit,application/x-stuffit
slm,com.apple.photos.slow-motion-video-sidecar,slm,None
smf,public.midi-audio,midi,audio/midi
smi,com.apple.disk-image-smi,smi,None
snd,public.au-audio,au,audio/basic
spx,com.apple.systemprofiler.document,spx,None
srf,com.sony.raw-image,srf,None
srw,com.samsung.raw-image,srw,None
sr2,com.sony.sr2-raw-image,sr2,image/x-sony-sr2
stc,org.openoffice.spreadsheet-template,stc,application/vnd.sun.xml.calc.template
std,org.openoffice.graphics-template,std,application/vnd.sun.xml.draw.template
sti,org.openoffice.presentation-template,sti,application/vnd.sun.xml.impress.template
stl,public.standard-tesselated-geometry-format,stl,None
stw,org.openoffice.text-template,stw,application/vnd.sun.xml.writer.template
svg,public.svg-image,svg,image/svg+xml
sxc,org.openoffice.spreadsheet,sxc,application/vnd.sun.xml.calc
sxd,org.openoffice.graphics,sxd,application/vnd.sun.xml.draw
sxg,org.openoffice.text-master,sxg,application/vnd.sun.xml.writer.global
sxi,org.openoffice.presentation,sxi,application/vnd.sun.xml.impress
sxm,org.openoffice.formula,sxm,application/vnd.sun.xml.math
sxw,org.openoffice.text,sxw,application/vnd.sun.xml.writer
tar,public.tar-archive,tar,application/x-tar
tbz,public.tar-bzip2-archive,tbz2,None
tga,com.truevision.tga-image,tga,image/targa
tgz,org.gnu.gnu-zip-tar-archive,tgz,None
tif,public.tiff,tiff,image/tiff
tsv,public.tab-separated-values-text,tsv,text/tab-separated-values
ttc,public.truetype-collection-font,ttc,None
ttf,public.truetype-ttf-font,ttf,None
txt,public.plain-text,txt,text/plain
ulw,public.ulaw-audio,ul,None
url,com.microsoft.internet-shortcut,url,None
usd,com.pixar.universal-scene-description,usd,None
vcf,public.vcard,vcf,text/directory
vcs,com.apple.ical.vcs,vcs,text/x-vcalendar
vfw,public.avi,avi,video/avi
vtt,org.w3.webvtt,vtt,text/vtt
war,com.sun.web-application-archive,war,None
wav,com.microsoft.waveform-audio,wav,audio/vnd.wave
wax,com.microsoft.windows-media-wax,wax,video/x-ms-wax
web,com.getdropbox.dropbox.shortcut,web,None
wma,com.microsoft.windows-media-wma,wma,video/x-ms-wma
wmp,com.microsoft.windows-media-wmp,wmp,video/x-ms-wmp
wmv,com.microsoft.windows-media-wmv,wmv,video/x-ms-wmv
wmx,com.microsoft.windows-media-wmx,wmx,video/x-ms-wmx
wvx,com.microsoft.windows-media-wvx,wvx,video/x-ms-wvx
xar,com.apple.xar-archive,xar,None
xbm,public.xbitmap-image,xbm,image/x-xbitmap
xfd,public.xfd,xfd,None
xht,public.xhtml,xhtml,application/xhtml+xml
xip,com.apple.xip-archive,xip,None
xla,com.microsoft.excel.xla,xla,None
xls,com.microsoft.excel.xls,xls,application/vnd.ms-excel
xlt,com.microsoft.excel.xlt,xlt,application/vnd.ms-excel
xlw,com.microsoft.excel.xlw,xlw,application/vnd.ms-excel
xml,public.xml,xml,application/xml
xmp,com.seriflabs.xmp,xmp,application/rdf+xml
xpc,com.apple.xpc-service,xpc,None
yml,public.yaml,yml,application/x-yaml
ymm,public.yacc-source,y,None
ypp,public.yacc-source,y,None
yxx,public.yacc-source,y,None
zip,public.zip-archive,zip,application/zip
zsh,public.zsh-script,zsh,None
3fr,com.hasselblad.3fr-raw-image,3fr,None
3gp,public.3gpp,3gp,video/3gpp
3g2,public.3gpp2,3g2,video/3gpp2
adts,public.aac-audio,aac,audio/aac
ahap,com.apple.haptics.ahap,ahap,None
aifc,public.aifc-audio,aifc,audio/aiff
aiff,public.aifc-audio,aifc,audio/aiff
astc,org.khronos.astc,astc,None
avci,public.avci,avci,image/avci
avcs,public.avcs,avcs,image/avcs
band,com.apple.garageband.project,band,None
bash,public.bash-script,bash,None
bdmv,public.avchd-content,bdm,None
book,com.apple.ibooksauthor.pkgbook,iba,None
cdda,public.aifc-audio,aifc,audio/aiff
chat,com.apple.ichat.transcript,ichat,None
cpgz,com.apple.bom-compressed-cpio,cpgz,None
cpio,public.cpio-archive,cpio,None
dart,com.apple.disk-image-dart,dart,None
dc42,com.apple.disk-image-dc42,dc42,None
defs,public.mig-source,defs,None
dext,com.apple.driver-extension,dext,None
diff,public.patch-file,patch,None
dist,com.apple.installer-distribution-package,dist,None
docm,org.openxmlformats.wordprocessingml.document.macroenabled,docm,application/vnd.ms-word.document.macroenabled.12
docx,org.openxmlformats.wordprocessingml.document,docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document
dotm,org.openxmlformats.wordprocessingml.template.macroenabled,dotm,application/vnd.ms-word.template.macroenabled.12
dotx,org.openxmlformats.wordprocessingml.template,dotx,application/vnd.openxmlformats-officedocument.wordprocessingml.template
dsym,com.apple.xcode.dsym,dsym,None
dvdr,com.apple.disk-image-cdr,dvdr,None
eac3,public.enhanced-ac3-audio,eac3,audio/eac3
emlx,com.apple.mail.emlx,emlx,None
enex,com.evernote.enex,enex,None
epub,org.idpf.epub-container,epub,application/epub+zip
fh10,com.seriflabs.affinity,fh10,None
fh11,com.seriflabs.affinity,fh10,None
flac,org.xiph.flac,flac,audio/flac
fpbf,com.apple.finder.burn-folder,fpbf,None
game,com.apple.chess.game,game,None
gdoc,com.google.gdoc,gdoc,None
gtar,org.gnu.gnu-tar-archive,gtar,application/x-gtar
gzip,org.gnu.gnu-zip-archive,gz,application/x-gzip
hang,com.apple.hangreport,hang,None
heic,public.heic,heic,image/heic
heif,public.heif,heif,image/heif
html,public.html,html,text/html
hvpl,com.apple.music.visual,hvpl,None
icbu,com.apple.ical.backup,icbu,None
icns,com.apple.icns,icns,None
ipsw,com.apple.itunes.ipsw,ipsw,None
itlp,com.apple.music.itlp,itlp,None
itms,com.apple.itunes.store-url,itms,None
java,com.sun.java-source,java,None
jnlp,com.sun.java-web-start,jnlp,application/x-java-jnlp-file
jpeg,public.jpeg,jpeg,image/jpeg
json,public.json,json,application/json
latm,public.mp4a-loas,loas,None
loas,public.mp4a-loas,loas,None
lpdf,com.apple.localized-pdf-bundle,lpdf,None
mbox,com.apple.mail.mbox,mbox,None
menu,com.apple.menu-extra,menu,None
midi,public.midi-audio,midi,audio/midi
minc,ca.mcgill.mni.bic.mnc,mnc,None
mpeg,public.mpeg,mpg,video/mpeg
mpga,public.mp3,mp3,audio/mpeg
mpg4,public.mpeg-4,mp4,video/mp4
mpkg,com.apple.installer-package-archive,pkg,None
m2ts,public.avchd-mpeg-2-transport-stream,mts,None
m3u8,public.m3u-playlist,m3u,audio/mpegurl
ndif,com.apple.disk-image-ndif,ndif,None
note,com.apple.notes.note,note,None
php3,public.php-script,php,text/php
php4,public.php-script,php,text/php
pict,com.apple.pict,pict,image/pict
pntg,com.apple.macpaint-image,pntg,None
potm,org.openxmlformats.presentationml.template.macroenabled,potm,application/vnd.ms-powerpoint.template.macroenabled.12
potx,org.openxmlformats.presentationml.template,potx,application/vnd.openxmlformats-officedocument.presentationml.template
ppsm,org.openxmlformats.presentationml.slideshow.macroenabled,ppsm,application/vnd.ms-powerpoint.slideshow.macroenabled.12
ppsx,org.openxmlformats.presentationml.slideshow,ppsx,application/vnd.openxmlformats-officedocument.presentationml.slideshow
pptm,org.openxmlformats.presentationml.presentation.macroenabled,pptm,application/vnd.ms-powerpoint.presentation.macroenabled.12
pptx,org.openxmlformats.presentationml.presentation,pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation
pset,com.apple.pdf-printer-settings,pset,None
qtif,com.apple.quicktime-image,qtif,image/x-quicktime
rmvb,com.real.realmedia-vbr,rmvb,application/vnd.rn-realmedia-vbr
rtfd,com.apple.rtfd,rtfd,None
scnz,com.apple.scenekit.scene,scn,None
scpt,com.apple.applescript.script,scpt,None
shtm,public.html,html,text/html
sidx,com.stuffit.archive.sidx,sidx,application/x-stuffitx-index
sitx,com.stuffit.archive.sitx,sitx,application/x-stuffitx
spin,com.apple.spinreport,spin,None
suit,com.apple.font-suitcase,suit,None
svgz,public.svg-image,svg,image/svg+xml
tbz2,public.tar-bzip2-archive,tbz2,None
tcsh,public.tcsh-script,tcsh,None
term,com.apple.terminal.session,term,None
text,public.plain-text,txt,text/plain
tiff,public.tiff,tiff,image/tiff
tool,com.apple.terminal.shell-script,command,None
udif,com.apple.disk-image-udif,dmg,None
ulaw,public.ulaw-audio,ul,None
usda,com.pixar.universal-scene-description,usd,None
usdc,com.pixar.universal-scene-description,usd,None
usdz,com.pixar.universal-scene-description-mobile,usdz,model/vnd.usdz+zip
vcal,com.apple.ical.vcs,vcs,text/x-vcalendar
wave,com.microsoft.waveform-audio,wav,audio/vnd.wave
wdgt,com.apple.dashboard-widget,wdgt,None
webp,public.webp,webp,None
xfdf,com.adobe.xfdf,xfdf,None
xhtm,public.xhtml,xhtml,application/xhtml+xml
xlsb,com.microsoft.excel.sheet.binary.macroenabled,xlsb,application/vnd.ms-excel.sheet.binary.macroenabled.12
xlsm,org.openxmlformats.spreadsheetml.sheet.macroenabled,xlsm,application/vnd.ms-excel.sheet.macroenabled.12
xlsx,org.openxmlformats.spreadsheetml.sheet,xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
xltm,org.openxmlformats.spreadsheetml.template.macroenabled,xltm,application/vnd.ms-excel.template.macroenabled.12
xltx,org.openxmlformats.spreadsheetml.template,xltx,application/vnd.openxmlformats-officedocument.spreadsheetml.template
yaml,public.yaml,yml,application/x-yaml
3gpp,public.3gpp,3gp,video/3gpp
3gp2,public.3gpp2,3g2,video/3gpp2
abcdg,com.apple.addressbook.group,abcdg,None
abcdp,com.apple.addressbook.person,abcdp,None
afpub,com.seriflabs.affinitypublisher.document,afpub,None
appex,com.apple.application-and-system-extension,appex,None
avchd,public.avchd-collection,avchd,None
blank,com.apple.preview.blank,blank,None
class,com.sun.java-class,class,None
crash,com.apple.crashreport,crash,None
dfont,com.apple.truetype-datafork-suitcase-font,dfont,None
dicom,org.nema.dicom,dcm,application/dicom
distz,com.apple.installer-distribution-package,dist,None
dlyan,public.dylan-source,dlyan,None
dylib,com.apple.mach-o-dylib,dylib,None
heics,public.heics,heics,image/heic-sequence
heifs,public.heifs,heifs,image/heif-sequence
ichat,com.apple.ichat.transcript,ichat,None
pages,com.apple.iwork.pages.pages,pages,None
panic,com.apple.panicreport,panic,None
paper,com.getdropbox.dropbox.paper,paper,None
patch,public.patch-file,patch,None
phtml,public.php-script,php,text/php
plist,com.apple.property-list,plist,None
saver,com.apple.systempreference.screen-saver,saver,None
scptd,com.apple.applescript.script-bundle,scptd,None
sfont,com.apple.cfr-font,sfont,None
shtml,public.html,html,text/html
swift,public.swift-source,swift,None
toast,com.roxio.disk-image-toast,toast,None
vcard,public.vcard,vcf,text/directory
wdmon,com.apple.wireless-diagnostics.wdmon,wdmon,None
xhtml,public.xhtml,xhtml,application/xhtml+xml
action,com.apple.automator-action,action,None
afploc,com.apple.afp-internet-location,afploc,None
"""
# load CSV separated uti data into dictionaries with key of extension and UTI
EXT_UTI_DICT = {}
UTI_EXT_DICT = {}
def _load_uti_dict():
"""load an initialize the cached UTI and extension dicts"""
_reader = csv.DictReader(UTI_CSV.split("\n"), delimiter=",")
for row in _reader:
EXT_UTI_DICT[row["extension"]] = row["UTI"]
UTI_EXT_DICT[row["UTI"]] = row["preferred_extension"]
_load_uti_dict()
# OS version for determining which methods can be used
OS_VER, OS_MAJOR, _ = (int(x) for x in _get_os_version())
def _get_uti_from_mdls(extension):
"""use mdls to get the UTI for a given extension on systems that don't support UTTypeCreatePreferredIdentifierForTag
Returns: UTI or None if UTI cannot be determined"""
# mdls -name kMDItemContentType foo.3fr
# kMDItemContentType = "com.hasselblad.3fr-raw-image"
with tempfile.NamedTemporaryFile(suffix="." + extension) as temp:
output = subprocess.check_output(
[
"/usr/bin/mdls",
"-name",
"kMDItemContentType",
temp.name,
]
).splitlines()
output = output[0].decode("UTF-8") if output else None
if not output:
return None
match = re.match(r'kMDItemContentType\s+\=\s+"(.*)"', output)
if match:
return match.group(1)
return None
def _get_uti_from_ext_dict(ext):
try:
return EXT_UTI_DICT[ext]
except KeyError:
return None
def _get_ext_from_uti_dict(uti):
try:
return UTI_EXT_DICT[uti]
except KeyError:
return None
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 or None if cannot be determined"""
if (OS_VER, OS_MAJOR) <= (10, 16):
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
# deprecated in Catalina+, likely won't work at all on macOS 12
with objc.autorelease_pool():
extension = CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension
)
if extension:
return extension
# on MacOS 10.12, HEIC files are not supported and UTTypeCopyPreferredTagWithClass will return None for HEIC
if uti == "public.heic":
return "HEIC"
return None
return _get_ext_from_uti_dict(uti)
def get_uti_for_extension(extension):
"""get UTI for a given file extension"""
# accepts extension with or without leading 0
if extension[0] == ".":
extension = extension[1:]
if (OS_VER, OS_MAJOR) <= (10, 16):
# https://developer.apple.com/documentation/coreservices/1448939-uttypecreatepreferredidentifierf
with objc.autorelease_pool():
uti = CoreServices.UTTypeCreatePreferredIdentifierForTag(
CoreServices.kUTTagClassFilenameExtension, extension, None
)
if uti:
return uti
# on MacOS 10.12, HEIC files are not supported and UTTypeCopyPreferredTagWithClass will return None for HEIC
if extension.lower() == "heic":
return "public.heic"
return None
uti = _get_uti_from_ext_dict(extension)
if uti:
return uti
uti = _get_uti_from_mdls(extension)
if uti:
# cache the UTI
EXT_UTI_DICT[extension.lower()] = uti
UTI_EXT_DICT[uti] = extension.lower()
return uti
return None

View File

@@ -19,8 +19,6 @@ from plistlib import load as plistload
from typing import Callable
import CoreFoundation
import CoreServices
import objc
from ._constants import UNICODE_FORMAT
@@ -38,7 +36,7 @@ if not _DEBUG:
def _get_logger():
"""Used only for testing
Returns:
logging.Logger object -- logging.Logger object for osxphotos
"""
@@ -46,7 +44,7 @@ def _get_logger():
def _set_debug(debug):
""" Enable or disable debug logging """
"""Enable or disable debug logging"""
global _DEBUG
_DEBUG = debug
if debug:
@@ -56,18 +54,18 @@ def _set_debug(debug):
def _debug():
""" returns True if debugging turned on (via _set_debug), otherwise, false """
"""returns True if debugging turned on (via _set_debug), otherwise, false"""
return _DEBUG
def noop(*args, **kwargs):
""" do nothing (no operation) """
"""do nothing (no operation)"""
pass
def lineno(filename):
""" Returns string with filename and current line number in caller as '(filename): line_num'
Will trim filename to just the name, dropping path, if any. """
"""Returns string with filename and current line number in caller as '(filename): line_num'
Will trim filename to just the name, dropping path, if any."""
line = inspect.currentframe().f_back.f_lineno
filename = pathlib.Path(filename).name
return f"{filename}: {line}"
@@ -92,14 +90,14 @@ def _get_os_version():
def _check_file_exists(filename):
""" returns true if file exists and is not a directory
otherwise returns false """
"""returns true if file exists and is not a directory
otherwise returns false"""
filename = os.path.abspath(filename)
return os.path.exists(filename) and not os.path.isdir(filename)
def _get_resource_loc(model_id):
""" returns folder_id and file_id needed to find location of edited photo """
"""returns folder_id and file_id needed to find location of edited photo"""
""" and live photos for version <= Photos 4.0 """
# determine folder where Photos stores edited version
# edited images are stored in:
@@ -117,7 +115,7 @@ def _get_resource_loc(model_id):
def _dd_to_dms(dd):
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """
"""convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds"""
""" return tuple of int(deg), int(min), float(sec) """
dd = float(dd)
negative = dd < 0
@@ -136,7 +134,7 @@ def _dd_to_dms(dd):
def dd_to_dms_str(lat, lon):
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
"""convert latitude, longitude in degrees to degrees, minutes, seconds as string"""
""" lat: latitude in degrees """
""" lon: longitude in degrees """
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
@@ -165,7 +163,7 @@ def dd_to_dms_str(lat, lon):
def get_system_library_path():
""" return the path to the system Photos library as string """
"""return the path to the system Photos library as string"""
""" only works on MacOS 10.15 """
""" on earlier versions, returns None """
_, major, _ = _get_os_version()
@@ -190,8 +188,8 @@ def get_system_library_path():
def get_last_library_path():
""" returns the path to the last opened Photos library
If a library has never been opened, returns None """
"""returns the path to the last opened Photos library
If a library has never been opened, returns None"""
plist_file = pathlib.Path(
str(pathlib.Path.home())
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
@@ -241,7 +239,7 @@ def get_last_library_path():
def list_photo_libraries():
""" returns list of Photos libraries found on the system """
"""returns list of Photos libraries found on the system"""
""" on MacOS < 10.15, this may omit some libraries """
# On 10.15, mdfind appears to find all libraries
@@ -265,22 +263,10 @@ def list_photo_libraries():
return lib_list
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
with objc.autorelease_pool():
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 []."""
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
@@ -289,26 +275,9 @@ def findfiles(pattern, path_):
return [name for name in os.listdir(path_) if rule.match(name)]
# TODO: this doesn't always work, still looking for a way to
# force Photos to open the library being operated on
# def _open_photos_library_applescript(library_path):
# """ Force Photos to open a specific library
# library_path: path to the Photos library """
# open_scpt = AppleScript(
# f"""
# on openLibrary
# tell application "Photos"
# open POSIX file "{library_path}"
# end tell
# end openLibrary
# """
# )
# open_scpt.run()
def _open_sql_file(dbname):
""" opens sqlite file dbname in read-only mode
returns tuple of (connection, cursor) """
"""opens sqlite file dbname in read-only mode
returns tuple of (connection, cursor)"""
try:
dbpath = pathlib.Path(dbname).resolve()
conn = sqlite3.connect(f"{dbpath.as_uri()}?mode=ro", timeout=1, uri=True)
@@ -319,9 +288,9 @@ def _open_sql_file(dbname):
def _db_is_locked(dbname):
""" check to see if a sqlite3 db is locked
returns True if database is locked, otherwise False
dbname: name of database to test """
"""check to see if a sqlite3 db is locked
returns True if database is locked, otherwise False
dbname: name of database to test"""
# first, check to see if lock file exists, if so, assume the file is locked
lock_name = f"{dbname}.lock"
@@ -372,7 +341,7 @@ def _db_is_locked(dbname):
def normalize_unicode(value):
""" normalize unicode data """
"""normalize unicode data"""
if value is not None:
if isinstance(value, (tuple, list)):
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
@@ -385,9 +354,9 @@ def normalize_unicode(value):
def increment_filename(filepath):
""" Return filename (1).ext, etc if filename.ext exists
"""Return filename (1).ext, etc if filename.ext exists
If file exists in filename's parent folder with same stem as filename,
If file exists in filename's parent folder with same stem as filename,
add (1), (2), etc. until a non-existing filename is found.
Args:
@@ -410,8 +379,22 @@ def increment_filename(filepath):
return str(dest)
def expand_and_validate_filepath(path: str) -> str:
"""validate and expand ~ in filepath, also un-escapes spaces
Returns:
expanded path if path is valid file, else None
"""
path = re.sub(r"\\ ", " ", path)
path = pathlib.Path(path).expanduser()
if path.is_file():
return str(path)
return None
def load_function(pyfile: str, function_name: str) -> Callable:
""" Load function_name from python file pyfile """
"""Load function_name from python file pyfile"""
module_file = pathlib.Path(pyfile)
if not module_file.is_file():
raise FileNotFoundError(f"module {pyfile} does not appear to exist")

View File

@@ -1,211 +1,22 @@
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
bitmath==1.3.3.1
bleach==3.3.0
pyobjc-core==7.2
pyobjc-framework-AppleScriptKit==7.2
pyobjc-framework-AppleScriptObjC==7.2
pyobjc-framework-Photos==7.2
pyobjc-framework-Quartz==7.2
pyobjc-framework-AVFoundation==7.2
pyobjc-framework-CoreServices==7.2
pyobjc-framework-Metal==7.2
Click==8.0.1
PyYAML==5.4.1
Mako==1.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
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
multidict==4.7.6
osxmetadata>=0.99.11
packaging==19.0
parso==0.6.2
pathspec==0.7.0
pathvalidate==2.2.1
pexpect==4.8.0
photoscript==0.1.2
pickleshare==0.7.5
Pillow==8.1.1
pkginfo==1.5.0.1
pluggy==0.12.0
prompt-toolkit==3.0.4
psutil==5.7.0
ptyprocess==0.6.0
py==1.10.0
py2app==0.21
pycparser==2.20
pyfiglet==0.8.post1
Pygments==2.7.4
PyInstaller==3.6
pyinstaller-setuptools==2019.3
pylint==2.3.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.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
python-dateutil==2.8.1
PyYAML==5.4
pyzmq==18.1.1
readme-renderer==25.0
regex==2020.2.20
requests==2.23.0
requests-toolbelt==0.9.1
rich==9.11.1
six==1.14.0
termcolor==1.1.0
pathvalidate==2.4.1
dataclasses==0.7;python_version<'3.7'
wurlitzer==2.1.0
photoscript==0.1.3
toml==0.10.2
osxmetadata==0.99.14
textx==2.3.0
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
wurlitzer==2.0.1
yarl==1.4.2
zipp==0.5.2
rich==10.2.2
bitmath==1.3.3.1
more-itertools==8.8.0

View File

@@ -73,20 +73,28 @@ setup(
"Topic :: Software Development :: Libraries :: Python Modules",
],
install_requires=[
"pyobjc>=6.2.2",
"Click>=7",
"PyYAML>=5.1.2",
"Mako>=1.1.1",
"pyobjc-core==7.2",
"pyobjc-framework-AppleScriptKit==7.2",
"pyobjc-framework-AppleScriptObjC==7.2",
"pyobjc-framework-Photos==7.2",
"pyobjc-framework-Quartz==7.2",
"pyobjc-framework-AVFoundation==7.2",
"pyobjc-framework-CoreServices==7.2",
"pyobjc-framework-Metal==7.2",
"Click==8.0.1",
"PyYAML==5.4.1",
"Mako==1.1.4",
"bpylist2==3.0.2",
"pathvalidate==2.2.1",
"pathvalidate==2.4.1",
"dataclasses==0.7;python_version<'3.7'",
"wurlitzer>=2.0.1",
"photoscript>=0.1.2",
"toml>=0.10.0",
"osxmetadata>=0.99.13",
"wurlitzer==2.1.0",
"photoscript==0.1.3",
"toml==0.10.2",
"osxmetadata==0.99.14",
"textx==2.3.0",
"rich>=9.11.1",
"rich==10.2.2",
"bitmath==1.3.3.1",
"more-itertools==8.8.0",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2020-04-17T18:39:50Z</date>
<date>2021-06-01T17:42:08Z</date>
</dict>
<key>PXPeopleScreenUnlocked</key>
<true/>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-04-17T18:39:52Z</date>
<date>2021-06-01T17:42:08Z</date>
</dict>
</plist>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

@@ -5,9 +5,9 @@
<key>hostname</key>
<string>Rhets-MacBook-Pro.local</string>
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<string>585B80BF-8D1F-55EF-A9E8-6CF4E5523959</string>
<key>pid</key>
<integer>86501</integer>
<integer>570</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LibrarySchemaVersion</key>
<integer>5001</integer>
<key>MetaSchemaVersion</key>
<integer>3</integer>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>hostname</key>
<string>ms-MacBook-Pro.local</string>
<key>hostuuid</key>
<string>793FB248-A75B-5F63-9A2E-76E4BFA8E877</string>
<key>pid</key>
<integer>391</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>
<integer>501</integer>
</dict>
</plist>

Binary file not shown.

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