Compare commits

..

30 Commits

Author SHA1 Message Date
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
27 changed files with 1899 additions and 406 deletions

View File

@@ -4,12 +4,12 @@ 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:
os: [macos-10.15]
python-version: [3.7, 3.8, 3.9]
steps:

3
.isort.cfg Normal file
View File

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

View File

@@ -4,6 +4,92 @@ 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.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
@@ -22,6 +108,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
@@ -106,6 +193,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)
@@ -121,6 +210,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)
@@ -256,6 +347,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)
@@ -325,6 +417,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)
@@ -450,6 +544,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)
@@ -461,6 +557,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)
@@ -492,6 +590,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)
@@ -501,6 +601,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)
@@ -538,6 +639,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)
@@ -647,6 +749,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)
@@ -674,6 +778,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)
@@ -765,6 +871,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)
@@ -948,6 +1056,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)
@@ -964,6 +1074,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)
@@ -999,6 +1111,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)
@@ -1025,6 +1139,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)
@@ -1035,6 +1151,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)
@@ -1051,6 +1168,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)
@@ -1262,6 +1381,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)
@@ -1293,6 +1414,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)
@@ -1302,6 +1425,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)
@@ -1326,6 +1451,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)
@@ -1376,6 +1503,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 < 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)
@@ -1454,6 +1583,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)
@@ -1532,6 +1663,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)
@@ -1565,6 +1698,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)
@@ -1577,6 +1712,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)
@@ -1587,6 +1724,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)
@@ -1637,6 +1776,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)
@@ -1660,6 +1801,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)

205
README.md
View File

@@ -50,11 +50,14 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
## Supported operating systems
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Big Sur (10.16/11.1).
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Big Sur (10.16/11.3).
If you have access to the macOS 12 / Monterey beta and would like to help ensure osxphotos is compatible, please visit the [Discussions](https://github.com/RhetTbull/osxphotos/discussions) page and let me know!
| macOS Version | macOS name | Photos.app version |
| ----------------- |------------|:-------------------|
| 10.16, 11.0-11.3 | Big Sur | 6.0 ✅ |
| 12.0 | Monterey | ?.0 UNKNOWN |
| 10.16, 11.0-11.4 | Big Sur | 6.0 ✅ |
| 10.15.1 - 10.15.7 | Catalina | 5.0 ✅ |
| 10.14.5, 10.14.6 | Mojave | 4.0 ✅ |
| 10.13.6 | High Sierra| 3.0 ✅ |
@@ -118,6 +121,7 @@ This package will install a command line utility called `osxphotos` that allows
```
> osxphotos
Usage: osxphotos [OPTIONS] COMMAND [ARGS]...
Options:
@@ -146,6 +150,8 @@ Commands:
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>`
@@ -482,6 +488,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!):
@@ -750,19 +790,25 @@ Options:
the library if a photo is a burst photo.
--skip-live Do not export the associated live video
component of a live photo.
--skip-raw Do not export associated raw images of a
RAW+JPEG pair. Note: this does not skip raw
photos if the raw photo does not have an
associated jpeg image (e.g. the raw file was
imported to Photos without a jpeg preview).
--skip-raw Do not export associated RAW image of a
RAW+JPEG pair. Note: this does not skip RAW
photos if the RAW photo does not have an
associated JPEG image (e.g. the RAW file was
imported to Photos without a JPEG preview).
--current-name Use photo's current filename instead of
original filename for export. Note: Starting
with Photos 5, all photos are renamed upon
import. By default, photos are exported with
the the original name they had before import.
--convert-to-jpeg Convert all non-jpeg images (e.g. raw, HEIC,
PNG, etc) to JPEG upon export. Only works if
your Mac has a GPU.
--convert-to-jpeg Convert all non-JPEG images (e.g. RAW, HEIC,
PNG, etc) to JPEG upon export. Note: does not
convert the RAW component of a RAW+JPEG pair
as the associated JPEG image will be exported.
You can use --skip-raw to skip exporting the
associated RAW image of a RAW+JPEG pair. See
also --jpeg-quality and --jpeg-ext. Only works
if your Mac has a GPU (thus may not work on
virtual machines).
--jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with
--convert-to-jpeg. A value of 1.0 specifies
best quality, a value of 0.0 specifies maximum
@@ -1009,6 +1055,36 @@ Options:
feature is currently experimental. I don't
know how well it will work on large export
sets.
--post-command CATEGORY COMMAND
Run COMMAND on exported files of category
CATEGORY. CATEGORY can be one of: exported,
new, updated, skipped, missing, exif_updated,
touched, converted_to_jpeg,
sidecar_json_written, sidecar_json_skipped,
sidecar_exiftool_written,
sidecar_exiftool_skipped, sidecar_xmp_written,
sidecar_xmp_skipped, error. COMMAND is an
osxphotos template string, for example: '--
post-command exported "echo
{filepath|shell_quote} >>
{export_dir}/exported.txt"', which appends the
full path of all exported files to the file
'exported.txt'. You can run more than one
command by repeating the '--post-command'
option with different arguments. See Post
Command below.
--post-function filename.py::function
Run function on exported files. Use this in
format: --post-function filename.py::function
where filename.py is a python file you've
created and function is the name of the
function in the python file you want to call.
The function will be passed information about
the photo that's been exported and a list of
all exported files associated with the photo.
You can run more than one function by
repeating the '--post-function' option with
different arguments. See Post Function below.
--exportdb EXPORTDB_FILE Specify alternate name for database file which
stores state information for export and
--update. If --exportdb is not specified,
@@ -1166,6 +1242,8 @@ 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
@@ -1505,7 +1583,7 @@ Substitution Description
{lf} A line feed: '\n', alias for {newline}
{cr} A carriage return: '\r'
{crlf} a carriage return + line feed: '\r\n'
{osxphotos_version} The osxphotos version, e.g. '0.42.31'
{osxphotos_version} The osxphotos version, e.g. '0.42.43'
{osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for
@@ -1564,6 +1642,10 @@ Substitution Description
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
@@ -1574,6 +1656,103 @@ Substitution Description
/blob/master/examples/template_function.py for an
example of how to implement a template function.
The following substitutions are file or directory paths. You can access various
parts of the path using the following modifiers:
{path.parent}: the parent directory
{path.name}: the name of the file or final sub-directory
{path.stem}: the name of the file without the extension
{path.suffix}: the suffix of the file including the leading '.'
For example, if the field {export_dir} is '/Shared/Backup/Photos':
{export_dir.parent} is '/Shared/Backup'
If the field {filepath} is '/Shared/Backup/Photos/IMG_1234.JPG':
{filepath.parent} is '/Shared/Backup/Photos'
{filepath.name} is 'IMG_1234.JPG'
{filepath.stem} is 'IMG_1234'
{filepath.suffix} is '.JPG'
Substitution Description
{export_dir} The full path to the export directory
{filepath} The full path to the exported file
** Post Command **
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:
Catgory Description
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
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':
--post-command new "echo {filepath.name|shell_quote} >> {shell_quote,{export_dir}/exported.txt}"
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:
echo 'IMG 1234.jpeg' >> '/Volumes/Photo Export/exported.txt'
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.
** Post Function **
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.
```
<!-- OSXPHOTOS-EXPORT-USAGE:END -->
@@ -3026,6 +3205,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 -->
@@ -3202,7 +3382,7 @@ The following template field substitutions are availabe for use the templating s
|{lf}|A line feed: '\n', alias for {newline}|
|{cr}|A carriage return: '\r'|
|{crlf}|a carriage return + line feed: '\r\n'|
|{osxphotos_version}|The osxphotos version, e.g. '0.42.31'|
|{osxphotos_version}|The osxphotos version, e.g. '0.42.43'|
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|{album}|Album(s) photo is contained in|
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
@@ -3217,6 +3397,7 @@ The following template field substitutions are availabe for use the templating s
|{searchinfo.venue}|Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{searchinfo.venue_type}|Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{photo}|Provides direct access to the PhotoInfo object for the photo. Must be used in format '{photo.property}' where 'property' represents a PhotoInfo property. 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. See https://github.com/RhetTbull/osxphotos/blob/master/examples/template_function.py for an example of how to implement a template function.|
<!-- OSXPHOTOS-TEMPLATE-TABLE:END -->

View File

@@ -18,6 +18,8 @@ Supported operating systems
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.
@@ -108,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>``

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

@@ -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

@@ -71,6 +71,7 @@ _TESTED_OS_VERSIONS = [
("11", "1"),
("11", "2"),
("11", "3"),
("11", "4"),
]
# Photos 5 has persons who are empty string if unidentified face
@@ -220,3 +221,24 @@ 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.31"
__version__ = "0.42.43"

View File

@@ -1,5 +1,6 @@
"""Command line interface for osxphotos """
import code
import csv
import datetime
import json
@@ -7,6 +8,8 @@ import os
import os.path
import pathlib
import pprint
import shlex
import subprocess
import sys
import time
@@ -14,6 +17,7 @@ import bitmath
import click
import osxmetadata
import yaml
from rich import pretty
import osxphotos
@@ -31,13 +35,13 @@ from ._constants import (
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
OSXPHOTOS_EXPORT_DB,
OSXPHOTOS_URL,
POST_COMMAND_CATEGORIES,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
SIDECAR_XMP,
UNICODE_FORMAT,
)
from ._version import __version__
from .cli_help import ExportCommand
from .cli_help import ExportCommand, tutorial_help
from .configoptions import (
ConfigOptions,
ConfigOptionsInvalidError,
@@ -50,9 +54,10 @@ from .fileutil import FileUtil, FileUtilNoOp
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoinfo import ExportResults
from .photokit import check_photokit_authorization, request_photokit_authorization
from .queryoptions import QueryOptions
from .utils import get_preferred_uti_extension
from .photosalbum import PhotosAlbum
from .phototemplate import PhotoTemplate, RenderOptions
from .queryoptions import QueryOptions
from .utils import get_preferred_uti_extension, load_function
# global variable to control verbose output
# set via --verbose/-V
@@ -60,7 +65,7 @@ VERBOSE = False
def verbose_(*args, **kwargs):
""" print output if verbose flag set """
"""print output if verbose flag set"""
if VERBOSE:
styled_args = []
for arg in args:
@@ -115,7 +120,7 @@ class DateTimeISO8601(click.ParamType):
return datetime.datetime.fromisoformat(value)
except Exception:
self.fail(
f"Invalid value for --{param.name}: invalid datetime format {value}. "
f"Invalid datetime format {value}. "
"Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]"
)
@@ -149,12 +154,35 @@ class TimeISO8601(click.ParamType):
return datetime.time.fromisoformat(value).replace(tzinfo=None)
except Exception:
self.fail(
f"Invalid value for --{param.name}: invalid time format {value}. "
f"Invalid time format {value}. "
"Valid format: HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]] "
"however, note that timezone will be ignored."
)
class FunctionCall(click.ParamType):
name = "FUNCTION"
def convert(self, value, param, ctx):
if "::" not in value:
self.fail(
f"Could not parse function name from '{value}'. "
"Valid format filename.py::function"
)
filename, funcname = value.split("::")
if not pathlib.Path(filename).is_file():
self.fail(f"'{filename}' does not appear to be a file")
try:
function = load_function(filename, funcname)
except Exception as e:
self.fail(f"Could not load function {funcname} from {filename}")
return (function, value)
# Click CLI object & context settings
class CLI_Obj:
def __init__(self, db=None, json=False, debug=False):
@@ -462,7 +490,7 @@ def QUERY_OPTIONS(f):
help="Search for photos with possible duplicates. osxphotos will compare signatures of photos, "
"evaluating date created, size, height, width, and edited status to find *possible* duplicates. "
"This does not compare images byte-for-byte nor compare hashes but should find photos imported multiple "
"times or duplicated within Photos."
"times or duplicated within Photos.",
),
o(
"--min-size",
@@ -620,9 +648,9 @@ def cli(ctx, db, json_, debug):
@click.option(
"--skip-raw",
is_flag=True,
help="Do not export associated raw images of a RAW+JPEG pair. "
"Note: this does not skip raw photos if the raw photo does not have an associated jpeg image "
"(e.g. the raw file was imported to Photos without a jpeg preview).",
help="Do not export associated RAW image of a RAW+JPEG pair. "
"Note: this does not skip RAW photos if the RAW photo does not have an associated JPEG image "
"(e.g. the RAW file was imported to Photos without a JPEG preview).",
)
@click.option(
"--current-name",
@@ -634,8 +662,11 @@ def cli(ctx, db, json_, debug):
@click.option(
"--convert-to-jpeg",
is_flag=True,
help="Convert all non-jpeg images (e.g. raw, HEIC, PNG, etc) "
"to JPEG upon export. Only works if your Mac has a GPU.",
help="Convert all non-JPEG images (e.g. RAW, HEIC, PNG, etc) to JPEG upon export. "
"Note: does not convert the RAW component of a RAW+JPEG pair as the associated JPEG image "
"will be exported. You can use --skip-raw to skip exporting the associated RAW image of "
"a RAW+JPEG pair. See also --jpeg-quality and --jpeg-ext. "
"Only works if your Mac has a GPU (thus may not work on virtual machines).",
)
@click.option(
"--jpeg-quality",
@@ -910,6 +941,31 @@ def cli(ctx, db, json_, debug):
"This only works if the Photos library being exported is the last-opened (default) library in Photos. "
"This feature is currently experimental. I don't know how well it will work on large export sets.",
)
@click.option(
"--post-command",
metavar="CATEGORY COMMAND",
nargs=2,
type=(click.Choice(POST_COMMAND_CATEGORIES, case_sensitive=False), str),
multiple=True,
help="Run COMMAND on exported files of category CATEGORY. CATEGORY can be one of: "
f"{', '.join(list(POST_COMMAND_CATEGORIES.keys()))}. "
"COMMAND is an osxphotos template string, for example: '--post-command exported \"echo {filepath|shell_quote} >> {export_dir}/exported.txt\"', "
"which appends the full path of all exported files to the file 'exported.txt'. "
"You can run more than one command by repeating the '--post-command' option with different arguments. "
"See Post Command below.",
)
@click.option(
"--post-function",
metavar="filename.py::function",
nargs=1,
type=FunctionCall(),
multiple=True,
help="Run function on exported files. Use this in format: --post-function filename.py::function where filename.py is a python "
"file you've created and function is the name of the function in the python file you want to call. The function will be "
"passed information about the photo that's been exported and a list of all exported files associated with the photo. "
"You can run more than one function by repeating the '--post-function' option with different arguments. "
"See Post Function below.",
)
@click.option(
"--exportdb",
metavar="EXPORTDB_FILE",
@@ -1074,6 +1130,8 @@ def export(
regex,
query_eval,
duplicate,
post_command,
post_function,
):
"""Export photos from the Photos database.
Export path DEST is required.
@@ -1229,6 +1287,8 @@ def export(
regex = cfg.regex
query_eval = cfg.query_eval
duplicate = cfg.duplicate
post_command = cfg.post_command
post_function = cfg.post_function
# config file might have changed verbose
VERBOSE = bool(verbose)
@@ -1629,6 +1689,30 @@ def export(
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
retry=retry,
export_dir=dest,
)
if post_function:
for function in post_function:
# post function is tuple of (function, filename.py::function_name)
verbose_(f"Calling post-function {function[1]}")
if not dry_run:
try:
function[0](p, export_results, verbose_)
except Exception as e:
click.secho(
f"Error running post-function {function[1]}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
run_post_command(
photo=p,
post_command=post_command,
export_results=export_results,
export_dir=dest,
dry_run=dry_run,
exiftool_path=exiftool_path,
)
if album_export and export_results.exported:
@@ -1697,13 +1781,18 @@ def export(
exiftool_merge_keywords=exiftool_merge_keywords,
finder_tag_template=finder_tag_template,
strip=strip,
export_dir=dest,
)
results.xattr_written.extend(tags_written)
results.xattr_skipped.extend(tags_skipped)
if xattr_template:
xattr_written, xattr_skipped = write_extended_attributes(
p, photo_files, xattr_template, strip=strip
p,
photo_files,
xattr_template,
strip=strip,
export_dir=dest,
)
results.xattr_written.extend(xattr_written)
results.xattr_skipped.extend(xattr_skipped)
@@ -1777,7 +1866,7 @@ def export(
@click.argument("topic", default=None, required=False, nargs=1)
@click.pass_context
def help(ctx, topic, **kw):
""" Print help; for help on commands: help <command>. """
"""Print help; for help on commands: help <command>."""
if topic is None:
click.echo(ctx.parent.get_help())
elif topic in cli.commands:
@@ -2062,7 +2151,7 @@ def query(
max_size=max_size,
query_eval=query_eval,
regex=regex,
duplicate=duplicate
duplicate=duplicate,
)
try:
@@ -2091,7 +2180,7 @@ def query(
album_query.add_list(photos)
except Exception as e:
click.secho(
f"Error adding photos to album {add_to_album}",
f"Error adding photos to album {add_to_album}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
@@ -2245,6 +2334,7 @@ def export_photo(
jpeg_ext=None,
replace_keywords=False,
retry=0,
export_dir=None,
):
"""Helper function for export that does the actual export
@@ -2285,6 +2375,7 @@ def export_photo(
jpeg_ext: if not None, specify the extension to use for all JPEG images on export
replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
retry: retry up to retry # of times if there's an error
export_dir: top-level export directory for {export_dir} template
Returns:
list of path(s) of exported photo or None if photo was missing
@@ -2348,9 +2439,8 @@ def export_photo(
rendered_suffix = ""
if original_suffix:
try:
rendered_suffix, unmatched = photo.render_template(
original_suffix, filename=True, strip=strip
)
options = RenderOptions(filename=True, strip=strip, export_dir=dest)
rendered_suffix, unmatched = photo.render_template(original_suffix, options)
except ValueError as e:
raise click.BadOptionUsage(
"original_suffix",
@@ -2444,6 +2534,7 @@ def export_photo(
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
retry=retry,
export_dir=export_dir,
)
if export_edited and photo.hasadjustments:
@@ -2477,8 +2568,13 @@ def export_photo(
if edited_suffix:
try:
options = RenderOptions(
filename=True,
strip=strip,
export_dir=dest,
)
rendered_suffix, unmatched = photo.render_template(
edited_suffix, filename=True, strip=strip
edited_suffix, options
)
except ValueError as e:
raise click.BadOptionUsage(
@@ -2543,6 +2639,7 @@ def export_photo(
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
retry=retry,
export_dir=export_dir,
)
return results
@@ -2587,8 +2684,9 @@ def export_photo_with_template(
jpeg_ext,
replace_keywords,
retry,
export_dir,
):
""" Evaluate directory template then export photo to each directory """
"""Evaluate directory template then export photo to each directory"""
results = ExportResults()
@@ -2637,6 +2735,8 @@ def export_photo_with_template(
results.missing.append(str(pathlib.Path(dest_path) / filename))
continue
render_options = RenderOptions(export_dir=export_dir)
tries = 0
while tries <= retry:
tries += 1
@@ -2674,6 +2774,7 @@ def export_photo_with_template(
exiftool_flags=exiftool_option,
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
render_options=render_options,
)
for warning_ in export_results.exiftool_warning:
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
@@ -2735,7 +2836,11 @@ def export_photo_with_template(
def get_filenames_from_template(
photo, filename_template, original_name, strip=False, edited=False
photo,
filename_template,
original_name,
strip=False,
edited=False,
):
"""get list of export filenames for a photo
@@ -2755,13 +2860,13 @@ def get_filenames_from_template(
if filename_template:
photo_ext = pathlib.Path(photo.original_filename).suffix
try:
filenames, unmatched = photo.render_template(
filename_template,
options = RenderOptions(
path_sep="_",
filename=True,
strip=strip,
edited=edited,
edited_version=edited,
)
filenames, unmatched = photo.render_template(filename_template, options)
except ValueError as e:
raise click.BadOptionUsage(
"filename_template", f"Invalid template '{filename_template}': {e}"
@@ -2815,9 +2920,8 @@ def get_dirnames_from_template(
elif directory:
# got a directory template, render it and check results are valid
try:
dirnames, unmatched = photo.render_template(
directory, dirname=True, strip=strip, edited=edited
)
options = RenderOptions(dirname=True, strip=strip, edited_version=edited)
dirnames, unmatched = photo.render_template(directory, options)
except ValueError as e:
raise click.BadOptionUsage(
"directory", f"Invalid template '{directory}': {e}"
@@ -3098,6 +3202,7 @@ def write_finder_tags(
exiftool_merge_keywords=None,
finder_tag_template=None,
strip=False,
export_dir=None,
):
"""Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written
@@ -3110,6 +3215,7 @@ def write_finder_tags(
person_keyword: if True, use person in image as keywords
exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords
finder_tag_template: list of templates to evaluate for determining Finder tags
export_dir: value to use for {export_dir} template
Returns:
(list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating)
@@ -3136,12 +3242,13 @@ def write_finder_tags(
rendered_tags = []
for template_str in finder_tag_template:
try:
rendered, unmatched = photo.render_template(
template_str,
options = RenderOptions(
none_str=_OSXPHOTOS_NONE_SENTINEL,
path_sep="/",
strip=strip,
export_dir=export_dir,
)
rendered, unmatched = photo.render_template(template_str, options)
except ValueError as e:
raise click.BadOptionUsage(
"finder_tag_template",
@@ -3178,13 +3285,16 @@ def write_finder_tags(
return (written, skipped)
def write_extended_attributes(photo, files, xattr_template, strip=False):
""" Writes extended attributes to exported files
def write_extended_attributes(
photo, files, xattr_template, strip=False, export_dir=None
):
"""Writes extended attributes to exported files
Args:
photo: a PhotoInfo object
xattr_template: list of tuples: (attribute name, attribute template)
strip: xattr_template: list of tuples: (attribute name, attribute template)
export_dir: value to use for {export_dir} template
Returns:
tuple(list of file paths that were updated with new attributes, list of file paths skipped because attributes didn't need updating)
"""
@@ -3192,12 +3302,13 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
attributes = {}
for xattr, template_str in xattr_template:
try:
rendered, unmatched = photo.render_template(
template_str,
options = RenderOptions(
none_str=_OSXPHOTOS_NONE_SENTINEL,
path_sep="/",
strip=strip,
export_dir=export_dir,
)
rendered, unmatched = photo.render_template(template_str, options)
except ValueError as e:
raise click.BadOptionUsage(
"xattr_template",
@@ -3246,6 +3357,46 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
return list(written), [f for f in skipped if f not in written]
def run_post_command(
photo, post_command, export_results, export_dir, dry_run, exiftool_path
):
# todo: pass in RenderOptions from export? (e.g. so it contains strip, etc?)
# todo: need a shell_quote template type:
# {shell_quote,{filepath}/foo/bar}
# that quotes everything in the default value
for category, command_template in post_command:
files = getattr(export_results, category)
for f in files:
# some categories, like error, return a tuple of (file, error str)
if isinstance(f, tuple):
f = f[0]
render_options = RenderOptions(export_dir=export_dir, filepath=f)
template = PhotoTemplate(photo, exiftool_path=exiftool_path)
command, _ = template.render(command_template, options=render_options)
command = command[0] if command else None
if command:
verbose_(f'Running command: "{command}"')
if not dry_run:
args = shlex.split(command)
cwd = pathlib.Path(f).parent
run_error = None
run_results = None
try:
run_results = subprocess.run(command, shell=True, cwd=cwd)
except Exception as e:
run_error = e
finally:
run_error = run_error or run_results.returncode
if run_error:
click.echo(
click.style(
f'Error running command "{command}": {run_error}',
fg=CLI_COLOR_ERROR,
),
err=True,
)
@cli.command(hidden=True)
@DB_OPTION
@DB_ARGUMENT
@@ -3266,7 +3417,7 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
@click.pass_obj
@click.pass_context
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
""" Print out debug info """
"""Print out debug info"""
global VERBOSE
VERBOSE = bool(verbose)
@@ -3337,7 +3488,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
@click.pass_obj
@click.pass_context
def keywords(ctx, cli_obj, db, json_, photos_library):
""" Print out keywords found in the Photos library. """
"""Print out keywords found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
@@ -3363,7 +3514,7 @@ def keywords(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def albums(ctx, cli_obj, db, json_, photos_library):
""" Print out albums found in the Photos library. """
"""Print out albums found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
@@ -3392,7 +3543,7 @@ def albums(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def persons(ctx, cli_obj, db, json_, photos_library):
""" Print out persons (faces) found in the Photos library. """
"""Print out persons (faces) found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
@@ -3418,7 +3569,7 @@ def persons(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def labels(ctx, cli_obj, db, json_, photos_library):
""" Print out image classification labels found in the Photos library. """
"""Print out image classification labels found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
@@ -3444,7 +3595,7 @@ def labels(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def info(ctx, cli_obj, db, json_, photos_library):
""" Print out descriptive info of the Photos library database. """
"""Print out descriptive info of the Photos library database."""
db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None:
@@ -3504,7 +3655,7 @@ def info(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def places(ctx, cli_obj, db, json_, photos_library):
""" Print out places found in the Photos library. """
"""Print out places found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
@@ -3555,7 +3706,7 @@ def places(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
""" Print list of all photos & associated info from the Photos library. """
"""Print list of all photos & associated info from the Photos library."""
db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None:
@@ -3586,7 +3737,7 @@ def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
@click.pass_obj
@click.pass_context
def list_libraries(ctx, cli_obj, json_):
""" Print list of Photos libraries found on the system. """
"""Print list of Photos libraries found on the system."""
# implemented in _list_libraries so it can be called by other CLI functions
# without errors due to passing ctx and cli_obj
@@ -3633,7 +3784,7 @@ def _list_libraries(json_=False, error=True):
@click.pass_obj
@click.pass_context
def about(ctx, cli_obj):
""" Print information about osxphotos including license. """
"""Print information about osxphotos including license."""
license = """
MIT License
@@ -3661,3 +3812,87 @@ SOFTWARE.
click.echo("")
click.echo(f"Source code available at: {OSXPHOTOS_URL}")
click.echo(license)
@cli.command(name="tutorial")
@click.argument(
"WIDTH",
nargs=-1,
type=click.INT,
)
@click.pass_obj
@click.pass_context
def tutorial(ctx, cli_obj, width):
"""Display osxphotos tutorial."""
width = width[0] if width else 100
click.echo_via_pager(tutorial_help(width=width))
def _show_photo(photo):
"""open image with default image viewer
Note: This is for debugging only -- it will actually open any filetype which could
be very, very bad.
Args:
photo: PhotoInfo object or a path to a photo on disk
"""
photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo
if not os.path.isfile(photopath):
return f"'{photopath}' does not appear to be a valid photo path"
os.system(f"open '{photopath}'")
def _load_photos_db(dbpath):
print("Loading database")
tic = time.perf_counter()
photosdb = osxphotos.PhotosDB(dbfile=dbpath, verbose=print)
toc = time.perf_counter()
tictoc = toc - tic
print(f"Done: took {tictoc:0.2f} seconds")
return photosdb
def _get_photos(photosdb):
photos = photosdb.photos(images=True, movies=True)
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
return photos
@cli.command()
@DB_OPTION
@click.pass_obj
@click.pass_context
def repl(ctx, cli_obj, db):
"""Run interactive osxphotos shell"""
pretty.install()
print(f"python version: {sys.version}")
print(f"osxphotos version: {osxphotos._version.__version__}")
db = db or get_photos_db()
photosdb = _load_photos_db(db)
print("Getting photos")
tic = time.perf_counter()
photos = _get_photos(photosdb)
toc = time.perf_counter()
tictoc = toc - tic
# shortcut for helper functions
get_photo = photosdb.get_photo
show = _show_photo
print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds")
print("The following variables are defined:")
print(f"- photosdb: PhotosDB() instance for {photosdb.library_path}")
print(
f"- photos: list of PhotoInfo objects for all photos in photosdb, including those in the trash"
)
print(f"\nThe following functions may be helpful:")
print(f"- get_photo(uuid): return a PhotoInfo object for photo with uuid")
print(f"- show(photo): open a photo object in the default viewer")
print(
f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)"
)
print(f"- quit(): exit this interactive shell\n")
code.interact(banner="", local=locals())

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

@@ -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

@@ -15,6 +15,7 @@
# TODO: should this be its own PhotoExporter class?
# TODO: the various sidecar_json, sidecar_xmp, etc args should all be collapsed to a sidecar param using a bit mask
import dataclasses
import glob
import hashlib
import json
@@ -24,6 +25,7 @@ import pathlib
import re
import tempfile
from collections import namedtuple # pylint: disable=syntax-error
from typing import Optional
import photoscript
from mako.template import Template
@@ -36,10 +38,10 @@ from .._constants import (
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
_XMP_TEMPLATE_NAME_BETA,
LIVE_VIDEO_EXTENSIONS,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
SIDECAR_XMP,
LIVE_VIDEO_EXTENSIONS,
)
from .._version import __version__
from ..datetime_utils import datetime_tz_to_utc
@@ -52,6 +54,7 @@ from ..photokit import (
PhotoKitFetchFailed,
PhotoLibrary,
)
from ..phototemplate import RenderOptions
from ..utils import findfiles, get_preferred_uti_extension, lineno, noop
# retry if use_photos_export fails the first time (which sometimes it does)
@@ -218,6 +221,7 @@ def _export_photo_uuid_applescript(
timeout=120,
burst=False,
dry_run=False,
overwrite=False,
):
"""Export photo to dest path using applescript to control Photos
If photo is a live photo, exports both the photo and associated .mov file
@@ -297,6 +301,8 @@ def _export_photo_uuid_applescript(
# use the name Photos provided
dest_new = dest / path.name
if not dry_run:
if overwrite and dest_new.exists():
FileUtil.unlink(dest_new)
FileUtil.copy(str(path), str(dest_new))
exported_paths.append(str(dest_new))
return exported_paths
@@ -390,6 +396,7 @@ def export(
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
render_options: Optional[RenderOptions] = None,
):
"""export photo
dest: must be valid destination path (or exception raised)
@@ -427,6 +434,7 @@ def export(
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer
Returns: list of photos exported
"""
@@ -458,6 +466,7 @@ def export(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
render_options = render_options,
)
return results.exported
@@ -500,6 +509,7 @@ def export2(
persons=True,
location=True,
replace_keywords=False,
render_options: Optional[RenderOptions] = None
):
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -555,6 +565,7 @@ def export2(
persons: if True, include persons in exported metadata
location: if True, include location in exported metadata
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
render_options: optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
Returns: ExportResults class
ExportResults has attributes:
@@ -596,6 +607,8 @@ def export2(
if verbose is None:
verbose = self._verbose
self._render_options = render_options or RenderOptions()
# suffix to add to edited files
# e.g. name will be filename_edited.jpg
edited_identifier = "_edited"
@@ -679,6 +692,7 @@ def export2(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
)
self._render_options.filepath = str(dest)
all_results = ExportResults()
if not use_photos_export:
# find the source file on disk and export
@@ -1161,7 +1175,10 @@ def _export_photo_with_photos_export(
else:
try:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT, overwrite=overwrite
dest.parent,
dest.name,
version=PHOTOS_VERSION_CURRENT,
overwrite=overwrite,
)
all_results.exported.extend(exported)
except Exception as e:
@@ -1180,6 +1197,7 @@ def _export_photo_with_photos_export(
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
overwrite=overwrite,
)
all_results.exported.extend(exported)
except ExportError as e:
@@ -1205,7 +1223,10 @@ def _export_photo_with_photos_export(
if not dry_run:
try:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL, overwrite=overwrite
dest.parent,
dest.name,
version=PHOTOS_VERSION_ORIGINAL,
overwrite=overwrite,
)
all_results.exported.extend(exported)
except Exception as e:
@@ -1227,6 +1248,7 @@ def _export_photo_with_photos_export(
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
overwrite=overwrite,
)
all_results.exported.extend(exported)
except ExportError as e:
@@ -1586,9 +1608,8 @@ def _exiftool_dict(
)
if description_template is not None:
rendered = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
rendered = self.render_template(description_template, options)[0]
description = " ".join(rendered) if rendered else ""
exif["EXIF:ImageDescription"] = description
exif["XMP:Description"] = description
@@ -1626,10 +1647,9 @@ def _exiftool_dict(
if keyword_template:
rendered_keywords = []
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
for template_str in keyword_template:
rendered, unmatched = self.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
rendered, unmatched = self.render_template(template_str, options)
if unmatched:
logging.warning(
f"Unmatched template substitution for template: {template_str} {unmatched}"
@@ -1905,9 +1925,8 @@ def _xmp_sidecar(
extension = extension.suffix[1:] if extension.suffix else None
if description_template is not None:
rendered = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
rendered = self.render_template(description_template, options)[0]
description = " ".join(rendered) if rendered else ""
else:
description = self.description if self.description is not None else ""
@@ -1939,10 +1958,9 @@ def _xmp_sidecar(
if keyword_template:
rendered_keywords = []
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
for template_str in keyword_template:
rendered, unmatched = self.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
rendered, unmatched = self.render_template(template_str, options)
if unmatched:
logging.warning(
f"Unmatched template substitution for template: {template_str} {unmatched}"

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,7 +34,7 @@ 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
@@ -46,31 +46,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
@@ -78,6 +78,9 @@ 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"""
@@ -1015,48 +1018,20 @@ class PhotoInfo:
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):
@@ -1269,3 +1244,13 @@ class PhotoInfo:
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

@@ -29,7 +29,12 @@ class PhotosAlbum:
)
def add_list(self, photo_list: List[PhotoInfo]):
photos = [photoscript.Photo(p.uuid) for p in photo_list]
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)

View File

@@ -44,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,
@@ -3207,11 +3208,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)

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

@@ -5,6 +5,7 @@ import locale
import os
import pathlib
import sys
import shlex
from textx import TextXSyntaxError, metamodel_from_file
@@ -14,6 +15,10 @@ 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 dataclasses import dataclass
from typing import Optional
# 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(
@@ -1131,17 +1191,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(
@@ -1166,9 +1227,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 +1236,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 +1270,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 +1295,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

@@ -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!):

View File

@@ -94,6 +94,7 @@ setup(
"textx==2.3.0",
"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

@@ -8,6 +8,7 @@ class PhotoInfoMock(PhotoInfo):
self._photo = photo
self._db = photo._db
self._info = photo._info
self._uuid = photo.uuid
for kw in kwargs:
if hasattr(photo, kw):

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,18 @@
""" Test template.py """
import os
import re
import pytest
from photoinfo_mock import PhotoInfoMock
import osxphotos
from osxphotos.exiftool import get_exiftool_path
from photoinfo_mock import PhotoInfoMock
from osxphotos.phototemplate import (
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
PhotoTemplate,
RenderOptions,
)
try:
exiftool = get_exiftool_path()
@@ -57,6 +66,7 @@ TEMPLATE_VALUES_MULTI_KEYWORDS = {
"{keyword|lower}": ["flowers", "wedding"],
"{keyword|titlecase}": ["Flowers", "Wedding"],
"{keyword|capitalize}": ["Flowers", "Wedding"],
"{keyword|shell_quote}": ["flowers", "wedding"],
"{+keyword}": ["flowerswedding"],
"{+keyword|titlecase}": ["Flowerswedding"],
"{+keyword|capitalize}": ["Flowerswedding"],
@@ -72,6 +82,7 @@ TEMPLATE_VALUES_TITLE = {
"{title|titlecase}": ["Tulips Tied Together At A Flower Shop"],
"{title|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"],
"{title|titlecase|lower|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"],
"{title|titlecase|lower|upper|shell_quote}": ["'TULIPS TIED TOGETHER AT A FLOWER SHOP'"],
"{title|upper|titlecase}": ["Tulips Tied Together At A Flower Shop"],
"{title|capitalize}": ["Tulips tied together at a flower shop"],
"{title[ ,_]}": ["Tulips_tied_together_at_a_flower_shop"],
@@ -81,6 +92,7 @@ TEMPLATE_VALUES_TITLE = {
"{+title}": ["Tulips tied together at a flower shop"],
"{,+title}": ["Tulips tied together at a flower shop"],
"{, +title}": ["Tulips tied together at a flower shop"],
"{title|shell_quote}": ["'Tulips tied together at a flower shop'"],
}
# Boolean type values that render to True
@@ -357,8 +369,6 @@ def photosdb_cloud():
def test_lookup(photosdb_places):
"""Test that a lookup is returned for every possible value"""
import re
from osxphotos.phototemplate import TEMPLATE_SUBSTITUTIONS, PhotoTemplate
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = PhotoTemplate(photo)
@@ -371,13 +381,6 @@ def test_lookup(photosdb_places):
def test_lookup_multi(photosdb_places):
"""Test that a lookup is returned for every possible value"""
import os
import re
from osxphotos.phototemplate import (
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
PhotoTemplate,
)
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = PhotoTemplate(photo)
@@ -385,7 +388,7 @@ def test_lookup_multi(photosdb_places):
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
if subst in ["{exiftool}", "{photo}", "{function}"]:
continue
lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep)
lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep, default=[])
assert isinstance(lookup, list)
@@ -464,7 +467,6 @@ def test_subst_locale_2(photosdb_places):
def test_subst_default_val(photosdb_places):
"""Test substitution with default value specified"""
import locale
import osxphotos
locale.setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
@@ -526,8 +528,6 @@ def test_subst_unknown_val_with_default(photosdb_places):
def test_subst_multi_1_1_2(photosdb):
"""Test that substitutions are correct"""
# one album, one keyword, two persons
import osxphotos
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
template = "{created.year}/{album}/{keyword}/{person}"
@@ -608,7 +608,6 @@ def test_subst_multi_0_2_0_default_val(photosdb):
def test_subst_multi_0_2_0_default_val_unknown_val(photosdb):
"""Test that substitutions are correct"""
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
import osxphotos
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
@@ -726,7 +725,6 @@ def test_subst_multi_folder_albums_3(photosdb_14_6):
def test_subst_multi_folder_albums_3_path_sep(photosdb_14_6):
"""Test substitutions for folder_album on < Photos 5 with custom PATH_SEP"""
import osxphotos
# photo in an album in a folder
photo = photosdb_14_6.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
@@ -739,7 +737,6 @@ def test_subst_multi_folder_albums_3_path_sep(photosdb_14_6):
def test_subst_multi_folder_albums_4_path_sep_lower(photosdb_14_6):
"""Test substitutions for folder_album on < Photos 5 with custom PATH_SEP"""
import osxphotos
# photo in an album in a folder
photo = photosdb_14_6.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
@@ -753,7 +750,6 @@ def test_subst_multi_folder_albums_4_path_sep_lower(photosdb_14_6):
def test_subst_strftime(photosdb_places):
"""Test that strftime substitutions are correct"""
import locale
import osxphotos
locale.setlocale(locale.LC_ALL, "en_US")
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
@@ -773,7 +769,8 @@ def test_subst_expand_inplace_1(photosdb):
template = "{person}"
expected = ["Katie,Suzy"]
rendered, unknown = photo.render_template(template, expand_inplace=True)
options = RenderOptions(expand_inplace=True)
rendered, unknown = photo.render_template(template, options)
assert sorted(rendered) == sorted(expected)
@@ -784,7 +781,8 @@ def test_subst_expand_inplace_2(photosdb):
template = "{person}-{keyword}"
expected = ["Katie,Suzy-Kids"]
rendered, unknown = photo.render_template(template, expand_inplace=True)
options = RenderOptions(expand_inplace=True)
rendered, unknown = photo.render_template(template, options)
assert sorted(rendered) == sorted(expected)
@@ -795,9 +793,9 @@ def test_subst_expand_inplace_3(photosdb):
template = "{person}-{keyword}"
expected = ["Katie; Suzy-Kids"]
rendered, unknown = photo.render_template(
template, expand_inplace=True, inplace_sep="; "
)
options = RenderOptions(expand_inplace=True, inplace_sep="; ")
rendered, unknown = photo.render_template(template, options)
assert sorted(rendered) == sorted(expected)
@@ -837,8 +835,9 @@ def test_bool_values(photosdb_cloud):
if uuid is not None:
photo = photosdb_cloud.get_photo(uuid)
edited = field == "edited_version"
options = RenderOptions(edited_version=edited)
rendered, _ = photo.render_template(
"{" + f"{field}" + "?True,False}", edited=edited
"{" + f"{field}" + "?True,False}", options
)
assert rendered[0] == "True"
@@ -1019,3 +1018,55 @@ def test_function_filter_bad(photosdb):
rendered, _ = photo.render_template(
"{photo.original_filename|function:tests/template_filter.py::foobar}"
)
def test_export_dir():
"""Test {export_dir} template"""
from osxphotos.photoinfo import PhotoInfoNone
from osxphotos.phototemplate import PhotoTemplate
options = RenderOptions(export_dir="/foo/bar")
template = PhotoTemplate(PhotoInfoNone())
rendered, _ = template.render("{export_dir}", options)
assert rendered[0] == "/foo/bar"
rendered, _ = template.render("{export_dir.name}", options)
assert rendered[0] == "bar"
rendered, _ = template.render("{export_dir.parent}", options)
assert rendered[0] == "/foo"
rendered, _ = template.render("{export_dir.stem}", options)
assert rendered[0] == "bar"
rendered, _ = template.render("{export_dir.suffix}", options)
assert rendered[0] == ""
with pytest.raises(ValueError):
rendered, _ = template.render("{export_dir.foo}", options)
def test_filepath():
"""Test {filepath} template"""
from osxphotos.photoinfo import PhotoInfoNone
from osxphotos.phototemplate import PhotoTemplate
options = RenderOptions(filepath="/foo/bar.jpeg")
template = PhotoTemplate(PhotoInfoNone())
rendered, _ = template.render("{filepath}", options)
assert rendered[0] == "/foo/bar.jpeg"
rendered, _ = template.render("{filepath.name}", options)
assert rendered[0] == "bar.jpeg"
rendered, _ = template.render("{filepath.parent}", options)
assert rendered[0] == "/foo"
rendered, _ = template.render("{filepath.stem}", options)
assert rendered[0] == "bar"
rendered, _ = template.render("{filepath.suffix}", options)
assert rendered[0] == ".jpeg"
with pytest.raises(ValueError):
rendered, _ = template.render("{filepath.foo}", options)

View File

@@ -2,6 +2,9 @@ import datetime
import pytest
import osxphotos
from osxphotos.phototemplate import RenderOptions
PHOTOS_DB_PLACES = (
"./tests/Test-Places-Catalina-10_15_1.photoslibrary/database/photos.db"
)
@@ -35,35 +38,40 @@ TODAY_VALUES = {
}
def test_subst_today():
""" Test that substitutions are correct for {today.x}"""
@pytest.fixture(scope="module")
def photosdb():
return osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
def test_subst_today(photosdb):
"""Test that substitutions are correct for {today.x}"""
import locale
import osxphotos
locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
photo_template = osxphotos.PhotoTemplate(photo)
photo_template.today = DATETIME_TODAY
options = RenderOptions()
for template in TODAY_VALUES:
rendered, _ = photo_template.render(template)
rendered, _ = photo_template.render(template, options)
assert rendered[0] == TODAY_VALUES[template]
def test_subst_strftime_today():
""" Test that strftime substitutions are correct for {today.strftime}"""
def test_subst_strftime_today(photosdb):
"""Test that strftime substitutions are correct for {today.strftime}"""
import locale
import osxphotos
locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
photo_template = osxphotos.PhotoTemplate(photo)
photo_template.today = DATETIME_TODAY
rendered, unmatched = photo_template.render("{today.strftime,%Y-%m-%d-%H%M%S}")
options = RenderOptions()
rendered, unmatched = photo_template.render(
"{today.strftime,%Y-%m-%d-%H%M%S}", options
)
assert rendered[0] == "2020-06-21-130000"
rendered, unmatched = photo.render_template("{today.strftime}")

View File

@@ -22,7 +22,7 @@ from osxphotos.phototemplate import (
)
TEMPLATE_HELP = "osxphotos/phototemplate.md"
TUTORIAL_HELP = "docsrc/source/tutorial.md"
TUTORIAL_HELP = "osxphotos/tutorial.md"
USAGE_START = (
"<!-- OSXPHOTOS-EXPORT-USAGE:START - Do not remove or modify this section -->"