Compare commits

...

61 Commits

Author SHA1 Message Date
Rhet Turnbull
235dea329c Implemented #605, refactor out export2 2022-01-29 09:38:52 -08:00
Rhet Turnbull
5afdf6fc20 Fix for #564, --preview with --download-missing 2022-01-29 08:27:43 -08:00
Rhet Turnbull
385059e973 Updated CHANGELOG.md [skip ci] 2022-01-28 23:32:46 -08:00
Rhet Turnbull
62aed02070 Updated docs [skip ci] 2022-01-28 23:20:27 -08:00
Rhet Turnbull
6843b8661d Refactored photoexporter for performance, #591 2022-01-28 23:15:02 -08:00
Rhet Turnbull
9da747ea9d Refactoring to support #591 2022-01-27 21:37:12 -08:00
Rhet Turnbull
22964afc69 Performance improvements and refactoring, #462, partial for #591 2022-01-27 06:28:12 -08:00
Rhet Turnbull
3bc53fd92b Performance improvements, partial for #591 2022-01-25 20:37:58 -08:00
Rhet Turnbull
bd31120569 Version bump 2022-01-24 06:28:58 -08:00
Rhet Turnbull
6af124e4d3 Removed exportdb requirement from PhotoTemplate 2022-01-24 06:20:34 -08:00
Rhet Turnbull
b3b1d8f193 Updated CHANGELOG.md [skip ci] 2022-01-23 22:01:54 -08:00
Rhet Turnbull
785580115b Added query options to repl, #597 2022-01-23 21:57:51 -08:00
Rhet Turnbull
b4bd04c146 Added run command, #598 2022-01-23 18:38:16 -08:00
Rhet Turnbull
e88c6b8a59 Bug fix for get_photos_library_version 2022-01-23 18:06:19 -08:00
Rhet Turnbull
74868238f3 Performance improvements, added --profile 2022-01-23 17:14:55 -08:00
Xiaoliang Wu
61a300250d creat unit test for __all__ (#599) 2022-01-23 16:40:20 -08:00
Rhet Turnbull
d8dbc0866f Updated CHANGELOG.md [skip ci] 2022-01-22 14:43:11 -08:00
Rhet Turnbull
586d96ae74 Updated docs [skip ci] 2022-01-22 14:40:38 -08:00
Rhet Turnbull
81032a5745 Added tutorial.md, #596 2022-01-22 14:38:22 -08:00
Rhet Turnbull
c2d726beaf More refactoring of export code, #462 2022-01-22 10:44:29 -08:00
Rhet Turnbull
3bafdf7bfd Blackified files 2022-01-22 09:25:08 -08:00
Xiaoliang Wu
edcc7ea34f Create __all__ for all python files (#589)
* add __all__ to files "adjustmentsinfo.py" and "albuminfo.py"

* add __all__ to file "cli.py"

* add __all__ to all files that misses except files with prefix "_"
2022-01-22 09:22:47 -08:00
Rhet Turnbull
6261a7b5c9 More refactoring of export code, #462 2022-01-22 09:03:01 -08:00
Rhet Turnbull
881832c92d Removed warning from test 2022-01-18 08:08:58 -08:00
Xiaoliang Wu
47d4dc7ef0 Create __all__ for the file cli.py (#587)
* add __all__ to files "adjustmentsinfo.py" and "albuminfo.py"

* add __all__ to file "cli.py"
2022-01-17 22:03:48 -08:00
allcontributors[bot]
10ce81bf98 docs: add xwu64 as a contributor for code (#585)
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-01-15 22:49:56 -08:00
Xiaoliang Wu
98b3d9f81e add __all__ to files "adjustmentsinfo.py" and "albuminfo.py" (#584) 2022-01-15 22:49:03 -08:00
Rhet Turnbull
81cbb7dcc4 Refactored docstrings, #462 2022-01-15 17:45:38 -08:00
Rhet Turnbull
9517876bd0 Added ExportOptions to photoexporter.py, #462 2022-01-15 16:12:27 -08:00
Rhet Turnbull
231d132792 More refactoring of export code, #462 2022-01-14 21:57:27 -08:00
Rhet Turnbull
9ada5dfea4 More refactoring of export code, #462 2022-01-14 19:48:36 -08:00
Rhet Turnbull
476c94407f More refactoring of export code, #462 2022-01-14 18:31:50 -08:00
Rhet Turnbull
458da0e9b2 Refactored photoexporter sidecar writing, #462 2022-01-14 17:43:40 -08:00
Rhet Turnbull
66673012ac Updated tested versions 2022-01-14 17:10:28 -08:00
Rhet Turnbull
46f8b6dc5a Updated README.md 2022-01-14 15:05:15 -08:00
Rhet Turnbull
ee81e69ece Added dev tools 2022-01-14 15:02:33 -08:00
Rhet Turnbull
3927f05267 Added diff command 2022-01-09 09:35:42 -08:00
Rhet Turnbull
a010ab5a29 Added uuid command 2022-01-09 07:58:14 -08:00
Rhet Turnbull
c49bebd412 Updated CHANGELOG.md [skip ci] 2022-01-09 07:49:09 -08:00
Rhet Turnbull
5a8105f5a0 Fix for #575, database version 5001 2022-01-09 07:44:38 -08:00
allcontributors[bot]
df66adeef6 docs: add ahti123 as a contributor for code, bug (#578)
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-01-09 07:29:15 -08:00
Ahti Liin
4e2367c868 changing photos_5 version constant to satisfy version 5001 (#577)
Co-authored-by: Ahti Liin <ahti@mooncascade.com>
2022-01-09 07:28:22 -08:00
Rhet Turnbull
53c701cc0e Added sqlgrep 2022-01-08 17:41:06 -08:00
Rhet Turnbull
92fced75da Added test for #576 2022-01-08 17:39:49 -08:00
Rhet Turnbull
4dd838b8bc Added grep command to CLI 2022-01-08 17:14:36 -08:00
Rhet Turnbull
0a3c375943 Updated CHANGELOG.md [skip ci] 2022-01-08 15:23:41 -08:00
Rhet Turnbull
64a0760a47 Updated docs [skip ci] 2022-01-08 15:23:14 -08:00
Rhet Turnbull
2e7db47806 Fix for #576, error exporting edited live photos 2022-01-08 15:15:28 -08:00
Rhet Turnbull
d2d56a7f71 Fix for burst images with pick type = 0, partial fix for #571 2022-01-06 22:46:16 -08:00
Rhet Turnbull
b4897ff1b5 version bump [skip ci] 2022-01-06 22:16:12 -08:00
Rhet Turnbull
661a573bf5 Fix for #570 2022-01-06 22:13:25 -08:00
Rhet Turnbull
0c9bd87602 More refactoring of export code, #462 2022-01-06 05:40:47 -08:00
Rhet Turnbull
896d888710 Updated CHANGELOG.md [skip ci] 2022-01-04 06:35:23 -08:00
Rhet Turnbull
76aee7f189 Export DB can now reside outside export directory, #568 2022-01-04 06:28:59 -08:00
Rhet Turnbull
147b30f973 More refactoring of export code, #462 2022-01-02 22:38:22 -08:00
Rhet Turnbull
a73dc72558 Refactored photoinfo, photoexporter; #462 2022-01-02 09:06:04 -08:00
Rhet Turnbull
c99cf5518d Updated CHANGELOG.md [skip ci] 2021-12-31 20:50:42 -08:00
Rhet Turnbull
1391675a3a Updated tests and docs 2021-12-31 20:31:15 -08:00
Rhet Turnbull
a3b2784f31 ImageConverter now uses generic context; #562 2021-12-31 17:34:41 -08:00
Rhet Turnbull
cbe79ee98c Updated docs [skip ci] 2021-12-31 09:45:42 -08:00
Rhet Turnbull
eb7a2988bf Updated CHANGELOG.md [skip ci] 2021-12-31 09:45:19 -08:00
72 changed files with 4460 additions and 3431 deletions

View File

@@ -293,7 +293,8 @@
"avatar_url": "https://avatars.githubusercontent.com/u/6291?v=4", "avatar_url": "https://avatars.githubusercontent.com/u/6291?v=4",
"profile": "https://hyfen.net", "profile": "https://hyfen.net",
"contributions": [ "contributions": [
"doc", "code" "doc",
"code"
] ]
}, },
{ {
@@ -304,6 +305,25 @@
"contributions": [ "contributions": [
"bug" "bug"
] ]
},
{
"login": "ahti123",
"name": "Ahti Liin",
"avatar_url": "https://avatars.githubusercontent.com/u/22232632?v=4",
"profile": "https://github.com/ahti123",
"contributions": [
"code",
"bug"
]
},
{
"login": "xwu64",
"name": "Xiaoliang Wu",
"avatar_url": "https://avatars.githubusercontent.com/u/10580396?v=4",
"profile": "https://github.com/xwu64",
"contributions": [
"code"
]
} }
], ],
"contributorsPerLine": 7, "contributorsPerLine": 7,

View File

@@ -4,6 +4,111 @@ 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). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.45.0](https://github.com/RhetTbull/osxphotos/compare/v0.44.13...v0.45.0)
> 28 January 2022
- Performance improvements and refactoring, #462, partial for #591 [`22964af`](https://github.com/RhetTbull/osxphotos/commit/22964afc6988166218413125d7a62348bb858a83)
- Refactored photoexporter for performance, #591 [`6843b86`](https://github.com/RhetTbull/osxphotos/commit/6843b8661d41d42368794c77304fc07194e7af18)
- Performance improvements, partial for #591 [`3bc53fd`](https://github.com/RhetTbull/osxphotos/commit/3bc53fd92b3222c6959e7aa12310811db41b83fe)
#### [v0.44.13](https://github.com/RhetTbull/osxphotos/compare/v0.44.12...v0.44.13)
> 24 January 2022
- Removed exportdb requirement from PhotoTemplate [`6af124e`](https://github.com/RhetTbull/osxphotos/commit/6af124e4d3a0e26c48f435452920020cd42afa1c)
- Version bump [`bd31120`](https://github.com/RhetTbull/osxphotos/commit/bd3112056920806f565be2c0c12caf4f2aff5231)
#### [v0.44.12](https://github.com/RhetTbull/osxphotos/compare/v0.44.11...v0.44.12)
> 23 January 2022
- Added query options to repl, #597 [`7855801`](https://github.com/RhetTbull/osxphotos/commit/785580115b29f5ccb895de22be1243f56dbb43dc)
- Added run command, #598 [`b4bd04c`](https://github.com/RhetTbull/osxphotos/commit/b4bd04c1461d0b427937f541403305bc979bcf4f)
- Bug fix for get_photos_library_version [`e88c6b8`](https://github.com/RhetTbull/osxphotos/commit/e88c6b8a59dfd947f6cf3c7eac9c92519ab781a3)
#### [v0.44.11](https://github.com/RhetTbull/osxphotos/compare/v0.44.10...v0.44.11)
> 23 January 2022
- creat unit test for __all__ [`#599`](https://github.com/RhetTbull/osxphotos/pull/599)
- Performance improvements, added --profile [`7486823`](https://github.com/RhetTbull/osxphotos/commit/74868238f3b1ee18feb744f137f5c14ef8e36ffc)
#### [v0.44.10](https://github.com/RhetTbull/osxphotos/compare/v0.44.9...v0.44.10)
> 22 January 2022
- Create __all__ for all python files [`#589`](https://github.com/RhetTbull/osxphotos/pull/589)
- Create __all__ for the file cli.py [`#587`](https://github.com/RhetTbull/osxphotos/pull/587)
- docs: add xwu64 as a contributor for code [`#585`](https://github.com/RhetTbull/osxphotos/pull/585)
- add __all__ to files "adjustmentsinfo.py" and "albuminfo.py" [`#584`](https://github.com/RhetTbull/osxphotos/pull/584)
- More refactoring of export code, #462 [`6261a7b`](https://github.com/RhetTbull/osxphotos/commit/6261a7b5c96ac43aece66b72b9e27a90854accfa)
- Added ExportOptions to photoexporter.py, #462 [`9517876`](https://github.com/RhetTbull/osxphotos/commit/9517876bd06572238648a6362a309063b86007e7)
- Blackified files [`3bafdf7`](https://github.com/RhetTbull/osxphotos/commit/3bafdf7bfd5f7992b2e0c12496c55e7be1f57455)
- More refactoring of export code, #462 [`c2d726b`](https://github.com/RhetTbull/osxphotos/commit/c2d726beafabe76cf4d5fb3213447c900129b8c0)
- Refactored photoexporter sidecar writing, #462 [`458da0e`](https://github.com/RhetTbull/osxphotos/commit/458da0e9b2b82a78cec30191c5bf1ee2ed993acf)
#### [v0.44.9](https://github.com/RhetTbull/osxphotos/compare/v0.44.8...v0.44.9)
> 9 January 2022
- Added diff command [`3927f05`](https://github.com/RhetTbull/osxphotos/commit/3927f052670b2a1c31cced1f8278a0ffe519a3eb)
- Added uuid command [`a010ab5`](https://github.com/RhetTbull/osxphotos/commit/a010ab5a299470782b938e689a7ddc336513065e)
#### [v0.44.8](https://github.com/RhetTbull/osxphotos/compare/v0.44.7...v0.44.8)
> 9 January 2022
- docs: add ahti123 as a contributor for code, bug [`#578`](https://github.com/RhetTbull/osxphotos/pull/578)
- changing photos_5 version constant to satisfy version 5001 [`#577`](https://github.com/RhetTbull/osxphotos/pull/577)
- Added grep command to CLI [`4dd838b`](https://github.com/RhetTbull/osxphotos/commit/4dd838b8bcb639eba3df9cb60a7cd28f45b22833)
- Added test for #576 [`92fced7`](https://github.com/RhetTbull/osxphotos/commit/92fced75da38f1c47be8d3d9d4ee22463ad029b9)
- Added sqlgrep [`53c701c`](https://github.com/RhetTbull/osxphotos/commit/53c701cc0ebd38db255c1ce694391b38dbb5fe01)
- Fix for #575, database version 5001 [`5a8105f`](https://github.com/RhetTbull/osxphotos/commit/5a8105f5a02080368ad22717c064afcb0748f646)
- Updated docs [skip ci] [`64a0760`](https://github.com/RhetTbull/osxphotos/commit/64a0760a47205a452e015a860f39f45bba67164a)
#### [v0.44.7](https://github.com/RhetTbull/osxphotos/compare/v0.44.6...v0.44.7)
> 8 January 2022
- Fix for #576, error exporting edited live photos [`2e7db47`](https://github.com/RhetTbull/osxphotos/commit/2e7db47806683fdd0db4d1d75e42471d2f127d4d)
#### [v0.44.6](https://github.com/RhetTbull/osxphotos/compare/v0.44.5...v0.44.6)
> 6 January 2022
- Fix for burst images with pick type = 0, partial fix for #571 [`d2d56a7`](https://github.com/RhetTbull/osxphotos/commit/d2d56a7f7118aeffa7ac81cc474fdd4fb4843065)
#### [v0.44.5](https://github.com/RhetTbull/osxphotos/compare/v0.44.4...v0.44.5)
> 6 January 2022
- More refactoring of export code, #462 [`0c9bd87`](https://github.com/RhetTbull/osxphotos/commit/0c9bd8760261770e11b0fa59153f49f2d65e2c2f)
- Fix for #570 [`661a573`](https://github.com/RhetTbull/osxphotos/commit/661a573bf50353fb2393c604080ffe0790ade59c)
- version bump [skip ci] [`b4897ff`](https://github.com/RhetTbull/osxphotos/commit/b4897ff1b5d2bc00f34158345b2b5fe85f1490ac)
#### [v0.44.4](https://github.com/RhetTbull/osxphotos/compare/v0.44.3...v0.44.4)
> 4 January 2022
- Refactored photoinfo, photoexporter; #462 [`a73dc72`](https://github.com/RhetTbull/osxphotos/commit/a73dc72558b77152f4c90f143b6a60924b8905c8)
- More refactoring of export code, #462 [`147b30f`](https://github.com/RhetTbull/osxphotos/commit/147b30f97308db65868dc7a8d177d77ad0d0ad40)
- Export DB can now reside outside export directory, #568 [`76aee7f`](https://github.com/RhetTbull/osxphotos/commit/76aee7f189b4b32e2e263a4e798711713ed17a14)
#### [v0.44.3](https://github.com/RhetTbull/osxphotos/compare/v0.44.2...v0.44.3)
> 31 December 2021
- ImageConverter now uses generic context; #562 [`a3b2784`](https://github.com/RhetTbull/osxphotos/commit/a3b2784f3177a753b78965b8ca205ca9bbb08168)
- Updated tests and docs [`1391675`](https://github.com/RhetTbull/osxphotos/commit/1391675a3a45be0d6800a68c8bcc6d0d55d1ab7a)
- Updated docs [skip ci] [`cbe79ee`](https://github.com/RhetTbull/osxphotos/commit/cbe79ee98cae68e0789df275220f5a5870a8bd91)
#### [v0.44.2](https://github.com/RhetTbull/osxphotos/compare/v0.44.1...v0.44.2)
> 31 December 2021
- Bug fix for #559 [`42426b9`](https://github.com/RhetTbull/osxphotos/commit/42426b95ee786b2d53482d3d931a0b962a4db20d)
#### [v0.44.1](https://github.com/RhetTbull/osxphotos/compare/v0.44.0...v0.44.1) #### [v0.44.1](https://github.com/RhetTbull/osxphotos/compare/v0.44.0...v0.44.1)
> 31 December 2021 > 31 December 2021

View File

@@ -3,4 +3,5 @@ include README.rst
include osxphotos/templates/* include osxphotos/templates/*
include osxphotos/phototemplate.tx include osxphotos/phototemplate.tx
include osxphotos/phototemplate.md include osxphotos/phototemplate.md
include osxphotos/tutorial.md
include osxphotos/queries/* include osxphotos/queries/*

148
README.md
View File

@@ -5,7 +5,7 @@
![PyPI - Python Version](https://img.shields.io/pypi/pyversions/osxphotos) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/osxphotos)
[![Downloads](https://static.pepy.tech/personalized-badge/osxphotos?period=month&units=international_system&left_color=black&right_color=brightgreen&left_text=downloads/month)](https://pepy.tech/project/osxphotos) [![Downloads](https://static.pepy.tech/personalized-badge/osxphotos?period=month&units=international_system&left_color=black&right_color=brightgreen&left_text=downloads/month)](https://pepy.tech/project/osxphotos)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-32-orange.svg?style=flat)](#contributors) [![All Contributors](https://img.shields.io/badge/all_contributors-34-orange.svg?style=flat)](#contributors)
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
OSXPhotos provides the ability to interact with and query Apple's Photos.app library on macOS. You can query the Photos library database — for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos. OSXPhotos provides the ability to interact with and query Apple's Photos.app library on macOS. You can query the Photos library database — for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
@@ -38,6 +38,7 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
+ [Raw Photos](#raw-photos) + [Raw Photos](#raw-photos)
+ [Template System](#template-system) + [Template System](#template-system)
+ [ExifTool](#exiftoolExifTool) + [ExifTool](#exiftoolExifTool)
+ [PhotoExporter](#photoexporter)
+ [Text Detection](#textdetection) + [Text Detection](#textdetection)
+ [Utility Functions](#utility-functions) + [Utility Functions](#utility-functions)
* [Examples](#examples) * [Examples](#examples)
@@ -142,6 +143,7 @@ Options:
Commands: Commands:
about Print information about osxphotos including license. about Print information about osxphotos including license.
albums Print out albums found in the Photos library. albums Print out albums found in the Photos library.
diff Compare two Photos databases and print out differences
dump Print list of all photos & associated info from the Photos... dump Print list of all photos & associated info from the Photos...
export Export photos from the Photos database. export Export photos from the Photos database.
help Print help; for help on commands: help <command>. help Print help; for help on commands: help <command>.
@@ -154,8 +156,10 @@ Commands:
places Print out places 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... query Query the Photos database using 1 or more search options; if...
repl Run interactive osxphotos REPL shell (useful for debugging,... repl Run interactive osxphotos REPL shell (useful for debugging,...
snap Create snapshot of Photos database to use with diff command
tutorial Display osxphotos tutorial. tutorial Display osxphotos tutorial.
uninstall Uninstall Python packages from the osxphotos environment uninstall Uninstall Python packages from the osxphotos environment
uuid Print out unique IDs (UUID) of photos selected in Photos
``` ```
To get help on a specific command, use `osxphotos help <command_name>` To get help on a specific command, use `osxphotos help <command_name>`
@@ -1154,14 +1158,13 @@ Options:
You can run more than one function by You can run more than one function by
repeating the '--post-function' option with repeating the '--post-function' option with
different arguments. See Post Function below. different arguments. See Post Function below.
--exportdb EXPORTDB_FILE Specify alternate name for database file which --exportdb EXPORTDB_FILE Specify alternate path for database file which
stores state information for export and stores state information for export and
--update. If --exportdb is not specified, --update. If --exportdb is not specified,
export database will be saved to export database will be saved to
'.osxphotos_export.db' in the export '.osxphotos_export.db' in the export
directory. Must be specified as filename directory. If --exportdb is specified, it
only, not a path, as export database will be will be saved to the specified file.
saved in export directory.
--load-config <config file path> --load-config <config file path>
Load options from file as written with --save- Load options from file as written with --save-
config. This allows you to save a complex config. This allows you to save a complex
@@ -1721,7 +1724,7 @@ Substitution Description
{lf} A line feed: '\n', alias for {newline} {lf} A line feed: '\n', alias for {newline}
{cr} A carriage return: '\r' {cr} A carriage return: '\r'
{crlf} a carriage return + line feed: '\r\n' {crlf} a carriage return + line feed: '\r\n'
{osxphotos_version} The osxphotos version, e.g. '0.44.1' {osxphotos_version} The osxphotos version, e.g. '0.45.0'
{osxphotos_cmd_line} The full command line used to run osxphotos {osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for The following substitutions may result in multiple values. Thus if specified for
@@ -2764,25 +2767,27 @@ Returns a JSON representation of all photo info.
Returns a dictionary representation of all photo info. Returns a dictionary representation of all photo info.
#### `export()` #### `export()`
`export(dest, filename=None, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)` `export(dest, filename=None, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, download_missing=False, use_photos_export=False, use_photokit=True, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
Export photo from the Photos library to another destination on disk. Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised). - dest: must be valid destination path as str (or exception raised).
- filename (optional): name of picture as str; if not provided, will use current filename. **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will happily export the photo using the incorrect file extension. e.g. to get the extension of the edited photo, look at [PhotoInfo.path_edited](#path_edited). - filename (optional): name of picture as str; if not provided, will use current filename. **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will happily export the photo using the incorrect file extension. e.g. to get the extension of the edited photo, look at [PhotoInfo.path_edited](#path_edited).
- edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version) - edited: bool; if True (default=False), will export the edited version of the photo (or raise exception if no edited version)
- export_as_hardlink: boolean; if True (default=False), will hardlink files instead of copying them - export_as_hardlink: bool; if True (default=False), will hardlink files instead of copying them
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist - overwrite: bool; if True (default=False), will overwrite files if they alreay exist
- live_photo: boolean; if True (default=False), will also export the associted .mov for live photos; exported live photo will be named filename.mov - live_photo: bool; if True (default=False), will also export the associted .mov for live photos; exported live photo will be named filename.mov
- increment: boolean; if True (default=True), will increment file name until a non-existent name is found - increment: bool; if True (default=True), will increment file name until a non-existent name is found
- sidecar_json: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name - sidecar_json: (bool, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name
- sidecar_json: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name; resulting json file will include tag group names (e.g. `exiftool -G -j`) - sidecar_json: (bool, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name; resulting json file will include tag group names (e.g. `exiftool -G -j`)
- sidecar_exiftool: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name; resulting json file will not include tag group names (e.g. `exiftool -j`) - sidecar_exiftool: (bool, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name; resulting json file will not include tag group names (e.g. `exiftool -j`)
- sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with metadata; sidecar filename will be dest/filename.xmp where filename is the stem of the photo name - sidecar_xmp: (bool, default = False); if True will also write a XMP sidecar with metadata; sidecar filename will be dest/filename.xmp where filename is the stem of the photo name
- use_photos_export: boolean; (default=False), if True will attempt to export photo via applescript interaction with Photos; useful for forcing download of missing photos. This only works if the Photos library being used is the default library (last opened by Photos) as applescript will directly interact with whichever library Photos is currently using. - use_photos_export: (bool, default=False); if True will attempt to export photo via AppleScript or PhotoKit interaction with Photos
- download_missing: (bool, default=False); if True will attempt to export photo via AppleScript or PhotoKit interaction with Photos if missing
- use_photokit: (bool, default=True); if True will attempt to export photo via photokit instead of AppleScript when used with use_photos_export or download_missing
- timeout: (int, default=120) timeout in seconds used with use_photos_export - timeout: (int, default=120) timeout in seconds used with use_photos_export
- exiftool: (boolean, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path - exiftool: (bool, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path
- use_albums_as_keywords: (boolean, default = False); if True, will use album names as keywords when exporting metadata with exiftool or sidecar - use_albums_as_keywords: (bool, default = False); if True, will use album names as keywords when exporting metadata with exiftool or sidecar
- use_persons_as_keywords: (boolean, default = False); if True, will use person names as keywords when exporting metadata with exiftool or sidecar - use_persons_as_keywords: (bool, default = False); if True, will use person names as keywords when exporting metadata with exiftool or sidecar
Returns: list of paths to exported files. More than one file could be exported, for example if live_photo=True, both the original image and the associated .mov file will be exported Returns: list of paths to exported files. More than one file could be exported, for example if live_photo=True, both the original image and the associated .mov file will be exported
@@ -3623,7 +3628,7 @@ The following template field substitutions are availabe for use the templating s
|{lf}|A line feed: '\n', alias for {newline}| |{lf}|A line feed: '\n', alias for {newline}|
|{cr}|A carriage return: '\r'| |{cr}|A carriage return: '\r'|
|{crlf}|a carriage return + line feed: '\r\n'| |{crlf}|a carriage return + line feed: '\r\n'|
|{osxphotos_version}|The osxphotos version, e.g. '0.44.1'| |{osxphotos_version}|The osxphotos version, e.g. '0.45.0'|
|{osxphotos_cmd_line}|The full command line used to run osxphotos| |{osxphotos_cmd_line}|The full command line used to run osxphotos|
|{album}|Album(s) photo is contained in| |{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| |{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
@@ -3707,6 +3712,105 @@ osxphotos.exiftool also provides an `ExifToolCaching` class which caches all met
`ExifTool()` runs `exiftool` as a subprocess using the `-stay_open True` flag to keep the process running in the background. The subprocess will be cleaned up when your main script terminates. `ExifTool()` uses a singleton pattern to ensure that only one instance of `exiftool` is created. Multiple instances of `ExifTool()` will all use the same `exiftool` subprocess. `ExifTool()` runs `exiftool` as a subprocess using the `-stay_open True` flag to keep the process running in the background. The subprocess will be cleaned up when your main script terminates. `ExifTool()` uses a singleton pattern to ensure that only one instance of `exiftool` is created. Multiple instances of `ExifTool()` will all use the same `exiftool` subprocess.
### <a name="photoexporter">PhotoExporter</a>
[PhotoInfo.export()](#photoinfo) provides a simple method to export a photo. This method actually calls `PhotoExporter.export()` to do the export. `PhotoExporter` provides many more options to configure the export and report results and this is what the osxphotos command line export tools uses.
#### `export(dest, filename=None, options: Optional[ExportOptions]=None) -> ExportResults`
Export a photo.
Args:
- dest: must be valid destination path or exception raised
- filename: (optional): name of exported picture; if not provided, will use current filename
- options (ExportOptions): optional ExportOptions instance
Returns: ExportResults instance
*Note*: to use dry run mode, you must set options.dry_run=True and also pass in memory version of export_db, and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp) in options.export_db and options.fileutil respectively.
#### `ExportOptions`
Options class for exporting photos with `export`
Attributes:
- convert_to_jpeg (bool): if True, converts non-jpeg images to jpeg
- description_template (str): optional template string that will be rendered for use as photo description
- download_missing: (bool, default=False): if True will attempt to export photo via applescript interaction with Photos if missing (see also use_photokit, use_photos_export)
- dry_run: (bool, default=False): set to True to run in "dry run" mode
- edited: (bool, default=False): if True will export the edited version of the photo otherwise exports the original version
- exiftool_flags (list of str): optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
- exiftool: (bool, default = False): if True, will use exiftool to write metadata to export file
- export_as_hardlink: (bool, default=False): if True, will hardlink files instead of copying them
- export_db: (ExportDB_ABC): instance of a class that conforms to ExportDB_ABC with methods for getting/setting data related to exported files to compare update state
- fileutil: (FileUtilABC): class that conforms to FileUtilABC with various file utilities
- ignore_date_modified (bool): for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
- ignore_signature (bool, default=False): ignore file signature when used with update (look only at filename)
- increment (bool, default=True): if True, will increment file name until a non-existant name is found if overwrite=False and increment=False, export will fail if destination file already exists
- jpeg_ext (str): if set, will use this value for extension on jpegs converted to jpeg with convert_to_jpeg; if not set, uses jpeg; do not include the leading "."
- jpeg_quality (float in range 0.0 <= jpeg_quality <= 1.0): a value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
- keyword_template (list of str): list of template strings that will be rendered as used as keywords
- live_photo (bool, default=False): if True, will also export the associated .mov for live photos
- location (bool): if True, include location in exported metadata
- merge_exif_keywords (bool): if True, merged keywords found in file's exif data (requires exiftool)
- merge_exif_persons (bool): if True, merged persons found in file's exif data (requires exiftool)
- overwrite (bool, default=False): if True will overwrite files if they already exist
- persons (bool): if True, include persons in exported metadata
- preview_suffix (str): optional string to append to end of filename for preview images
- preview (bool): if True, also exports preview image
- raw_photo (bool, default=False): if True, will also export the associated RAW photo
- render_options (RenderOptions): optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
- replace_keywords (bool): if True, keyword_template replaces any keywords, otherwise it's additive
- sidecar_drop_ext (bool, default=False): if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json')
- sidecar: bit field (int): set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
- SIDECAR_JSON: if set will write a json sidecar with data in format readable by exiftool sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
- SIDECAR_EXIFTOOL: if set will write a json sidecar with data in format readable by exiftool sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
- SIDECAR_XMP: if set will write an XMP sidecar with IPTC data sidecar filename will be dest/filename.xmp
- strip (bool): if True, strip whitespace from rendered templates
- timeout (int, default=120): timeout in seconds used with use_photos_export
- touch_file (bool, default=False): if True, sets file's modification time upon photo date
- update (bool, default=False): if True export will run in update mode, that is, it will not export the photo if the current version already exists in the destination
- use_albums_as_keywords (bool, default = False): if True, will include album names in keywords when exporting metadata with exiftool or sidecar
- use_persons_as_keywords (bool, default = False): if True, will include person names in keywords when exporting metadata with exiftool or sidecar
- use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos even if not missing (see also use_photokit, download_missing)
- use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True
- verbose (Callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
#### `ExportResults`
`PhotoExporter().export()` returns an instance of this class.
`ExportResults` has the following properties:
- exported: list of all exported files (A single call to export could export more than one file, e.g. original file, preview, live video, raw, etc.)
- new: list of new files exported when used with update=True
- updated: list of updated files when used with update=True
- skipped: list of skipped files when used with update=True
- exif_updated: list of updated files when used with update=True and exiftool
- touched: list of files touched during export (e.g. file date/time updated with touch_file=True)
- to_touch: Reserved for internal use of export
- converted_to_jpeg: list of files converted to jpeg when convert_to_jpeg=True
- sidecar_json_written: list of JSON sidecars written
- sidecar_json_skipped: list of JSON sidecars skipped when update=True
- sidecar_exiftool_written: list of exiftool sidecars written
- sidecar_exiftool_skipped: list of exiftool sidecars skipped when update=True
- sidecar_xmp_written: list of XMP sidecars written
- sidecar_xmp_skipped: list of XMP sidecars skipped when update=True
- missing: list of missing files
- error: list of tuples containing (filename, error) if error generated during export
- exiftool_warning: list of warnings generated by exiftool during export
- exiftool_error: list of errors generated by exiftool during export
- xattr_written: list of files with extended attributes written during export
- xattr_skipped: list of files where extended attributes were skipped when update=True
- deleted_files: reserved for use by osxphotos CLI
- deleted_directories: reserved for use by osxphotos CLI
- exported_album: reserved for use by osxphotos CLI
- skipped_album: reserved for use by osxphotos CLI
- missing_album: reserved for use by osxphotos CLI
### <a name="textdetection">Text Detection</a> ### <a name="textdetection">Text Detection</a>
The [PhotoInfo.detected_text()](#detected_text_method) and the `{detected_text}` template will perform text detection on the photos in your library. Text detection is a slow process so to avoid unnecessary re-processing of photos, osxphotos will cache the results of the text detection process as an extended attribute on the photo image file. Extended attributes do not modify the actual file. The extended attribute is named `osxphotos.metadata:detected_text` and can be viewed using the built-in [xattr](https://ss64.com/osx/xattr.html) command or my [osxmetadata](https://github.com/RhetTbull/osxmetadata) tool. If you want to remove the cached attribute, you can do so with osxmetadata as follows: The [PhotoInfo.detected_text()](#detected_text_method) and the `{detected_text}` template will perform text detection on the photos in your library. Text detection is a slow process so to avoid unnecessary re-processing of photos, osxphotos will cache the results of the text detection process as an extended attribute on the photo image file. Extended attributes do not modify the actual file. The extended attribute is named `osxphotos.metadata:detected_text` and can be viewed using the built-in [xattr](https://ss64.com/osx/xattr.html) command or my [osxmetadata](https://github.com/RhetTbull/osxmetadata) tool. If you want to remove the cached attribute, you can do so with osxmetadata as follows:
@@ -3851,6 +3955,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://alandefreitas.github.io/alandefreitas/"><img src="https://avatars.githubusercontent.com/u/5369819?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Alan de Freitas</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/issues?q=author%3Aalandefreitas" title="Bug reports">🐛</a></td> <td align="center"><a href="https://alandefreitas.github.io/alandefreitas/"><img src="https://avatars.githubusercontent.com/u/5369819?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Alan de Freitas</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/issues?q=author%3Aalandefreitas" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://hyfen.net"><img src="https://avatars.githubusercontent.com/u/6291?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Andrew Louis</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hyfen" title="Documentation">📖</a> <a href="https://github.com/RhetTbull/osxphotos/commits?author=hyfen" title="Code">💻</a></td> <td align="center"><a href="https://hyfen.net"><img src="https://avatars.githubusercontent.com/u/6291?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Andrew Louis</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hyfen" title="Documentation">📖</a> <a href="https://github.com/RhetTbull/osxphotos/commits?author=hyfen" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/neebah"><img src="https://avatars.githubusercontent.com/u/71442026?v=4?s=75" width="75px;" alt=""/><br /><sub><b>neebah</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/issues?q=author%3Aneebah" title="Bug reports">🐛</a></td> <td align="center"><a href="https://github.com/neebah"><img src="https://avatars.githubusercontent.com/u/71442026?v=4?s=75" width="75px;" alt=""/><br /><sub><b>neebah</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/issues?q=author%3Aneebah" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/ahti123"><img src="https://avatars.githubusercontent.com/u/22232632?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Ahti Liin</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=ahti123" title="Code">💻</a> <a href="https://github.com/RhetTbull/osxphotos/issues?q=author%3Aahti123" title="Bug reports">🐛</a></td>
<td align="center"><a href="https://github.com/xwu64"><img src="https://avatars.githubusercontent.com/u/10580396?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Xiaoliang Wu</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=xwu64" title="Code">💻</a></td>
</tr> </tr>
</table> </table>

View File

@@ -1,4 +1,4 @@
# Sphinx build info version 1 # Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. # This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 58505ca56d322ccc59c38bc440ccc347 config: fb08da4139dc0e6a5dd26bd81551df02
tags: 645f666f9bcd5a90fca523b33c5a78b7 tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@@ -1,6 +1,6 @@
var DOCUMENTATION_OPTIONS = { var DOCUMENTATION_OPTIONS = {
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'), URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
VERSION: '0.44.1', VERSION: '0.45.0',
LANGUAGE: 'None', LANGUAGE: 'None',
COLLAPSE_INDEX: false, COLLAPSE_INDEX: false,
BUILDER: 'html', BUILDER: 'html',

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.44.1 documentation</title> <title>osxphotos command line interface (CLI) &#8212; osxphotos 0.45.0 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" /> <link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" /> <link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script> <script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &#8212; osxphotos 0.44.1 documentation</title> <title>Index &#8212; osxphotos 0.45.0 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" /> <link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" /> <link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script> <script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.44.1 documentation</title> <title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.45.0 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" /> <link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" /> <link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script> <script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>osxphotos &#8212; osxphotos 0.44.1 documentation</title> <title>osxphotos &#8212; osxphotos 0.45.0 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" /> <link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" /> <link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script> <script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>osxphotos package &#8212; osxphotos 0.44.1 documentation</title> <title>osxphotos package &#8212; osxphotos 0.45.0 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" /> <link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" /> <link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script> <script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; osxphotos 0.44.1 documentation</title> <title>Search &#8212; osxphotos 0.45.0 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" /> <link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" /> <link rel="stylesheet" type="text/css" href="_static/alabaster.css" />

View File

@@ -1,12 +1,45 @@
from ._constants import AlbumSortOrder from ._constants import AlbumSortOrder
from ._version import __version__ from ._version import __version__
from .exiftool import ExifTool from .exiftool import ExifTool
from .photoinfo import ExportResults, PhotoInfo from .export_db import ExportDB, ExportDBInMemory, ExportDBNoOp
from .fileutil import FileUtil, FileUtilNoOp
from .momentinfo import MomentInfo
from .personinfo import PersonInfo
from .photoexporter import ExportOptions, ExportResults, PhotoExporter
from .photoinfo import PhotoInfo
from .photosdb import PhotosDB from .photosdb import PhotosDB
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
from .phototemplate import PhotoTemplate from .phototemplate import PhotoTemplate
from .placeinfo import PlaceInfo
from .queryoptions import QueryOptions from .queryoptions import QueryOptions
from .scoreinfo import ScoreInfo
from .searchinfo import SearchInfo
from .utils import _debug, _get_logger, _set_debug from .utils import _debug, _get_logger, _set_debug
# TODO: Add test for imageTimeZoneOffsetSeconds = None __all__ = [
# TODO: Add special albums and magic albums "__version__",
"_debug",
"_get_logger",
"_set_debug",
"AlbumSortOrder",
"CommentInfo",
"ExifTool",
"ExportDB",
"ExportDBInMemory",
"ExportDBNoOp",
"ExportOptions",
"ExportResults",
"FileUtil",
"FileUtilNoOp",
"LikeInfo",
"MomentInfo",
"PersonInfo",
"PhotoExporter",
"PhotoInfo",
"PhotosDB",
"PhotoTemplate",
"PlaceInfo",
"QueryOptions",
"ScoreInfo",
"SearchInfo",
]

View File

@@ -20,8 +20,8 @@ UNICODE_FORMAT = "NFC"
# Photos 3.0 (10.13.6) == 3301 # Photos 3.0 (10.13.6) == 3301
# Photos 4.0 (10.14.5) == 4016 # Photos 4.0 (10.14.5) == 4016
# Photos 4.0 (10.14.6) == 4025 # Photos 4.0 (10.14.6) == 4025
# Photos 5.0 (10.15.0) == 6000 # Photos 5.0 (10.15.0) == 6000 or 5001
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"] _TESTED_DB_VERSIONS = ["6000", "5001", "4025", "4016", "3301", "2622"]
# database model versions (applies to Photos 5, Photos 6) # database model versions (applies to Photos 5, Photos 6)
# these come from PLModelVersion key in binary plist in Z_METADATA.Z_PLIST # these come from PLModelVersion key in binary plist in Z_METADATA.Z_PLIST
@@ -37,12 +37,15 @@ _PHOTOS_3_VERSION = "3301"
# versions 5.0 and later have a different database structure # versions 5.0 and later have a different database structure
_PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6 _PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.7 (also Big Sur and Monterey which switch to model version) _PHOTOS_5_VERSION = "5000" # I've seen both 5001 and 6000. 6000 is most common on Catalina and up but there are some version 5001 database in the wild
# Ranges for model version by Photos version # Ranges for model version by Photos version
_PHOTOS_5_MODEL_VERSION = [13000, 13999] _PHOTOS_5_MODEL_VERSION = [13000, 13999]
_PHOTOS_6_MODEL_VERSION = [14000, 14999] _PHOTOS_6_MODEL_VERSION = [14000, 14999]
_PHOTOS_7_MODEL_VERSION = [15000, 15999] # Monterey developer preview is 15134 _PHOTOS_7_MODEL_VERSION = [
15000,
15999,
] # Monterey developer preview is 15134, 12.1 is 15331
# some table names differ between Photos 5 and Photos 6 # some table names differ between Photos 5 and Photos 6
_DB_TABLE_NAMES = { _DB_TABLE_NAMES = {
@@ -98,6 +101,8 @@ _TESTED_OS_VERSIONS = [
("11", "4"), ("11", "4"),
("11", "5"), ("11", "5"),
("11", "6"), ("11", "6"),
("12", "0"),
("12", "1"),
] ]
# Photos 5 has persons who are empty string if unidentified face # Photos 5 has persons who are empty string if unidentified face
@@ -258,6 +263,7 @@ EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db" OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
# bit flags for burst images ("burstPickType") # bit flags for burst images ("burstPickType")
BURST_PICK_TYPE_NONE = 0b0 # 0: sometimes used for single images with a burst UUID
BURST_NOT_SELECTED = 0b10 # 2: burst image is not selected BURST_NOT_SELECTED = 0b10 # 2: burst image is not selected
BURST_DEFAULT_PICK = 0b100 # 4: burst image is the one Photos picked to be key image before any selections made BURST_DEFAULT_PICK = 0b100 # 4: burst image is the one Photos picked to be key image before any selections made
BURST_SELECTED = 0b1000 # 8: burst image is selected BURST_SELECTED = 0b1000 # 8: burst image is selected
@@ -299,3 +305,21 @@ class AlbumSortOrder(Enum):
TEXT_DETECTION_CONFIDENCE_THRESHOLD = 0.75 TEXT_DETECTION_CONFIDENCE_THRESHOLD = 0.75
# stat sort order for cProfile: https://docs.python.org/3/library/profile.html#pstats.Stats.sort_stats
PROFILE_SORT_KEYS = [
"calls",
"cumulative",
"cumtime",
"file",
"filename",
"module",
"ncalls",
"pcalls",
"line",
"name",
"nfl",
"stdname",
"time",
"tottime",
]

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.44.2" __version__ = "0.45.2"

View File

@@ -16,6 +16,8 @@ import zlib
from .datetime_utils import datetime_naive_to_utc from .datetime_utils import datetime_naive_to_utc
__all__ = ["AdjustmentsDecodeError", "AdjustmentsInfo"]
class AdjustmentsDecodeError(Exception): class AdjustmentsDecodeError(Exception):
"""Could not decode adjustments plist file""" """Could not decode adjustments plist file"""

View File

@@ -24,6 +24,15 @@ from ._constants import (
from .datetime_utils import get_local_tz from .datetime_utils import get_local_tz
from .query_builder import get_query from .query_builder import get_query
__all__ = [
"sort_list_by_keys",
"AlbumInfoBaseClass",
"AlbumInfo",
"ImportInfo",
"ProjectInfo",
"FolderInfo",
]
def sort_list_by_keys(values, sort_keys): def sort_list_by_keys(values, sort_keys):
"""Sorts list values by a second list sort_keys """Sorts list values by a second list sort_keys

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,17 @@ from .phototemplate import (
get_template_help, get_template_help,
) )
__all__ = [
"ExportCommand",
"template_help",
"tutorial_help",
"rich_text",
"strip_md_header_and_links",
"strip_md_links",
"strip_html_comments",
"get_tutorial_text",
]
# TODO: The following help text could probably be done as mako template # TODO: The following help text could probably be done as mako template
class ExportCommand(click.Command): class ExportCommand(click.Command):

View File

@@ -1,6 +1,13 @@
""" ConfigOptions class to load/save config settings for osxphotos CLI """ """ ConfigOptions class to load/save config settings for osxphotos CLI """
import toml import toml
__all__ = [
"ConfigOptionsException",
"ConfigOptionsInvalidError",
"ConfigOptionsLoadError",
"ConfigOptions",
]
class ConfigOptionsException(Exception): class ConfigOptionsException(Exception):
"""Invalid combination of options.""" """Invalid combination of options."""

View File

@@ -2,6 +2,8 @@
import datetime import datetime
__all__ = ["DateTimeFormatter"]
class DateTimeFormatter: class DateTimeFormatter:
"""provides property access to formatted datetime.datetime strftime values""" """provides property access to formatted datetime.datetime strftime values"""

View File

@@ -2,6 +2,16 @@
import datetime import datetime
__all__ = [
"get_local_tz",
"datetime_has_tz",
"datetime_tz_to_utc",
"datetime_remove_tz",
"datetime_naive_to_utc",
"datetime_naive_to_local",
"datetime_utc_to_local",
]
def get_local_tz(dt): def get_local_tz(dt):
"""Return local timezone as datetime.timezone tzinfo for dt """Return local timezone as datetime.timezone tzinfo for dt

30
osxphotos/exifinfo.py Normal file
View File

@@ -0,0 +1,30 @@
""" ExifInfo class to expose EXIF info from the library """
from dataclasses import dataclass
__all__ = ["ExifInfo"]
@dataclass(frozen=True)
class ExifInfo:
"""EXIF info associated with a photo from the Photos library"""
flash_fired: bool
iso: int
metering_mode: int
sample_rate: int
track_format: int
white_balance: int
aperture: float
bit_rate: float
duration: float
exposure_bias: float
focal_length: float
fps: float
latitude: float
longitude: float
shutter_speed: float
camera_make: str
camera_model: str
codec: str
lens_model: str

View File

@@ -17,6 +17,15 @@ import subprocess
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from functools import lru_cache # pylint: disable=syntax-error from functools import lru_cache # pylint: disable=syntax-error
__all__ = [
"escape_str",
"unescape_str",
"terminate_exiftool",
"get_exiftool_path",
"ExifTool",
"ExifToolCaching",
]
# exiftool -stay_open commands outputs this EOF marker after command is run # exiftool -stay_open commands outputs this EOF marker after command is run
EXIFTOOL_STAYOPEN_EOF = "{ready}" EXIFTOOL_STAYOPEN_EOF = "{ready}"
EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF) EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)

View File

@@ -1,5 +1,6 @@
""" Helper class for managing a database used by PhotoInfo.export for tracking state of exports and updates """ """ Helper class for managing a database used by PhotoInfo.export for tracking state of exports and updates """
import datetime import datetime
import logging import logging
import os import os
@@ -13,8 +14,10 @@ from sqlite3 import Error
from ._constants import OSXPHOTOS_EXPORT_DB from ._constants import OSXPHOTOS_EXPORT_DB
from ._version import __version__ from ._version import __version__
OSXPHOTOS_EXPORTDB_VERSION = "4.0" __all__ = ["ExportDB_ABC", "ExportDBNoOp", "ExportDB", "ExportDBInMemory"]
OSXPHOTOS_ABOUT_STRING = f"Created by osxphotos version {__version__} (https://github.com/RhetTbull/osxphotos) on {str(datetime.datetime.now())}"
OSXPHOTOS_EXPORTDB_VERSION = "4.2"
OSXPHOTOS_ABOUT_STRING = f"Created by osxphotos version {__version__} (https://github.com/RhetTbull/osxphotos) on {datetime.datetime.now()}"
class ExportDB_ABC(ABC): class ExportDB_ABC(ABC):
@@ -101,12 +104,12 @@ class ExportDB_ABC(ABC):
self, self,
filename, filename,
uuid, uuid,
orig_stat, orig_stat=None,
exif_stat, exif_stat=None,
converted_stat, converted_stat=None,
edited_stat, edited_stat=None,
info_json, info_json=None,
exif_json, exif_json=None,
): ):
pass pass
@@ -180,12 +183,12 @@ class ExportDBNoOp(ExportDB_ABC):
self, self,
filename, filename,
uuid, uuid,
orig_stat, orig_stat=None,
exif_stat, exif_stat=None,
converted_stat, converted_stat=None,
edited_stat, edited_stat=None,
info_json, info_json=None,
exif_json, exif_json=None,
): ):
pass pass
@@ -193,15 +196,14 @@ class ExportDBNoOp(ExportDB_ABC):
class ExportDB(ExportDB_ABC): class ExportDB(ExportDB_ABC):
"""Interface to sqlite3 database used to store state information for osxphotos export command""" """Interface to sqlite3 database used to store state information for osxphotos export command"""
def __init__(self, dbfile): def __init__(self, dbfile, export_dir):
"""dbfile: path to osxphotos export database file""" """dbfile: path to osxphotos export database file"""
self._dbfile = dbfile self._dbfile = dbfile
# _path is parent of the database # export_dir is required as all files referenced by get_/set_uuid_for_file will be converted to
# all files referenced by get_/set_uuid_for_file will be converted to # relative paths to this path
# relative paths to this parent _path
# this allows the entire export tree to be moved to a new disk/location # this allows the entire export tree to be moved to a new disk/location
# whilst preserving the UUID to filename mapping # whilst preserving the UUID to filename mapping
self._path = pathlib.Path(dbfile).parent self._path = export_dir
self._conn = self._open_export_db(dbfile) self._conn = self._open_export_db(dbfile)
self._insert_run_info() self._insert_run_info()
@@ -214,14 +216,13 @@ class ExportDB(ExportDB_ABC):
try: try:
c = conn.cursor() c = conn.cursor()
c.execute( c.execute(
f"SELECT uuid FROM files WHERE filepath_normalized = ?", (filename,) "SELECT uuid FROM files WHERE filepath_normalized = ?", (filename,)
) )
results = c.fetchone() results = c.fetchone()
uuid = results[0] if results else None uuid = results[0] if results else None
except Error as e: except Error as e:
logging.warning(e) logging.warning(e)
uuid = None uuid = None
return uuid return uuid
def set_uuid_for_file(self, filename, uuid): def set_uuid_for_file(self, filename, uuid):
@@ -232,9 +233,10 @@ class ExportDB(ExportDB_ABC):
try: try:
c = conn.cursor() c = conn.cursor()
c.execute( c.execute(
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);", "INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
(filename, filename_normalized, uuid), (filename, filename_normalized, uuid),
) )
conn.commit() conn.commit()
except Error as e: except Error as e:
logging.warning(e) logging.warning(e)
@@ -274,15 +276,14 @@ class ExportDB(ExportDB_ABC):
) )
results = c.fetchone() results = c.fetchone()
if results: if results:
stats = results[0:3] stats = results[:3]
mtime = int(stats[2]) if stats[2] is not None else None mtime = int(stats[2]) if stats[2] is not None else None
stats = (stats[0], stats[1], mtime) stats = (stats[0], stats[1], mtime)
else: else:
stats = (None, None, None) stats = (None, None, None)
except Error as e: except Error as e:
logging.warning(e) logging.warning(e)
stats = (None, None, None) stats = None, None, None
return stats return stats
def set_stat_edited_for_file(self, filename, stats): def set_stat_edited_for_file(self, filename, stats):
@@ -332,15 +333,14 @@ class ExportDB(ExportDB_ABC):
) )
results = c.fetchone() results = c.fetchone()
if results: if results:
stats = results[0:3] stats = results[:3]
mtime = int(stats[2]) if stats[2] is not None else None mtime = int(stats[2]) if stats[2] is not None else None
stats = (stats[0], stats[1], mtime) stats = (stats[0], stats[1], mtime)
else: else:
stats = (None, None, None) stats = (None, None, None)
except Error as e: except Error as e:
logging.warning(e) logging.warning(e)
stats = (None, None, None) stats = None, None, None
return stats return stats
def set_stat_converted_for_file(self, filename, stats): def set_stat_converted_for_file(self, filename, stats):
@@ -493,7 +493,10 @@ class ExportDB(ExportDB_ABC):
c = conn.cursor() c = conn.cursor()
c.execute( c.execute(
"INSERT OR REPLACE INTO detected_text(uuid, text_data) VALUES (?, ?);", "INSERT OR REPLACE INTO detected_text(uuid, text_data) VALUES (?, ?);",
(uuid, text_json,), (
uuid,
text_json,
),
) )
conn.commit() conn.commit()
except Error as e: except Error as e:
@@ -503,47 +506,61 @@ class ExportDB(ExportDB_ABC):
self, self,
filename, filename,
uuid, uuid,
orig_stat, orig_stat=None,
exif_stat, exif_stat=None,
converted_stat, converted_stat=None,
edited_stat, edited_stat=None,
info_json, info_json=None,
exif_json, exif_json=None,
): ):
"""sets all the data for file and uuid at once""" """sets all the data for file and uuid at once; if any value is None, does not set it"""
filename = str(pathlib.Path(filename).relative_to(self._path)) filename = str(pathlib.Path(filename).relative_to(self._path))
filename_normalized = filename.lower() filename_normalized = filename.lower()
conn = self._conn conn = self._conn
try: try:
c = conn.cursor() c = conn.cursor()
# update files table (if needed);
# this statement works around fact that there was no unique constraint on files.filepath_normalized
c.execute( c.execute(
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);", """INSERT OR IGNORE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);""",
(filename, filename_normalized, uuid), (filename, filename_normalized, uuid),
) )
if orig_stat is not None:
c.execute( c.execute(
"UPDATE files " "UPDATE files "
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? " + "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
+ "WHERE filepath_normalized = ?;", + "WHERE filepath_normalized = ?;",
(*orig_stat, filename_normalized), (*orig_stat, filename_normalized),
) )
if exif_stat is not None:
c.execute( c.execute(
"UPDATE files " "UPDATE files "
+ "SET exif_mode = ?, exif_size = ?, exif_mtime = ? " + "SET exif_mode = ?, exif_size = ?, exif_mtime = ? "
+ "WHERE filepath_normalized = ?;", + "WHERE filepath_normalized = ?;",
(*exif_stat, filename_normalized), (*exif_stat, filename_normalized),
) )
if converted_stat is not None:
c.execute( c.execute(
"INSERT OR REPLACE INTO converted(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);", "INSERT OR REPLACE INTO converted(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
(filename_normalized, *converted_stat), (filename_normalized, *converted_stat),
) )
if edited_stat is not None:
c.execute( c.execute(
"INSERT OR REPLACE INTO edited(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);", "INSERT OR REPLACE INTO edited(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
(filename_normalized, *edited_stat), (filename_normalized, *edited_stat),
) )
if info_json is not None:
c.execute( c.execute(
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);", "INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
(uuid, info_json), (uuid, info_json),
) )
if exif_json is not None:
c.execute( c.execute(
"INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);", "INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
(filename_normalized, exif_json), (filename_normalized, exif_json),
@@ -582,7 +599,7 @@ class ExportDB(ExportDB_ABC):
) )
results = c.fetchone() results = c.fetchone()
if results: if results:
stats = results[0:3] stats = results[:3]
mtime = int(stats[2]) if stats[2] is not None else None mtime = int(stats[2]) if stats[2] is not None else None
stats = (stats[0], stats[1], mtime) stats = (stats[0], stats[1], mtime)
else: else:
@@ -658,6 +675,22 @@ class ExportDB(ExportDB_ABC):
exif_size INTEGER, exif_size INTEGER,
exif_mtime REAL exif_mtime REAL
); """, ); """,
"sql_files_table_migrate": """ CREATE TABLE IF NOT EXISTS files_migrate (
id INTEGER PRIMARY KEY,
filepath TEXT NOT NULL,
filepath_normalized TEXT NOT NULL,
uuid TEXT,
orig_mode INTEGER,
orig_size INTEGER,
orig_mtime REAL,
exif_mode INTEGER,
exif_size INTEGER,
exif_mtime REAL,
UNIQUE(filepath_normalized)
); """,
"sql_files_migrate": """ INSERT INTO files_migrate SELECT * FROM files;""",
"sql_files_drop_tables": """ DROP TABLE files;""",
"sql_files_alter": """ ALTER TABLE files_migrate RENAME TO files;""",
"sql_runs_table": """ CREATE TABLE IF NOT EXISTS runs ( "sql_runs_table": """ CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
datetime TEXT, datetime TEXT,
@@ -741,9 +774,10 @@ class ExportDB(ExportDB_ABC):
try: try:
c = conn.cursor() c = conn.cursor()
c.execute( c.execute(
f"INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)", "INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)",
(dt, python_path, cmd, args, cwd), (dt, python_path, cmd, args, cwd),
) )
conn.commit() conn.commit()
except Error as e: except Error as e:
logging.warning(e) logging.warning(e)
@@ -755,14 +789,13 @@ class ExportDBInMemory(ExportDB):
modifying the on-disk version modifying the on-disk version
""" """
def __init__(self, dbfile): def __init__(self, dbfile, export_dir):
self._dbfile = dbfile or f"./{OSXPHOTOS_EXPORT_DB}" self._dbfile = dbfile or f"./{OSXPHOTOS_EXPORT_DB}"
# _path is parent of the database # export_dir is required as all files referenced by get_/set_uuid_for_file will be converted to
# all files referenced by get_/set_uuid_for_file will be converted to # relative paths to this path
# relative paths to this parent _path
# this allows the entire export tree to be moved to a new disk/location # this allows the entire export tree to be moved to a new disk/location
# whilst preserving the UUID to filename mapping # whilst preserving the UUID to filename mapping
self._path = pathlib.Path(self._dbfile).parent self._path = export_dir
self._conn = self._open_export_db(self._dbfile) self._conn = self._open_export_db(self._dbfile)
self._insert_run_info() self._insert_run_info()

View File

@@ -11,6 +11,8 @@ import Foundation
from .imageconverter import ImageConverter from .imageconverter import ImageConverter
__all__ = ["FileUtilABC", "FileUtilMacOS", "FileUtil", "FileUtilNoOp"]
class FileUtilABC(ABC): class FileUtilABC(ABC):
"""Abstract base class for FileUtil""" """Abstract base class for FileUtil"""
@@ -179,7 +181,6 @@ class FileUtilMacOS(FileUtilABC):
return False return False
s1 = cls._sig(os.stat(f1)) s1 = cls._sig(os.stat(f1))
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG: if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
return False return False
return s1 == s2 return s1 == s2

View File

@@ -15,6 +15,8 @@ from Foundation import NSDictionary
# needed to capture system-level stderr # needed to capture system-level stderr
from wurlitzer import pipes from wurlitzer import pipes
__all__ = ["ImageConversionError", "ImageConverter"]
class ImageConversionError(Exception): class ImageConversionError(Exception):
"""Base class for exceptions in this module.""" """Base class for exceptions in this module."""
@@ -47,10 +49,7 @@ class ImageConverter:
"workingFormat": Quartz.kCIFormatRGBAh, "workingFormat": Quartz.kCIFormatRGBAh,
} }
) )
mtldevice = Metal.MTLCreateSystemDefaultDevice() self.context = Quartz.CIContext.contextWithOptions_(context_options)
self.context = Quartz.CIContext.contextWithMTLDevice_options_(
mtldevice, context_options
)
def write_jpeg(self, input_path, output_path, compression_quality=1.0): def write_jpeg(self, input_path, output_path, compression_quality=1.0):
"""convert image to jpeg and write image to output_path """convert image to jpeg and write image to output_path
@@ -104,9 +103,12 @@ class ImageConverter:
if input_image is None: if input_image is None:
raise ImageConversionError(f"Could not create CIImage for {input_path}") raise ImageConversionError(f"Could not create CIImage for {input_path}")
output_colorspace = input_image.colorSpace() or Quartz.CGColorSpaceCreateWithName( output_colorspace = (
input_image.colorSpace()
or Quartz.CGColorSpaceCreateWithName(
Quartz.CoreGraphics.kCGColorSpaceSRGB Quartz.CoreGraphics.kCGColorSpaceSRGB
) )
)
output_options = NSDictionary.dictionaryWithDictionary_( output_options = NSDictionary.dictionaryWithDictionary_(
{"kCGImageDestinationLossyCompressionQuality": compression_quality} {"kCGImageDestinationLossyCompressionQuality": compression_quality}
@@ -123,4 +125,3 @@ class ImageConverter:
raise ImageConversionError( raise ImageConversionError(
f"Error converting file {input_path} to jpeg at {output_path}: {error}" f"Error converting file {input_path} to jpeg at {output_path}: {error}"
) )

View File

@@ -1,3 +1,4 @@
__all__ = ["MomentInfo"]
"""MomentInfo class with details about photo moments.""" """MomentInfo class with details about photo moments."""

View File

@@ -4,6 +4,14 @@ import pathvalidate
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
__all__ = [
"sanitize_filepath",
"is_valid_filepath",
"sanitize_filename",
"sanitize_dirname",
"sanitize_pathpart",
]
def sanitize_filepath(filepath): def sanitize_filepath(filepath):
"""sanitize a filepath""" """sanitize a filepath"""

View File

@@ -6,6 +6,8 @@ import math
from collections import namedtuple from collections import namedtuple
__all__ = ["PersonInfo", "FaceInfo", "rotate_image_point"]
MWG_RS_Area = namedtuple("MWG_RS_Area", ["x", "y", "h", "w"]) MWG_RS_Area = namedtuple("MWG_RS_Area", ["x", "y", "h", "w"])
MPRI_Reg_Rect = namedtuple("MPRI_Reg_Rect", ["x", "y", "h", "w"]) MPRI_Reg_Rect = namedtuple("MPRI_Reg_Rect", ["x", "y", "h", "w"])

1945
osxphotos/photoexporter.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ from typing import Optional
import yaml import yaml
from osxmetadata import OSXMetaData from osxmetadata import OSXMetaData
from .._constants import ( from ._constants import (
_MOVIE_TYPE, _MOVIE_TYPE,
_PHOTO_TYPE, _PHOTO_TYPE,
_PHOTOS_4_ALBUM_KIND, _PHOTOS_4_ALBUM_KIND,
@@ -35,18 +35,28 @@ from .._constants import (
BURST_KEY, BURST_KEY,
BURST_NOT_SELECTED, BURST_NOT_SELECTED,
BURST_SELECTED, BURST_SELECTED,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
SIDECAR_XMP,
TEXT_DETECTION_CONFIDENCE_THRESHOLD, TEXT_DETECTION_CONFIDENCE_THRESHOLD,
) )
from ..adjustmentsinfo import AdjustmentsInfo from .adjustmentsinfo import AdjustmentsInfo
from ..albuminfo import AlbumInfo, ImportInfo, ProjectInfo from .albuminfo import AlbumInfo, ImportInfo, ProjectInfo
from ..momentinfo import MomentInfo from .exifinfo import ExifInfo
from ..personinfo import FaceInfo, PersonInfo from .exiftool import ExifToolCaching, get_exiftool_path
from ..phototemplate import PhotoTemplate, RenderOptions from .momentinfo import MomentInfo
from ..placeinfo import PlaceInfo4, PlaceInfo5 from .personinfo import FaceInfo, PersonInfo
from ..query_builder import get_query from .photoexporter import ExportOptions, PhotoExporter
from ..text_detection import detect_text from .phototemplate import PhotoTemplate, RenderOptions
from ..uti import get_preferred_uti_extension, get_uti_for_extension from .placeinfo import PlaceInfo4, PlaceInfo5
from ..utils import _debug, _get_resource_loc, findfiles from .query_builder import get_query
from .scoreinfo import ScoreInfo
from .searchinfo import SearchInfo
from .text_detection import detect_text
from .uti import get_preferred_uti_extension, get_uti_for_extension
from .utils import _debug, _get_resource_loc, findfiles
__all__ = ["PhotoInfo", "PhotoInfoNone"]
class PhotoInfo: class PhotoInfo:
@@ -55,42 +65,12 @@ class PhotoInfo:
including keywords, persons, albums, uuid, path, etc. including keywords, persons, albums, uuid, path, etc.
""" """
# import additional methods
from ._photoinfo_comments import comments, likes
from ._photoinfo_exifinfo import ExifInfo, exif_info
from ._photoinfo_exiftool import exiftool
from ._photoinfo_export import (
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,
export,
export2,
)
from ._photoinfo_scoreinfo import ScoreInfo, score
from ._photoinfo_searchinfo import (
SearchInfo,
labels,
labels_normalized,
search_info,
search_info_normalized,
)
def __init__(self, db=None, uuid=None, info=None): def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid self._uuid = uuid
self._info = info self._info = info
self._db = db self._db = db
self._verbose = self._db._verbose self._verbose = self._db._verbose
# TODO: remove this once refactor of PhotoExporter is done
self._render_options = RenderOptions()
@property @property
def filename(self): def filename(self):
"""filename of the picture""" """filename of the picture"""
@@ -750,8 +730,10 @@ class PhotoInfo:
self._uti_original = self.uti self._uti_original = self.uti
elif self._db._photos_ver >= 7: elif self._db._photos_ver >= 7:
# Monterey+ # Monterey+
self._uti_original = get_uti_for_extension( # there are some cases with UTI_original is None (photo imported with no extension) so fallback to UTI and hope it's right
pathlib.Path(self.original_filename).suffix self._uti_original = (
get_uti_for_extension(pathlib.Path(self.original_filename).suffix)
or self.uti
) )
else: else:
self._uti_original = self._info["UTI_original"] self._uti_original = self._info["UTI_original"]
@@ -1050,7 +1032,7 @@ class PhotoInfo:
@property @property
def israw(self): def israw(self):
"""returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw""" """returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw"""
return "raw-image" in self.uti_original return "raw-image" in self.uti_original if self.uti_original else False
@property @property
def raw_original(self): def raw_original(self):
@@ -1140,21 +1122,239 @@ class PhotoInfo:
self._owner = None self._owner = None
return self._owner return self._owner
def render_template( @property
self, template_str: str, options: Optional[RenderOptions] = None def score(self):
): """Computed score information for a photo
"""Renders a template string for PhotoInfo instance using PhotoTemplate
Args:
template_str: a template string with fields to render
options: a RenderOptions instance
Returns: Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values ScoreInfo instance
""" """
options = options or RenderOptions()
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path) if self._db._db_version <= _PHOTOS_4_VERSION:
return template.render(template_str, options) logging.debug(f"score not implemented for this database version")
return None
try:
return self._scoreinfo # pylint: disable=access-member-before-definition
except AttributeError:
try:
scores = self._db._db_scoreinfo_uuid[self.uuid]
self._scoreinfo = ScoreInfo(
overall=scores["overall_aesthetic"],
curation=scores["curation"],
promotion=scores["promotion"],
highlight_visibility=scores["highlight_visibility"],
behavioral=scores["behavioral"],
failure=scores["failure"],
harmonious_color=scores["harmonious_color"],
immersiveness=scores["immersiveness"],
interaction=scores["interaction"],
interesting_subject=scores["interesting_subject"],
intrusive_object_presence=scores["intrusive_object_presence"],
lively_color=scores["lively_color"],
low_light=scores["low_light"],
noise=scores["noise"],
pleasant_camera_tilt=scores["pleasant_camera_tilt"],
pleasant_composition=scores["pleasant_composition"],
pleasant_lighting=scores["pleasant_lighting"],
pleasant_pattern=scores["pleasant_pattern"],
pleasant_perspective=scores["pleasant_perspective"],
pleasant_post_processing=scores["pleasant_post_processing"],
pleasant_reflection=scores["pleasant_reflection"],
pleasant_symmetry=scores["pleasant_symmetry"],
sharply_focused_subject=scores["sharply_focused_subject"],
tastefully_blurred=scores["tastefully_blurred"],
well_chosen_subject=scores["well_chosen_subject"],
well_framed_subject=scores["well_framed_subject"],
well_timed_shot=scores["well_timed_shot"],
)
return self._scoreinfo
except KeyError:
self._scoreinfo = ScoreInfo(
overall=0.0,
curation=0.0,
promotion=0.0,
highlight_visibility=0.0,
behavioral=0.0,
failure=0.0,
harmonious_color=0.0,
immersiveness=0.0,
interaction=0.0,
interesting_subject=0.0,
intrusive_object_presence=0.0,
lively_color=0.0,
low_light=0.0,
noise=0.0,
pleasant_camera_tilt=0.0,
pleasant_composition=0.0,
pleasant_lighting=0.0,
pleasant_pattern=0.0,
pleasant_perspective=0.0,
pleasant_post_processing=0.0,
pleasant_reflection=0.0,
pleasant_symmetry=0.0,
sharply_focused_subject=0.0,
tastefully_blurred=0.0,
well_chosen_subject=0.0,
well_framed_subject=0.0,
well_timed_shot=0.0,
)
return self._scoreinfo
@property
def search_info(self):
"""returns SearchInfo object for photo
only valid on Photos 5, on older libraries, returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
# memoize SearchInfo object
try:
return self._search_info
except AttributeError:
self._search_info = SearchInfo(self)
return self._search_info
@property
def search_info_normalized(self):
"""returns SearchInfo object for photo that produces normalized results
only valid on Photos 5, on older libraries, returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
# memoize SearchInfo object
try:
return self._search_info_normalized
except AttributeError:
self._search_info_normalized = SearchInfo(self, normalized=True)
return self._search_info_normalized
@property
def labels(self):
"""returns list of labels applied to photo by Photos image categorization
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info.labels
@property
def labels_normalized(self):
"""returns normalized list of labels applied to photo by Photos image categorization
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info_normalized.labels
@property
def comments(self):
"""Returns list of Comment objects for any comments on the photo (sorted by date)"""
try:
return self._db._db_comments_uuid[self.uuid]["comments"]
except:
return []
@property
def likes(self):
"""Returns list of Like objects for any likes on the photo (sorted by date)"""
try:
return self._db._db_comments_uuid[self.uuid]["likes"]
except:
return []
@property
def exif_info(self):
"""Returns an ExifInfo object with the EXIF data for photo
Note: the returned EXIF data is the data Photos stores in the database on import;
ExifInfo does not provide access to the EXIF info in the actual image file
Some or all of the fields may be None
Only valid for Photos 5; on earlier database returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"exif_info not implemented for this database version")
return None
try:
exif = self._db._db_exifinfo_uuid[self.uuid]
exif_info = ExifInfo(
iso=exif["ZISO"],
flash_fired=True if exif["ZFLASHFIRED"] == 1 else False,
metering_mode=exif["ZMETERINGMODE"],
sample_rate=exif["ZSAMPLERATE"],
track_format=exif["ZTRACKFORMAT"],
white_balance=exif["ZWHITEBALANCE"],
aperture=exif["ZAPERTURE"],
bit_rate=exif["ZBITRATE"],
duration=exif["ZDURATION"],
exposure_bias=exif["ZEXPOSUREBIAS"],
focal_length=exif["ZFOCALLENGTH"],
fps=exif["ZFPS"],
latitude=exif["ZLATITUDE"],
longitude=exif["ZLONGITUDE"],
shutter_speed=exif["ZSHUTTERSPEED"],
camera_make=exif["ZCAMERAMAKE"],
camera_model=exif["ZCAMERAMODEL"],
codec=exif["ZCODEC"],
lens_model=exif["ZLENSMODEL"],
)
except KeyError:
logging.debug(f"Could not find exif record for uuid {self.uuid}")
exif_info = ExifInfo(
iso=None,
flash_fired=None,
metering_mode=None,
sample_rate=None,
track_format=None,
white_balance=None,
aperture=None,
bit_rate=None,
duration=None,
exposure_bias=None,
focal_length=None,
fps=None,
latitude=None,
longitude=None,
shutter_speed=None,
camera_make=None,
camera_model=None,
codec=None,
lens_model=None,
)
return exif_info
@property
def exiftool(self):
"""Returns a ExifToolCaching (read-only instance of ExifTool) object for the photo.
Requires that exiftool (https://exiftool.org/) be installed
If exiftool not installed, logs warning and returns None
If photo path is missing, returns None
"""
try:
# return the memoized instance if it exists
return self._exiftool
except AttributeError:
try:
exiftool_path = self._db._exiftool_path or get_exiftool_path()
if self.path is not None and os.path.isfile(self.path):
exiftool = ExifToolCaching(self.path, exiftool=exiftool_path)
else:
exiftool = None
except FileNotFoundError:
# get_exiftool_path raises FileNotFoundError if exiftool not found
exiftool = None
logging.warning(
"exiftool not in path; download and install from https://exiftool.org/"
)
self._exiftool = exiftool
return self._exiftool
def detected_text(self, confidence_threshold=TEXT_DETECTION_CONFIDENCE_THRESHOLD): def detected_text(self, confidence_threshold=TEXT_DETECTION_CONFIDENCE_THRESHOLD):
"""Detects text in photo and returns lists of results as (detected text, confidence) """Detects text in photo and returns lists of results as (detected text, confidence)
@@ -1213,6 +1413,128 @@ class PhotoInfo:
"""Returns latitude, in degrees""" """Returns latitude, in degrees"""
return self._info["latitude"] return self._info["latitude"]
def render_template(
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
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, options)
def export(
self,
dest,
filename=None,
edited=False,
live_photo=False,
raw_photo=False,
export_as_hardlink=False,
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_exiftool=False,
sidecar_xmp=False,
use_photos_export=False,
timeout=120,
exiftool=False,
use_albums_as_keywords=False,
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)
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
export will print a warning but will export the photo using the
incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo, otherwise exports the original version
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
overwrite: (boolean, default=False); if True will overwrite files if they already exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
sidecar_xmp: if set will write an XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
returns list of full paths to the exported files
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
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
"""
exporter = PhotoExporter(self)
sidecar = 0
if sidecar_json:
sidecar |= SIDECAR_JSON
if sidecar_exiftool:
sidecar |= SIDECAR_EXIFTOOL
if sidecar_xmp:
sidecar |= SIDECAR_XMP
if not filename:
if not edited:
filename = self.original_filename
else:
original_name = pathlib.Path(self.original_filename)
if self.path_edited:
ext = pathlib.Path(self.path_edited).suffix
else:
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
ext = "." + ext
filename = original_name.stem + "_edited" + ext
options = ExportOptions(
description_template=description_template,
edited=edited,
exiftool=exiftool,
export_as_hardlink=export_as_hardlink,
increment=increment,
keyword_template=keyword_template,
live_photo=live_photo,
overwrite=overwrite,
raw_photo=raw_photo,
render_options=render_options,
sidecar=sidecar,
timeout=timeout,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
use_photos_export=use_photos_export,
)
results = exporter.export(dest, filename=filename, options=options)
return results.exported
def _get_album_uuids(self, project=False): def _get_album_uuids(self, project=False):
"""Return list of album UUIDs this photo is found in """Return list of album UUIDs this photo is found in

View File

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

View File

@@ -1,17 +0,0 @@
""" PhotoInfo methods to expose comments and likes for shared photos """
@property
def comments(self):
""" Returns list of Comment objects for any comments on the photo (sorted by date) """
try:
return self._db._db_comments_uuid[self.uuid]["comments"]
except:
return []
@property
def likes(self):
""" Returns list of Like objects for any likes on the photo (sorted by date) """
try:
return self._db._db_comments_uuid[self.uuid]["likes"]
except:
return []

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -30,11 +30,34 @@ import Photos
import Quartz import Quartz
from Foundation import NSNotificationCenter, NSObject from Foundation import NSNotificationCenter, NSObject
from PyObjCTools import AppHelper from PyObjCTools import AppHelper
from wurlitzer import pipes
from .fileutil import FileUtil from .fileutil import FileUtil
from .uti import get_preferred_uti_extension from .uti import get_preferred_uti_extension
from .utils import _get_os_version, increment_filename from .utils import _get_os_version, increment_filename
__all__ = [
"NSURL_to_path",
"path_to_NSURL",
"check_photokit_authorization",
"request_photokit_authorization",
"PhotoKitError",
"PhotoKitFetchFailed",
"PhotoKitAuthError",
"PhotoKitExportError",
"PhotoKitMediaTypeError",
"ImageData",
"AVAssetData",
"PHAssetResourceData",
"PhotoKitNotificationDelegate",
"PhotoAsset",
"SlowMoVideoExporter",
"VideoAsset",
"LivePhotoRequest",
"LivePhotoAsset",
"PhotoLibrary",
]
# NOTE: This requires user have granted access to the terminal (e.g. Terminal.app or iTerm) # NOTE: This requires user have granted access to the terminal (e.g. Terminal.app or iTerm)
# to access Photos. This should happen automatically the first time it's called. I've # to access Photos. This should happen automatically the first time it's called. I've
# not figured out how to get the call to requestAuthorization_ to actually work in the case # not figured out how to get the call to requestAuthorization_ to actually work in the case
@@ -495,6 +518,7 @@ class PhotoAsset:
""" """
with objc.autorelease_pool(): with objc.autorelease_pool():
with pipes() as (out, err):
filename = ( filename = (
pathlib.Path(filename) pathlib.Path(filename)
if filename if filename
@@ -513,9 +537,13 @@ class PhotoAsset:
# export the raw component # export the raw component
resources = self._resources() resources = self._resources()
for resource in resources: for resource in resources:
if resource.type() == Photos.PHAssetResourceTypeAlternatePhoto: if (
resource.type()
== Photos.PHAssetResourceTypeAlternatePhoto
):
data = self._request_resource_data(resource) data = self._request_resource_data(resource)
ext = pathlib.Path(self.raw_filename).suffix[1:] suffix = pathlib.Path(self.raw_filename).suffix
ext = suffix[1:] if suffix else ""
break break
else: else:
raise PhotoKitExportError( raise PhotoKitExportError(
@@ -807,10 +835,14 @@ class VideoAsset(PhotoAsset):
""" """
with objc.autorelease_pool(): with objc.autorelease_pool():
with pipes() as (out, err):
if self.slow_mo and version == PHOTOS_VERSION_CURRENT: if self.slow_mo and version == PHOTOS_VERSION_CURRENT:
return [ return [
self._export_slow_mo( self._export_slow_mo(
dest, filename=filename, version=version, overwrite=overwrite dest,
filename=filename,
version=version,
overwrite=overwrite,
) )
] ]
@@ -1053,6 +1085,7 @@ class LivePhotoAsset(PhotoAsset):
""" """
with objc.autorelease_pool(): with objc.autorelease_pool():
with pipes() as (out, err):
filename = ( filename = (
pathlib.Path(filename) pathlib.Path(filename)
if filename if filename
@@ -1091,8 +1124,12 @@ class LivePhotoAsset(PhotoAsset):
video_output_file = dest / f"{filename.stem}.{video_ext}" video_output_file = dest / f"{filename.stem}.{video_ext}"
if not overwrite: if not overwrite:
photo_output_file = pathlib.Path(increment_filename(photo_output_file)) photo_output_file = pathlib.Path(
video_output_file = pathlib.Path(increment_filename(video_output_file)) increment_filename(photo_output_file)
)
video_output_file = pathlib.Path(
increment_filename(video_output_file)
)
exported = [] exported = []
if photo: if photo:

View File

@@ -8,6 +8,8 @@ from more_itertools import chunked
from .photoinfo import PhotoInfo from .photoinfo import PhotoInfo
from .utils import noop from .utils import noop
__all__ = ["PhotosAlbum"]
class PhotosAlbum: class PhotosAlbum:
def __init__(self, name: str, verbose: Optional[callable] = None): def __init__(self, name: str, verbose: Optional[callable] = None):

View File

@@ -7,6 +7,7 @@ from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
from ..utils import _db_is_locked, _debug, _open_sql_file from ..utils import _db_is_locked, _debug, _open_sql_file
from .photosdb_utils import get_db_version from .photosdb_utils import get_db_version
def _process_exifinfo(self): def _process_exifinfo(self):
"""load the exif data from the database """load the exif data from the database
this is a PhotosDB method that should be imported in this is a PhotosDB method that should be imported in

View File

@@ -22,8 +22,7 @@ from .photosdb_utils import get_db_version
def _process_faceinfo(self): def _process_faceinfo(self):
""" Process face information """Process face information"""
"""
self._db_faceinfo_pk = {} self._db_faceinfo_pk = {}
self._db_faceinfo_uuid = {} self._db_faceinfo_uuid = {}

View File

@@ -27,11 +27,11 @@ from .._constants import (
_PHOTO_TYPE, _PHOTO_TYPE,
_PHOTOS_3_VERSION, _PHOTOS_3_VERSION,
_PHOTOS_4_ALBUM_KIND, _PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_TOP_LEVEL_ALBUMS,
_PHOTOS_4_ALBUM_TYPE_ALBUM, _PHOTOS_4_ALBUM_TYPE_ALBUM,
_PHOTOS_4_ALBUM_TYPE_PROJECT, _PHOTOS_4_ALBUM_TYPE_PROJECT,
_PHOTOS_4_ALBUM_TYPE_SLIDESHOW, _PHOTOS_4_ALBUM_TYPE_SLIDESHOW,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_TOP_LEVEL_ALBUMS,
_PHOTOS_4_VERSION, _PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND, _PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_FOLDER_KIND, _PHOTOS_5_FOLDER_KIND,
@@ -42,6 +42,7 @@ from .._constants import (
_TESTED_OS_VERSIONS, _TESTED_OS_VERSIONS,
_UNKNOWN_PERSON, _UNKNOWN_PERSON,
BURST_KEY, BURST_KEY,
BURST_PICK_TYPE_NONE,
BURST_SELECTED, BURST_SELECTED,
TIME_DELTA, TIME_DELTA,
) )
@@ -65,6 +66,8 @@ from ..utils import (
) )
from .photosdb_utils import get_db_model_version, get_db_version from .photosdb_utils import get_db_model_version, get_db_version
__all__ = ["PhotosDB"]
# TODO: Add test for imageTimeZoneOffsetSeconds = None # TODO: Add test for imageTimeZoneOffsetSeconds = None
# TODO: Add test for __str__ # TODO: Add test for __str__
# TODO: Add special albums and magic albums # TODO: Add special albums and magic albums
@@ -3062,6 +3065,7 @@ class PhotosDB:
if self._dbphotos[p]["burst"] and not ( if self._dbphotos[p]["burst"] and not (
self._dbphotos[p]["burstPickType"] & BURST_SELECTED self._dbphotos[p]["burstPickType"] & BURST_SELECTED
or self._dbphotos[p]["burstPickType"] & BURST_KEY or self._dbphotos[p]["burstPickType"] & BURST_KEY
or self._dbphotos[p]["burstPickType"] == BURST_PICK_TYPE_NONE
): ):
# not a key/selected burst photo, don't include in returned results # not a key/selected burst photo, don't include in returned results
continue continue

View File

@@ -1,6 +1,7 @@
""" utility functions used by PhotosDB """ """ utility functions used by PhotosDB """
import logging import logging
import pathlib
import plistlib import plistlib
from .._constants import ( from .._constants import (
@@ -15,6 +16,14 @@ from .._constants import (
) )
from ..utils import _open_sql_file from ..utils import _open_sql_file
__all__ = [
"get_db_version",
"get_model_version",
"get_db_model_version",
"UnknownLibraryVersion",
"get_photos_library_version",
]
def get_db_version(db_file): def get_db_version(db_file):
"""Gets the Photos DB version from LiGlobals table """Gets the Photos DB version from LiGlobals table
@@ -104,9 +113,8 @@ def get_photos_library_version(library_path):
return 3 return 3
if db_ver == int(_PHOTOS_4_VERSION): if db_ver == int(_PHOTOS_4_VERSION):
return 4 return 4
if db_ver != int(_PHOTOS_5_VERSION):
raise UnknownLibraryVersion(f"db_ver = {db_ver}")
# assume it's a Photos 5+ library, get the model version to determine which version
model_ver = get_model_version(str(library_path / "database" / "Photos.sqlite")) model_ver = get_model_version(str(library_path / "database" / "Photos.sqlite"))
model_ver = int(model_ver) model_ver = int(model_ver)
if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]: if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]:

View File

@@ -17,11 +17,19 @@ from ._constants import _UNKNOWN_PERSON, TEXT_DETECTION_CONFIDENCE_THRESHOLD
from ._version import __version__ from ._version import __version__
from .datetime_formatter import DateTimeFormatter from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifToolCaching from .exiftool import ExifToolCaching
from .export_db import ExportDB_ABC, ExportDBInMemory
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
from .text_detection import detect_text from .text_detection import detect_text
from .utils import expand_and_validate_filepath, load_function from .utils import expand_and_validate_filepath, load_function
__all__ = [
"RenderOptions",
"PhotoTemplateParser",
"PhotoTemplate",
"parse_default_kv",
"get_template_help",
"format_str_value",
]
# TODO: a lot of values are passed from function to function like path_sep--make these all class properties # 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 # ensure locale set to user's locale
@@ -291,7 +299,6 @@ class RenderOptions:
dest_path: set to the destination path of the photo (for use by {function} template), only valid with --filename dest_path: set to the destination path of the photo (for use by {function} template), only valid with --filename
filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} 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 quote: quote path templates for execution in the shell
exportdb: ExportDB object
""" """
none_str: str = "_" none_str: str = "_"
@@ -306,7 +313,6 @@ class RenderOptions:
dest_path: Optional[str] = None dest_path: Optional[str] = None
filepath: Optional[str] = None filepath: Optional[str] = None
quote: bool = False quote: bool = False
exportdb: Optional[ExportDB_ABC] = None
class PhotoTemplateParser: class PhotoTemplateParser:
@@ -375,7 +381,6 @@ class PhotoTemplate:
self.filepath = options.filepath self.filepath = options.filepath
self.quote = options.quote self.quote = options.quote
self.dest_path = options.dest_path self.dest_path = options.dest_path
self.exportdb = options.exportdb or ExportDBInMemory(None)
def render( def render(
self, self,
@@ -409,7 +414,6 @@ class PhotoTemplate:
self.filepath = options.filepath self.filepath = options.filepath
self.quote = options.quote self.quote = options.quote
self.dest_path = options.dest_path self.dest_path = options.dest_path
self.exportdb = options.exportdb or self.exportdb
try: try:
model = self.parser.parse(template) model = self.parser.parse(template)
@@ -1205,7 +1209,7 @@ class PhotoTemplate:
else: else:
values = list(obj) values = list(obj)
elif field == "detected_text": elif field == "detected_text":
values = _get_detected_text(self.photo, self.exportdb, confidence=subfield) values = _get_detected_text(self.photo, confidence=subfield)
else: else:
raise ValueError(f"Unhandled template value: {field}") raise ValueError(f"Unhandled template value: {field}")
@@ -1448,7 +1452,7 @@ def _get_album_by_path(photo, folder_album_path):
return None return None
def _get_detected_text(photo, exportdb, confidence=TEXT_DETECTION_CONFIDENCE_THRESHOLD): def _get_detected_text(photo, confidence=TEXT_DETECTION_CONFIDENCE_THRESHOLD):
"""Returns the detected text for a photo """Returns the detected text for a photo
{detected_text} uses this instead of PhotoInfo.detected_text() to cache the text for all confidence values {detected_text} uses this instead of PhotoInfo.detected_text() to cache the text for all confidence values
""" """
@@ -1464,5 +1468,4 @@ def _get_detected_text(photo, exportdb, confidence=TEXT_DETECTION_CONFIDENCE_THR
# _detected_text caches the text detection results in an extended attribute # _detected_text caches the text detection results in an extended attribute
# so the first time this gets called is slow but repeated accesses are fast # so the first time this gets called is slow but repeated accesses are fast
detected_text = photo._detected_text() detected_text = photo._detected_text()
exportdb.set_detected_text_for_uuid(photo.uuid, json.dumps(detected_text))
return [text for text, conf in detected_text if conf >= confidence] return [text for text, conf in detected_text if conf >= confidence]

View File

@@ -14,6 +14,16 @@ from bpylist import archiver
from ._constants import UNICODE_FORMAT from ._constants import UNICODE_FORMAT
from .utils import normalize_unicode from .utils import normalize_unicode
__all__ = [
"PLRevGeoLocationInfo",
"PLRevGeoMapItem",
"PLRevGeoMapItemAdditionalPlaceInfo",
"CNPostalAddress",
"PlaceInfo",
"PlaceInfo4",
"PlaceInfo5",
]
# postal address information, returned by PlaceInfo.address # postal address information, returned by PlaceInfo.address
PostalAddress = namedtuple( PostalAddress = namedtuple(
"PostalAddress", "PostalAddress",

View File

@@ -1,3 +1,4 @@
__all__ = ["PyReplQuitter", "embed_repl"]
""" Custom Python REPL based on ptpython that allows quitting with custom keywords instead of `quit()` """ """ Custom Python REPL based on ptpython that allows quitting with custom keywords instead of `quit()` """
""" This file is distributed under the same license as the ptpython package: """ This file is distributed under the same license as the ptpython package:

View File

@@ -8,6 +8,8 @@ from mako.template import Template
from ._constants import _DB_TABLE_NAMES from ._constants import _DB_TABLE_NAMES
__all__ = ["get_query"]
QUERY_DIR = os.path.join(os.path.dirname(__file__), "queries") QUERY_DIR = os.path.join(os.path.dirname(__file__), "queries")

View File

@@ -6,6 +6,8 @@ from typing import Iterable, List, Optional, Tuple
import bitmath import bitmath
__all__ = ["QueryOptions"]
@dataclass @dataclass
class QueryOptions: class QueryOptions:

40
osxphotos/scoreinfo.py Normal file
View File

@@ -0,0 +1,40 @@
""" ScoreInfo class to expose computed score info from the library """
from dataclasses import dataclass
from ._constants import _PHOTOS_4_VERSION
__all__ = ["ScoreInfo"]
@dataclass(frozen=True)
class ScoreInfo:
"""Computed photo score info associated with a photo from the Photos library"""
overall: float
curation: float
promotion: float
highlight_visibility: float
behavioral: float
failure: float
harmonious_color: float
immersiveness: float
interaction: float
interesting_subject: float
intrusive_object_presence: float
lively_color: float
low_light: float
noise: float
pleasant_camera_tilt: float
pleasant_composition: float
pleasant_lighting: float
pleasant_pattern: float
pleasant_perspective: float
pleasant_post_processing: float
pleasant_reflection: float
pleasant_symmetry: float
sharply_focused_subject: float
tastefully_blurred: float
well_chosen_subject: float
well_framed_subject: float
well_timed_shot: float

View File

@@ -1,86 +1,29 @@
""" Methods and class for PhotoInfo exposing SearchInfo data such as labels """ class for PhotoInfo exposing SearchInfo data such as labels
Adds the following properties to PhotoInfo (valid only for Photos 5):
search_info: returns a SearchInfo object
search_info_normalized: returns a SearchInfo object with properties that produce normalized results
labels: returns list of labels
labels_normalized: returns list of normalized labels
""" """
from .._constants import ( from ._constants import (
_PHOTOS_4_VERSION, _PHOTOS_4_VERSION,
SEARCH_CATEGORY_ACTIVITY,
SEARCH_CATEGORY_ALL_LOCALITY,
SEARCH_CATEGORY_BODY_OF_WATER,
SEARCH_CATEGORY_CITY, SEARCH_CATEGORY_CITY,
SEARCH_CATEGORY_COUNTRY,
SEARCH_CATEGORY_HOLIDAY,
SEARCH_CATEGORY_LABEL, SEARCH_CATEGORY_LABEL,
SEARCH_CATEGORY_MEDIA_TYPES,
SEARCH_CATEGORY_MONTH,
SEARCH_CATEGORY_NEIGHBORHOOD, SEARCH_CATEGORY_NEIGHBORHOOD,
SEARCH_CATEGORY_PLACE_NAME, SEARCH_CATEGORY_PLACE_NAME,
SEARCH_CATEGORY_STREET, SEARCH_CATEGORY_SEASON,
SEARCH_CATEGORY_ALL_LOCALITY,
SEARCH_CATEGORY_COUNTRY,
SEARCH_CATEGORY_STATE, SEARCH_CATEGORY_STATE,
SEARCH_CATEGORY_STATE_ABBREVIATION, SEARCH_CATEGORY_STATE_ABBREVIATION,
SEARCH_CATEGORY_BODY_OF_WATER, SEARCH_CATEGORY_STREET,
SEARCH_CATEGORY_MONTH,
SEARCH_CATEGORY_YEAR,
SEARCH_CATEGORY_HOLIDAY,
SEARCH_CATEGORY_ACTIVITY,
SEARCH_CATEGORY_SEASON,
SEARCH_CATEGORY_VENUE, SEARCH_CATEGORY_VENUE,
SEARCH_CATEGORY_VENUE_TYPE, SEARCH_CATEGORY_VENUE_TYPE,
SEARCH_CATEGORY_MEDIA_TYPES, SEARCH_CATEGORY_YEAR,
) )
__all__ = ["SearchInfo"]
@property
def search_info(self):
""" returns SearchInfo object for photo
only valid on Photos 5, on older libraries, returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
# memoize SearchInfo object
try:
return self._search_info
except AttributeError:
self._search_info = SearchInfo(self)
return self._search_info
@property
def search_info_normalized(self):
""" returns SearchInfo object for photo that produces normalized results
only valid on Photos 5, on older libraries, returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
# memoize SearchInfo object
try:
return self._search_info_normalized
except AttributeError:
self._search_info_normalized = SearchInfo(self, normalized=True)
return self._search_info_normalized
@property
def labels(self):
""" returns list of labels applied to photo by Photos image categorization
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info.labels
@property
def labels_normalized(self):
""" returns normalized list of labels applied to photo by Photos image categorization
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info_normalized.labels
class SearchInfo: class SearchInfo:
@@ -92,7 +35,7 @@ class SearchInfo:
if photo._db._db_version <= _PHOTOS_4_VERSION: if photo._db._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError( raise NotImplementedError(
f"search info not implemented for this database version" "search info not implemented for this database version"
) )
self._photo = photo self._photo = photo

57
osxphotos/sqlgrep.py Normal file
View File

@@ -0,0 +1,57 @@
"""Search through a sqlite database file for a given string"""
import re
import sqlite3
from typing import Generator, List
__all__ = ["sqlgrep"]
def sqlgrep(
filename: str,
pattern: str,
ignore_case: bool = False,
print_filename: bool = True,
rich_markup: bool = False,
) -> Generator[List[str], None, None]:
"""grep through a sqlite database file for a given string
Args:
filename (str): The filename of the sqlite database file
pattern (str): The pattern to search for
ignore_case (bool, optional): Ignore case when searching. Defaults to False.
print_filename (bool, optional): include the filename of the file with table name. Defaults to True.
rich_markup (bool, optional): Add rich markup to mark found text in bold. Defaults to False.
Returns:
Generator which yields list of [table, column, row_id, value]
"""
flags = re.IGNORECASE if ignore_case else 0
try:
with sqlite3.connect(f"file:{filename}?mode=ro", uri=True) as conn:
regex = re.compile(r"(" + pattern + r")", flags=flags)
filename_header = f"{filename}: " if print_filename else ""
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
for tablerow in cursor.fetchall():
table = tablerow[0]
cursor.execute("SELECT * FROM {t}".format(t=table))
for row_num, row in enumerate(cursor):
for field in row.keys():
field_value = row[field]
if not field_value or type(field_value) == bytes:
# don't search binary blobs
next
field_value = str(field_value)
if re.search(pattern, field_value, flags=flags):
if rich_markup:
field_value = regex.sub(r"[bold]\1[/bold]", field_value)
yield [
f"{filename_header}{table}",
field,
str(row_num),
field_value,
]
except sqlite3.DatabaseError as e:
raise sqlite3.DatabaseError(f"{filename}: {e}")

View File

@@ -13,6 +13,8 @@ from wurlitzer import pipes
from .utils import _get_os_version from .utils import _get_os_version
__all__ = ["detect_text", "make_request_handler"]
ver, major, minor = _get_os_version() ver, major, minor = _get_os_version()
if ver == "10" and int(major) < 15: if ver == "10" and int(major) < 15:
vision = False vision = False

View File

@@ -1,3 +1,4 @@
__all__ = ["get_preferred_uti_extension", "get_uti_for_extension"]
""" get UTI for a given file extension and the preferred extension for a given UTI """ """ get UTI for a given file extension and the preferred extension for a given UTI """
""" Implementation note: runs only on macOS """ Implementation note: runs only on macOS
@@ -591,6 +592,9 @@ def get_preferred_uti_extension(uti):
def get_uti_for_extension(extension): def get_uti_for_extension(extension):
"""get UTI for a given file extension""" """get UTI for a given file extension"""
if not extension:
return None
# accepts extension with or without leading 0 # accepts extension with or without leading 0
if extension[0] == ".": if extension[0] == ".":
extension = extension[1:] extension = extension[1:]

View File

@@ -16,14 +16,31 @@ import sys
import unicodedata import unicodedata
import urllib.parse import urllib.parse
from plistlib import load as plistload from plistlib import load as plistload
from typing import Callable, Union from typing import Callable, List, Union
import CoreFoundation import CoreFoundation
import objc import objc
from Foundation import NSString from Foundation import NSFileManager, NSPredicate, NSString
from ._constants import UNICODE_FORMAT from ._constants import UNICODE_FORMAT
__all__ = [
"dd_to_dms_str",
"expand_and_validate_filepath",
"findfiles",
"get_last_library_path",
"get_system_library_path",
"increment_filename_with_count",
"increment_filename",
"lineno",
"list_directory",
"list_photo_libraries",
"load_function",
"noop",
"normalize_fs_path",
"normalize_unicode",
]
_DEBUG = False _DEBUG = False
@@ -248,7 +265,7 @@ def list_photo_libraries():
# On older MacOS versions, mdfind appears to ignore some libraries # On older MacOS versions, mdfind appears to ignore some libraries
# glob to find libraries in ~/Pictures then mdfind to find all the others # glob to find libraries in ~/Pictures then mdfind to find all the others
# TODO: make this more robust # TODO: make this more robust
lib_list = glob.glob(f"{str(pathlib.Path.home())}/Pictures/*.photoslibrary") lib_list = glob.glob(f"{pathlib.Path.home()}/Pictures/*.photoslibrary")
# On older OS, may not get all libraries so make sure we get the last one # On older OS, may not get all libraries so make sure we get the last one
last_lib = get_last_library_path() last_lib = get_last_library_path()
@@ -267,26 +284,34 @@ def list_photo_libraries():
def normalize_fs_path(path: str) -> str: def normalize_fs_path(path: str) -> str:
"""Normalize filesystem paths with unicode in them""" """Normalize filesystem paths with unicode in them"""
with objc.autorelease_pool(): # macOS HFS+ uses NFD, APFS doesn't normalize but stick with NFD
normalized_path = NSString.fileSystemRepresentation(path) # ref: https://eclecticlight.co/2021/05/08/explainer-unicode-normalization-and-apfs/
return normalized_path.decode("utf8") return unicodedata.normalize("NFD", path)
def findfiles(pattern, path_): def findfiles(pattern, path):
"""Returns list of filenames from path_ matched by pattern """Returns list of filenames from path matched by pattern
shell pattern. Matching is case-insensitive. shell pattern. Matching is case-insensitive.
If 'path_' is invalid/doesn't exist, returns [].""" If 'path_' is invalid/doesn't exist, returns []."""
if not os.path.isdir(path_): if not os.path.isdir(path):
return [] return []
# See: https://gist.github.com/techtonik/5694830
# paths need to be normalized for unicode as filesystem returns unicode in NFD form # paths need to be normalized for unicode as filesystem returns unicode in NFD form
pattern = normalize_fs_path(pattern) pattern = normalize_fs_path(pattern)
rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE) rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
files = [normalize_fs_path(p) for p in os.listdir(path_)] files = os.listdir(path)
return [name for name in files if rule.match(name)] return [name for name in files if rule.match(name)]
def list_directory_startswith(directory_path: str, startswith: str) -> List[str]:
"""List directory contents and return list of files starting with startswith; returns [] if directory doesn't exist"""
if not os.path.isdir(directory_path):
return []
startswith = normalize_fs_path(startswith)
files = [normalize_fs_path(f) for f in os.listdir(directory_path)]
return [f for f in files if f.startswith(startswith)]
def _open_sql_file(dbname): def _open_sql_file(dbname):
"""opens sqlite file dbname in read-only mode """opens sqlite file dbname in read-only mode
returns tuple of (connection, cursor)""" returns tuple of (connection, cursor)"""
@@ -325,47 +350,21 @@ def _db_is_locked(dbname):
return locked return locked
# OSXPHOTOS_XATTR_UUID = "com.osxphotos.uuid"
# def get_uuid_for_file(filepath):
# """ returns UUID associated with an exported file
# filepath: path to exported photo
# """
# attr = xattr.xattr(filepath)
# try:
# uuid_bytes = attr[OSXPHOTOS_XATTR_UUID]
# uuid_str = uuid_bytes.decode('utf-8')
# except KeyError:
# uuid_str = None
# return uuid_str
# def set_uuid_for_file(filepath, uuid):
# """ sets the UUID associated with an exported file
# filepath: path to exported photo
# uuid: uuid string for photo
# """
# if not os.path.exists(filepath):
# raise FileNotFoundError(f"Missing file: {filepath}")
# attr = xattr.xattr(filepath)
# uuid_bytes = bytes(uuid, 'utf-8')
# attr.set(OSXPHOTOS_XATTR_UUID, uuid_bytes)
def normalize_unicode(value): def normalize_unicode(value):
"""normalize unicode data""" """normalize unicode data"""
if value is not None: if value is None:
return None
if isinstance(value, (tuple, list)): if isinstance(value, (tuple, list)):
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value) return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
elif isinstance(value, str): elif isinstance(value, str):
return unicodedata.normalize(UNICODE_FORMAT, value) return unicodedata.normalize(UNICODE_FORMAT, value)
else: else:
return value return value
else:
return None
def increment_filename_with_count(filepath: Union[str,pathlib.Path], count: int = 0) -> str: def increment_filename_with_count(
filepath: Union[str, pathlib.Path], count: int = 0
) -> str:
"""Return filename (1).ext, etc if filename.ext exists """Return filename (1).ext, etc if filename.ext exists
If file exists in filename's parent folder with same stem as filename, If file exists in filename's parent folder with same stem as filename,
@@ -381,16 +380,16 @@ def increment_filename_with_count(filepath: Union[str,pathlib.Path], count: int
Note: This obviously is subject to race condition so using with caution. Note: This obviously is subject to race condition so using with caution.
""" """
dest = filepath if isinstance(filepath, pathlib.Path) else pathlib.Path(filepath) dest = filepath if isinstance(filepath, pathlib.Path) else pathlib.Path(filepath)
dest_files = findfiles(f"{dest.stem}*", str(dest.parent)) dest_files = list_directory_startswith(str(dest.parent), dest.stem)
dest_files = [normalize_fs_path(pathlib.Path(f).stem.lower()) for f in dest_files] dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = dest.stem dest_new = f"{dest.stem} ({count})" if count else dest.stem
if count: dest_new = normalize_fs_path(dest_new)
dest_new = f"{dest.stem} ({count})"
while normalize_fs_path(dest_new.lower()) in dest_files: while dest_new.lower() in dest_files:
count += 1 count += 1
dest_new = f"{dest.stem} ({count})" dest_new = normalize_fs_path(f"{dest.stem} ({count})")
dest = dest.parent / f"{dest_new}{dest.suffix}" dest = dest.parent / f"{dest_new}{dest.suffix}"
return str(dest), count return normalize_fs_path(str(dest)), count
def increment_filename(filepath: Union[str, pathlib.Path]) -> str: def increment_filename(filepath: Union[str, pathlib.Path]) -> str:

25
tests/test___all__.py Normal file
View File

@@ -0,0 +1,25 @@
import re
import sys
from os import walk
from collections import Counter
FILE_PATTERN = "^(?!_).*\.py$"
SOUCE_CODE_ROOT = "osxphotos"
def create_module_name(dirpath: str, filename: str) -> str:
prefix = dirpath[dirpath.rfind(SOUCE_CODE_ROOT):].replace('/', '.')
return f"{prefix}.{filename}".replace(".py", "")
def test_check_duplicate():
for dirpath, dirnames, filenames in walk(SOUCE_CODE_ROOT):
print("\n", sys.modules)
for filename in filenames:
if re.search(FILE_PATTERN, filename):
module = create_module_name(dirpath, filename)
if module in sys.modules:
all_list = sys.modules[module].__all__
all_set = set(all_list)
assert Counter(all_list) == Counter(all_set)

View File

@@ -13,6 +13,7 @@ import pytest
import osxphotos import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.photoexporter import PhotoExporter
from osxphotos.utils import _get_os_version from osxphotos.utils import _get_os_version
OS_VERSION = _get_os_version() OS_VERSION = _get_os_version()
@@ -1142,6 +1143,7 @@ def test_date_invalid():
"""Test date is invalid""" """Test date is invalid"""
# doesn't run correctly with the module-level fixture # doesn't run correctly with the module-level fixture
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import osxphotos import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
@@ -1396,7 +1398,7 @@ def test_exiftool_newlines_in_description(photosdb):
"""Test that exiftool handles newlines embedded in description, issue #393""" """Test that exiftool handles newlines embedded in description, issue #393"""
photo = photosdb.get_photo(UUID_DICT["description_newlines"]) photo = photosdb.get_photo(UUID_DICT["description_newlines"])
exif = photo._exiftool_dict() exif = PhotoExporter(photo)._exiftool_dict()
assert photo.description.find("\n") > 0 assert photo.description.find("\n") > 0
assert exif["EXIF:ImageDescription"].find("\n") > 0 assert exif["EXIF:ImageDescription"].find("\n") > 0

View File

@@ -768,7 +768,10 @@ CLI_EXPORT_UUID_FROM_FILE_FILENAMES = [
"wedding_edited.jpeg", "wedding_edited.jpeg",
] ]
CLI_EXPORT_SKIP_UUID = ["E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"] CLI_EXPORT_SKIP_UUID = [
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51",
"6191423D-8DB8-4D4C-92BE-9BBBA308AAC4",
]
CLI_EXPORT_SKIP_UUID_FILENAMES = [ CLI_EXPORT_SKIP_UUID_FILENAMES = [
"Tulips.jpg", "Tulips.jpg",
"Tulips_edited.jpeg", "Tulips_edited.jpeg",
@@ -903,6 +906,14 @@ QUERY_EXIF_DATA_CASE_INSENSITIVE = [
] ]
EXPORT_EXIF_DATA = [("EXIF:Make", "FUJIFILM", ["Tulips.jpg", "Tulips_edited.jpeg"])] EXPORT_EXIF_DATA = [("EXIF:Make", "FUJIFILM", ["Tulips.jpg", "Tulips_edited.jpeg"])]
UUID_LIVE_EDITED = "136A78FA-1B90-46CC-88A7-CCA3331F0353" # IMG_4813.HEIC
CLI_EXPORT_LIVE_EDITED = [
"IMG_4813.HEIC",
"IMG_4813.mov",
"IMG_4813_edited.jpeg",
"IMG_4813_edited.mov",
]
def modify_file(filename): def modify_file(filename):
"""appends data to a file to modify it""" """appends data to a file to modify it"""
@@ -1268,7 +1279,8 @@ def test_query_duplicate():
runner = CliRunner() runner = CliRunner()
cwd = os.getcwd() cwd = os.getcwd()
result = runner.invoke( result = runner.invoke(
query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--duplicate"], query,
["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--duplicate"],
) )
assert result.exit_code == 0 assert result.exit_code == 0
@@ -1289,7 +1301,8 @@ def test_query_location():
runner = CliRunner() runner = CliRunner()
cwd = os.getcwd() cwd = os.getcwd()
result = runner.invoke( result = runner.invoke(
query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--location"], query,
["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--location"],
) )
assert result.exit_code == 0 assert result.exit_code == 0
@@ -1311,7 +1324,8 @@ def test_query_no_location():
runner = CliRunner() runner = CliRunner()
cwd = os.getcwd() cwd = os.getcwd()
result = runner.invoke( result = runner.invoke(
query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--no-location"], query,
["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--no-location"],
) )
assert result.exit_code == 0 assert result.exit_code == 0
@@ -1432,6 +1446,7 @@ def test_export_uuid_from_file():
files = glob.glob("*") files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_UUID_FROM_FILE_FILENAMES) assert sorted(files) == sorted(CLI_EXPORT_UUID_FROM_FILE_FILENAMES)
def test_export_skip_uuid_from_file(): def test_export_skip_uuid_from_file():
"""Test export with --skip-uuid-from-file""" """Test export with --skip-uuid-from-file"""
import glob import glob
@@ -1460,6 +1475,7 @@ def test_export_skip_uuid_from_file():
for skipped_file in CLI_EXPORT_SKIP_UUID_FILENAMES: for skipped_file in CLI_EXPORT_SKIP_UUID_FILENAMES:
assert skipped_file not in files assert skipped_file not in files
def test_export_skip_uuid(): def test_export_skip_uuid():
"""Test export with --skip-uuid""" """Test export with --skip-uuid"""
import glob import glob
@@ -4277,7 +4293,7 @@ def test_export_deleted_only_2():
def test_export_error(monkeypatch): def test_export_error(monkeypatch):
"""Test that export catches errors thrown by export2""" """Test that export catches errors thrown by export"""
# Note: I often comment out the try/except block in cli.py::export_photo_with_template when # Note: I often comment out the try/except block in cli.py::export_photo_with_template when
# debugging to see exactly where the error is # debugging to see exactly where the error is
# this test verifies I've re-enabled that code # this test verifies I've re-enabled that code
@@ -4291,7 +4307,7 @@ def test_export_error(monkeypatch):
def throw_error(*args, **kwargs): def throw_error(*args, **kwargs):
raise ValueError("Argh!") raise ValueError("Argh!")
monkeypatch.setattr(osxphotos.PhotoInfo, "export2", throw_error) monkeypatch.setattr(osxphotos.PhotoExporter, "export", throw_error)
with runner.isolated_filesystem(): with runner.isolated_filesystem():
result = runner.invoke( result = runner.invoke(
export, export,
@@ -4665,6 +4681,32 @@ def test_export_update_basic():
) )
@pytest.mark.skipif(
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
reason="Skip if not running on author's personal library.",
)
def test_export_live_edited():
"""test export of edited live image #576"""
import glob
import os
import os.path
from osxphotos.cli import OSXPHOTOS_EXPORT_DB, export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# basic export
result = runner.invoke(
export,
[os.path.join(cwd, PHOTOS_DB_RHET), ".", "-V", "--uuid", UUID_LIVE_EDITED],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_LIVE_EDITED)
def test_export_update_child_folder(): def test_export_update_child_folder():
"""test export then update into a child folder of previous export""" """test export then update into a child folder of previous export"""
import glob import glob
@@ -5610,7 +5652,7 @@ def test_export_ignore_signature_sidecar():
# change the sidecar data in export DB # change the sidecar data in export DB
# should result in a new sidecar being exported but not the image itself # should result in a new sidecar being exported but not the image itself
exportdb = osxphotos.export_db.ExportDB("./.osxphotos_export.db") exportdb = osxphotos.export_db.ExportDB("./.osxphotos_export.db", ".")
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES: for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
exportdb.set_sidecar_for_file(f"{filename}.xmp", "FOO", (0, 1, 2)) exportdb.set_sidecar_for_file(f"{filename}.xmp", "FOO", (0, 1, 2))
@@ -6163,16 +6205,6 @@ def test_export_exportdb():
in result.output in result.output
) )
# specify a path for exportdb, should generate error
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "./export.db"],
)
assert result.exit_code != 0
assert (
"Error: --exportdb must be specified as filename not path" in result.output
)
def test_export_finder_tag_keywords(): def test_export_finder_tag_keywords():
"""test --finder-tag-keywords""" """test --finder-tag-keywords"""
@@ -6836,7 +6868,78 @@ def test_export_download_missing_file_exists():
], ],
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert "exported: 1" in result.output assert "skipped: 1" in result.output
@pytest.mark.skipif(
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
reason="Skip if not running on author's personal library.",
)
def test_export_download_missing_preview():
"""test --download-missing --preview, #564"""
import glob
import os
import os.path
import pathlib
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_RHET),
".",
"-V",
"--uuid",
UUID_DOWNLOAD_MISSING,
"--download-missing",
"--use-photos-export",
"--use-photokit",
"--preview",
],
)
assert result.exit_code == 0
assert "exported: 2" in result.output
@pytest.mark.skipif(
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
reason="Skip if not running on author's personal library.",
)
def test_export_download_missing_preview_applesccript():
"""test --download-missing --preview and applescript download, #564"""
import glob
import os
import os.path
import pathlib
from osxphotos.cli import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_RHET),
".",
"-V",
"--uuid",
UUID_DOWNLOAD_MISSING,
"--download-missing",
"--use-photos-export",
"--preview",
],
)
assert result.exit_code == 0
assert "exported: 2" in result.output
@pytest.mark.skipif( @pytest.mark.skipif(
@@ -7652,6 +7755,7 @@ def test_export_query_function():
def test_export_album_seq(): def test_export_album_seq():
"""Test {album_seq} template""" """Test {album_seq} template"""
import glob import glob
from osxphotos.cli import cli from osxphotos.cli import cli
runner = CliRunner() runner = CliRunner()
@@ -7729,7 +7833,6 @@ def test_export_description_template_conditional():
import osxphotos import osxphotos
from osxphotos.cli import cli from osxphotos.cli import cli
from osxphotos.exiftool import ExifTool from osxphotos.exiftool import ExifTool
import json
runner = CliRunner() runner = CliRunner()
cwd = os.getcwd() cwd = os.getcwd()

View File

@@ -2,7 +2,7 @@
import pytest import pytest
from osxphotos.photoinfo import ExifInfo from osxphotos.exifinfo import ExifInfo
PHOTOS_DB_5 = "tests/Test-Cloud-10.15.1.photoslibrary" PHOTOS_DB_5 = "tests/Test-Cloud-10.15.1.photoslibrary"
PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary" PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary"

View File

@@ -8,6 +8,7 @@ import pytest
import osxphotos import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.exiftool import get_exiftool_path from osxphotos.exiftool import get_exiftool_path
from osxphotos.photoexporter import ExportOptions, PhotoExporter
from osxphotos.utils import dd_to_dms_str from osxphotos.utils import dd_to_dms_str
# determine if exiftool installed so exiftool tests can be skipped # determine if exiftool installed so exiftool tests can be skipped
@@ -320,7 +321,9 @@ def test_export_12(photosdb):
edited_name = pathlib.Path(photos[0].path_edited).name edited_name = pathlib.Path(photos[0].path_edited).name
edited_suffix = pathlib.Path(edited_name).suffix edited_suffix = pathlib.Path(edited_name).suffix
filename = pathlib.Path(photos[0].original_filename).stem + "_edited" + edited_suffix filename = (
pathlib.Path(photos[0].original_filename).stem + "_edited" + edited_suffix
)
expected_dest = os.path.join(dest, filename) expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest, edited=True)[0] got_dest = photos[0].export(dest, edited=True)[0]
@@ -401,7 +404,7 @@ def test_exiftool_json_sidecar(photosdb):
with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp: with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp:
json_expected = json.load(fp)[0] json_expected = json.load(fp)[0]
json_got = photo._exiftool_json_sidecar() json_got = PhotoExporter(photo)._exiftool_json_sidecar()
json_got = json.loads(json_got)[0] json_got = json.loads(json_got)[0]
assert json_got == json_expected assert json_got == json_expected
@@ -417,7 +420,9 @@ def test_exiftool_json_sidecar_ignore_date_modified(photosdb):
) as fp: ) as fp:
json_expected = json.load(fp)[0] json_expected = json.load(fp)[0]
json_got = photo._exiftool_json_sidecar(ignore_date_modified=True) json_got = PhotoExporter(photo)._exiftool_json_sidecar(
ExportOptions(ignore_date_modified=True)
)
json_got = json.loads(json_got)[0] json_got = json.loads(json_got)[0]
assert json_got == json_expected assert json_got == json_expected
@@ -448,7 +453,9 @@ def test_exiftool_json_sidecar_keyword_template_long(capsys, photosdb):
long_str = "x" * (_MAX_IPTC_KEYWORD_LEN + 1) long_str = "x" * (_MAX_IPTC_KEYWORD_LEN + 1)
photos[0]._verbose = print photos[0]._verbose = print
json_got = photos[0]._exiftool_json_sidecar(keyword_template=[long_str]) json_got = PhotoExporter(photos[0])._exiftool_json_sidecar(
ExportOptions(keyword_template=[long_str])
)
json_got = json.loads(json_got)[0] json_got = json.loads(json_got)[0]
captured = capsys.readouterr() captured = capsys.readouterr()
@@ -483,7 +490,9 @@ def test_exiftool_json_sidecar_keyword_template(photosdb):
str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.json"), "r" str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.json"), "r"
) as fp: ) as fp:
json_expected = json.load(fp) json_expected = json.load(fp)
json_got = photo._exiftool_json_sidecar(keyword_template=["{folder_album}"]) json_got = PhotoExporter(photo)._exiftool_json_sidecar(
ExportOptions(keyword_template=["{folder_album}"])
)
json_got = json.loads(json_got) json_got = json.loads(json_got)
assert json_got == json_expected assert json_got == json_expected
@@ -499,7 +508,9 @@ def test_exiftool_json_sidecar_use_persons_keyword(photosdb):
) as fp: ) as fp:
json_expected = json.load(fp)[0] json_expected = json.load(fp)[0]
json_got = photo._exiftool_json_sidecar(use_persons_as_keywords=True) json_got = PhotoExporter(photo)._exiftool_json_sidecar(
ExportOptions(use_persons_as_keywords=True)
)
json_got = json.loads(json_got)[0] json_got = json.loads(json_got)[0]
assert json_got == json_expected assert json_got == json_expected
@@ -515,7 +526,9 @@ def test_exiftool_json_sidecar_use_albums_keywords(photosdb):
) as fp: ) as fp:
json_expected = json.load(fp) json_expected = json.load(fp)
json_got = photo._exiftool_json_sidecar(use_albums_as_keywords=True) json_got = PhotoExporter(photo)._exiftool_json_sidecar(
ExportOptions(use_albums_as_keywords=True)
)
json_got = json.loads(json_got) json_got = json.loads(json_got)
assert json_got == json_expected assert json_got == json_expected
@@ -528,7 +541,7 @@ def test_exiftool_sidecar(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_no_tag_groups.json", "r") as fp: with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_no_tag_groups.json", "r") as fp:
json_expected = fp.read() json_expected = fp.read()
json_got = photo._exiftool_json_sidecar(tag_groups=False) json_got = PhotoExporter(photo)._exiftool_json_sidecar(tag_groups=False)
assert json_got == json_expected assert json_got == json_expected
@@ -554,7 +567,7 @@ def test_xmp_sidecar(photosdb):
with open(f"tests/sidecars/{uuid}.xmp", "r") as file: with open(f"tests/sidecars/{uuid}.xmp", "r") as file:
xmp_expected = file.read() xmp_expected = file.read()
xmp_got = photos[0]._xmp_sidecar(extension="jpg") xmp_got = PhotoExporter(photos[0])._xmp_sidecar(extension="jpg")
assert xmp_got == xmp_expected assert xmp_got == xmp_expected
@@ -568,7 +581,7 @@ def test_xmp_sidecar_extension(photosdb):
xmp_expected = file.read() xmp_expected = file.read()
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")] xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
xmp_got = photos[0]._xmp_sidecar() xmp_got = PhotoExporter(photos[0])._xmp_sidecar()
assert xmp_got == xmp_expected assert xmp_got == xmp_expected
@@ -580,7 +593,9 @@ def test_xmp_sidecar_use_persons_keyword(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_persons_as_keywords.xmp") as fp: with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_persons_as_keywords.xmp") as fp:
xmp_expected = fp.read() xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar(use_persons_as_keywords=True, extension="jpg") xmp_got = PhotoExporter(photo)._xmp_sidecar(
ExportOptions(use_persons_as_keywords=True), extension="jpg"
)
assert xmp_got == xmp_expected assert xmp_got == xmp_expected
@@ -592,7 +607,9 @@ def test_xmp_sidecar_use_albums_keyword(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_albums_as_keywords.xmp") as fp: with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_albums_as_keywords.xmp") as fp:
xmp_expected = fp.read() xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar(use_albums_as_keywords=True, extension="jpg") xmp_got = PhotoExporter(photo)._xmp_sidecar(
ExportOptions(use_albums_as_keywords=True), extension="jpg"
)
assert xmp_got == xmp_expected assert xmp_got == xmp_expected
@@ -605,7 +622,7 @@ def test_xmp_sidecar_gps(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}.xmp") as fp: with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}.xmp") as fp:
xmp_expected = fp.read() xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar() xmp_got = PhotoExporter(photo)._xmp_sidecar()
assert xmp_got == xmp_expected assert xmp_got == xmp_expected
@@ -617,8 +634,8 @@ def test_xmp_sidecar_keyword_template(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.xmp") as fp: with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.xmp") as fp:
xmp_expected = fp.read() xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar( xmp_got = PhotoExporter(photo)._xmp_sidecar(
keyword_template=["{created.year}", "{folder_album}"], extension="jpg" ExportOptions(keyword_template=["{created.year}", "{folder_album}"]),
extension="jpg",
) )
assert xmp_got == xmp_expected assert xmp_got == xmp_expected

View File

@@ -73,7 +73,6 @@ def test_export_default_name(photosdb):
filename = photos[0].original_filename filename = photos[0].original_filename
expected_dest = pathlib.Path(dest) / filename expected_dest = pathlib.Path(dest) / filename
expected_dest = expected_dest.parent / f"{expected_dest.stem}.jpeg"
got_dest = photos[0].export(dest, use_photos_export=True)[0] got_dest = photos[0].export(dest, use_photos_export=True)[0]
assert got_dest == str(expected_dest) assert got_dest == str(expected_dest)

View File

@@ -1,7 +1,9 @@
import os import os
import pytest import pytest
from osxphotos._constants import _UNKNOWN_PERSON from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.photoexporter import ExportOptions, PhotoExporter
skip_test = "OSXPHOTOS_TEST_CONVERT" not in os.environ skip_test = "OSXPHOTOS_TEST_CONVERT" not in os.environ
pytestmark = pytest.mark.skipif( pytestmark = pytest.mark.skipif(
@@ -15,16 +17,10 @@ UUID_DICT = {
"heic": "7783E8E6-9CAC-40F3-BE22-81FB7051C266", "heic": "7783E8E6-9CAC-40F3-BE22-81FB7051C266",
} }
NAMES_DICT = { NAMES_DICT = {"raw": "DSC03584.jpeg", "heic": "IMG_3092.jpeg"}
"raw": "DSC03584.jpeg",
"heic": "IMG_3092.jpeg"
}
UUID_LIVE_HEIC = "8EC216A2-0032-4934-BD3F-04C6259B3304" UUID_LIVE_HEIC = "8EC216A2-0032-4934-BD3F-04C6259B3304"
NAMES_LIVE_HEIC = [ NAMES_LIVE_HEIC = ["IMG_3259.jpeg", "IMG_3259.mov"]
"IMG_3259.jpeg",
"IMG_3259.mov"
]
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
@@ -43,7 +39,8 @@ def test_export_convert_raw_to_jpeg(photosdb):
dest = tempdir.name dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["raw"]]) photos = photosdb.photos(uuid=[UUID_DICT["raw"]])
results = photos[0].export2(dest, convert_to_jpeg=True) export_options = ExportOptions(convert_to_jpeg=True)
results = PhotoExporter(photos[0]).export(dest, options=export_options)
got_dest = pathlib.Path(results.exported[0]) got_dest = pathlib.Path(results.exported[0])
assert got_dest.is_file() assert got_dest.is_file()
@@ -60,7 +57,8 @@ def test_export_convert_heic_to_jpeg(photosdb):
dest = tempdir.name dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["heic"]]) photos = photosdb.photos(uuid=[UUID_DICT["heic"]])
results = photos[0].export2(dest, convert_to_jpeg=True) export_options = ExportOptions(convert_to_jpeg=True)
results = PhotoExporter(photos[0]).export(dest, options=export_options)
got_dest = pathlib.Path(results.exported[0]) got_dest = pathlib.Path(results.exported[0])
assert got_dest.is_file() assert got_dest.is_file()
@@ -87,7 +85,8 @@ def test_export_convert_live_heic_to_jpeg():
dest = tempdir.name dest = tempdir.name
photo = photosdb.get_photo(UUID_LIVE_HEIC) photo = photosdb.get_photo(UUID_LIVE_HEIC)
results = photo.export2(dest, convert_to_jpeg=True, live_photo=True) export_options = ExportOptions(convert_to_jpeg=True, live_photo=True)
results = PhotoExporter(photo).export(dest, options=export_options)
for name in NAMES_LIVE_HEIC: for name in NAMES_LIVE_HEIC:
assert f"{tempdir.name}/{name}" in results.exported assert f"{tempdir.name}/{name}" in results.exported
@@ -95,4 +94,3 @@ def test_export_convert_live_heic_to_jpeg():
for file_ in results.exported: for file_ in results.exported:
dest = pathlib.Path(file_) dest = pathlib.Path(file_)
assert dest.is_file() assert dest.is_file()

View File

@@ -22,7 +22,7 @@ def test_export_db():
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dbname = os.path.join(tempdir.name, ".osxphotos_export.db") dbname = os.path.join(tempdir.name, ".osxphotos_export.db")
db = ExportDB(dbname) db = ExportDB(dbname, tempdir.name)
assert os.path.isfile(dbname) assert os.path.isfile(dbname)
assert db.was_created assert db.was_created
assert not db.was_upgraded assert not db.was_upgraded
@@ -74,9 +74,29 @@ def test_export_db():
assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12) assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12)
assert sorted(db.get_previous_uuids()) == (["BAR-FOO", "FOO-BAR"]) assert sorted(db.get_previous_uuids()) == (["BAR-FOO", "FOO-BAR"])
# test set_data value=None doesn't overwrite existing data
db.set_data(
filepath2,
"BAR-FOO",
None,
None,
None,
None,
None,
None,
)
assert db.get_uuid_for_file(filepath2) == "BAR-FOO"
assert db.get_info_for_uuid("BAR-FOO") == INFO_DATA
assert db.get_exifdata_for_file(filepath2) == EXIF_DATA
assert db.get_stat_orig_for_file(filepath2) == (1, 2, 3)
assert db.get_stat_exif_for_file(filepath2) == (4, 5, 6)
assert db.get_stat_converted_for_file(filepath2) == (7, 8, 9)
assert db.get_stat_edited_for_file(filepath2) == (10, 11, 12)
assert sorted(db.get_previous_uuids()) == (["BAR-FOO", "FOO-BAR"])
# close and re-open # close and re-open
db.close() db.close()
db = ExportDB(dbname) db = ExportDB(dbname, tempdir.name)
assert not db.was_created assert not db.was_created
assert db.get_uuid_for_file(filepath2) == "BAR-FOO" assert db.get_uuid_for_file(filepath2) == "BAR-FOO"
assert db.get_info_for_uuid("BAR-FOO") == INFO_DATA assert db.get_info_for_uuid("BAR-FOO") == INFO_DATA
@@ -171,7 +191,7 @@ def test_export_db_in_memory():
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dbname = os.path.join(tempdir.name, ".osxphotos_export.db") dbname = os.path.join(tempdir.name, ".osxphotos_export.db")
db = ExportDB(dbname) db = ExportDB(dbname, tempdir.name)
assert os.path.isfile(dbname) assert os.path.isfile(dbname)
filepath = os.path.join(tempdir.name, "test.JPG") filepath = os.path.join(tempdir.name, "test.JPG")
@@ -190,7 +210,7 @@ def test_export_db_in_memory():
db.close() db.close()
dbram = ExportDBInMemory(dbname) dbram = ExportDBInMemory(dbname, tempdir.name)
assert not dbram.was_created assert not dbram.was_created
assert not dbram.was_upgraded assert not dbram.was_upgraded
assert dbram.version == OSXPHOTOS_EXPORTDB_VERSION assert dbram.version == OSXPHOTOS_EXPORTDB_VERSION
@@ -232,7 +252,7 @@ def test_export_db_in_memory():
dbram.close() dbram.close()
# re-open on disk and verify no changes # re-open on disk and verify no changes
db = ExportDB(dbname) db = ExportDB(dbname, tempdir.name)
assert db.get_uuid_for_file(filepath_lower) == "FOO-BAR" assert db.get_uuid_for_file(filepath_lower) == "FOO-BAR"
assert db.get_info_for_uuid("FOO-BAR") == INFO_DATA assert db.get_info_for_uuid("FOO-BAR") == INFO_DATA
assert db.get_exifdata_for_file(filepath) == EXIF_DATA assert db.get_exifdata_for_file(filepath) == EXIF_DATA
@@ -258,7 +278,9 @@ def test_export_db_in_memory_nofile():
filepath = os.path.join(tempdir.name, "test.JPG") filepath = os.path.join(tempdir.name, "test.JPG")
filepath_lower = os.path.join(tempdir.name, "test.jpg") filepath_lower = os.path.join(tempdir.name, "test.jpg")
dbram = ExportDBInMemory(os.path.join(tempdir.name, "NOT_A_DATABASE_FILE.db")) dbram = ExportDBInMemory(
os.path.join(tempdir.name, "NOT_A_DATABASE_FILE.db"), tempdir.name
)
assert dbram.was_created assert dbram.was_created
assert not dbram.was_upgraded assert not dbram.was_upgraded
assert dbram.version == OSXPHOTOS_EXPORTDB_VERSION assert dbram.version == OSXPHOTOS_EXPORTDB_VERSION

View File

@@ -1,9 +1,11 @@
import json
import pathlib
import pytest import pytest
import json
import osxphotos import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON from osxphotos._constants import _UNKNOWN_PERSON
import pathlib from osxphotos.photoexporter import ExportOptions, PhotoExporter
PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db" PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
PHOTOS_DB_PATH = "/Test-10.14.6.photoslibrary/database/photos.db" PHOTOS_DB_PATH = "/Test-10.14.6.photoslibrary/database/photos.db"
@@ -335,7 +337,7 @@ def test_exiftool_json_sidecar(photosdb):
with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp: with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp:
json_expected = json.load(fp)[0] json_expected = json.load(fp)[0]
json_got = photo._exiftool_json_sidecar() json_got = PhotoExporter(photo)._exiftool_json_sidecar()
json_got = json.loads(json_got)[0] json_got = json.loads(json_got)[0]
assert json_got == json_expected assert json_got == json_expected
@@ -349,7 +351,7 @@ def test_xmp_sidecar(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_ext.xmp") as fp: with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_ext.xmp") as fp:
xmp_expected = fp.read() xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar(extension="jpg") xmp_got = PhotoExporter(photo)._xmp_sidecar(extension="jpg")
assert xmp_got == xmp_expected assert xmp_got == xmp_expected
@@ -362,8 +364,9 @@ def test_xmp_sidecar_keyword_template(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.xmp") as fp: with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.xmp") as fp:
xmp_expected = fp.read() xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar( xmp_got = PhotoExporter(photo)._xmp_sidecar(
keyword_template=["{created.year}", "{folder_album}"], extension="jpg" ExportOptions(keyword_template=["{created.year}", "{folder_album}"]),
extension="jpg",
) )
assert xmp_got == xmp_expected assert xmp_got == xmp_expected

View File

@@ -1,7 +1,7 @@
""" test ExportResults class """ """ test ExportResults class """
import pytest import pytest
from osxphotos.photoinfo import ExportResults from osxphotos.photoexporter import ExportResults
EXPORT_RESULT_ATTRIBUTES = [ EXPORT_RESULT_ATTRIBUTES = [
"exported", "exported",
@@ -106,13 +106,3 @@ def test_all_files():
assert sorted( assert sorted(
results.all_files() + results.deleted_files + results.deleted_directories results.all_files() + results.deleted_files + results.deleted_directories
) == sorted([f"{x}1" for x in EXPORT_RESULT_ATTRIBUTES]) ) == sorted([f"{x}1" for x in EXPORT_RESULT_ATTRIBUTES])
def test_str():
""" test ExportResults.__str__ """
results = ExportResults()
assert (
str(results)
== "ExportResults(exported=[],new=[],updated=[],skipped=[],exif_updated=[],touched=[],converted_to_jpeg=[],sidecar_json_written=[],sidecar_json_skipped=[],sidecar_exiftool_written=[],sidecar_exiftool_skipped=[],sidecar_xmp_written=[],sidecar_xmp_skipped=[],missing=[],error=[],exiftool_warning=[],exiftool_error=[],deleted_files=[],deleted_directories=[],exported_album=[],skipped_album=[],missing_album=[])"
)

View File

@@ -13,6 +13,7 @@ import pytest
import osxphotos import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.photoexporter import PhotoExporter
from osxphotos.utils import _get_os_version from osxphotos.utils import _get_os_version
OS_VERSION = _get_os_version() OS_VERSION = _get_os_version()
@@ -1109,6 +1110,7 @@ def test_date_invalid():
"""Test date is invalid""" """Test date is invalid"""
# doesn't run correctly with the module-level fixture # doesn't run correctly with the module-level fixture
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import osxphotos import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
@@ -1349,7 +1351,7 @@ def test_exiftool_newlines_in_description(photosdb):
"""Test that exiftool handles newlines embedded in description, issue #393""" """Test that exiftool handles newlines embedded in description, issue #393"""
photo = photosdb.get_photo(UUID_DICT["description_newlines"]) photo = photosdb.get_photo(UUID_DICT["description_newlines"])
exif = photo._exiftool_dict() exif = PhotoExporter(photo)._exiftool_dict()
assert photo.description.find("\n") > 0 assert photo.description.find("\n") > 0
assert exif["EXIF:ImageDescription"].find("\n") > 0 assert exif["EXIF:ImageDescription"].find("\n") > 0

View File

@@ -3,7 +3,7 @@
from math import isclose from math import isclose
import pytest import pytest
from osxphotos.photoinfo import ScoreInfo from osxphotos.scoreinfo import ScoreInfo
PHOTOS_DB_5 = "tests/Test-10.15.5.photoslibrary" PHOTOS_DB_5 = "tests/Test-10.15.5.photoslibrary"
PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary" PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary"

View File

@@ -348,7 +348,6 @@ def test_labels_normalized(photosdb):
for uuid in LABELS_NORMALIZED_DICT: for uuid in LABELS_NORMALIZED_DICT:
photo = photosdb.photos(uuid=[uuid])[0] photo = photosdb.photos(uuid=[uuid])[0]
logging.warning(f"uuid = {uuid}")
assert sorted(photo.search_info_normalized.labels) == sorted( assert sorted(photo.search_info_normalized.labels) == sorted(
LABELS_NORMALIZED_DICT[uuid] LABELS_NORMALIZED_DICT[uuid]
) )
@@ -359,7 +358,6 @@ def test_labels(photosdb):
import logging import logging
for uuid in LABELS_DICT: for uuid in LABELS_DICT:
logging.warning(f"uuid = {uuid}")
photo = photosdb.photos(uuid=[uuid])[0] photo = photosdb.photos(uuid=[uuid])[0]
assert sorted(photo.search_info.labels) == sorted(LABELS_DICT[uuid]) assert sorted(photo.search_info.labels) == sorted(LABELS_DICT[uuid])
assert sorted(photo.labels) == sorted(LABELS_DICT[uuid]) assert sorted(photo.labels) == sorted(LABELS_DICT[uuid])

View File

@@ -9,6 +9,7 @@ import osxphotos
from osxphotos._constants import SIDECAR_XMP from osxphotos._constants import SIDECAR_XMP
from osxphotos.exiftool import ExifTool, get_exiftool_path from osxphotos.exiftool import ExifTool, get_exiftool_path
from osxphotos.fileutil import FileUtil from osxphotos.fileutil import FileUtil
from osxphotos.photoexporter import ExportOptions, PhotoExporter
PHOTOS_DB_15_7 = "tests/Test-10.15.7.photoslibrary" PHOTOS_DB_15_7 = "tests/Test-10.15.7.photoslibrary"
@@ -39,7 +40,10 @@ def test_sidecar_xmp(photosdb):
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos") tempdir = tempfile.TemporaryDirectory(prefix="osxphotos")
dest = tempdir.name dest = tempdir.name
photo = photosdb.get_photo(uuid) photo = photosdb.get_photo(uuid)
photo.export2(dest, photo.original_filename, sidecar=SIDECAR_XMP) export_options = ExportOptions(sidecar=SIDECAR_XMP)
PhotoExporter(photo).export(
dest, photo.original_filename, options=export_options
)
filepath = str(pathlib.Path(dest) / photo.original_filename) filepath = str(pathlib.Path(dest) / photo.original_filename)
xmppath = filepath + ".xmp" xmppath = filepath + ".xmp"
@@ -53,6 +57,8 @@ def test_sidecar_xmp(photosdb):
test_xmp = str(pathlib.Path(dest) / "test.xmp") test_xmp = str(pathlib.Path(dest) / "test.xmp")
FileUtil.copy(xmppath, test_xmp) FileUtil.copy(xmppath, test_xmp)
exif = ExifTool(test_xmp) exif = ExifTool(test_xmp)
output, warning, error = exif.run_commands("-tagsfromfile", xmppath, "-all:all", test_xmp, no_file=True) output, warning, error = exif.run_commands(
"-tagsfromfile", xmppath, "-all:all", test_xmp, no_file=True
)
assert not warning assert not warning
assert not error assert not error