Compare commits

..

46 Commits

Author SHA1 Message Date
Rhet Turnbull
f469cccc4b Updated README.md 2020-12-26 08:12:43 -08:00
Rhet Turnbull
4ece5c0d1c Exposed SearchInfo, closes #121 2020-12-26 08:08:18 -08:00
Rhet Turnbull
9ca5d8f0fd Added version to --verbose, closes #297 2020-12-22 21:05:40 -08:00
Rhet Turnbull
2a49255277 Added --exportdb 2020-12-22 20:42:48 -08:00
Rhet Turnbull
f3b7134af1 Fixed help text 2020-12-21 07:40:42 -08:00
Rhet Turnbull
73716f12cd Updated CHANGELOG.md 2020-12-21 07:35:21 -08:00
Rhet Turnbull
a4bbb6492d Added --exiftool-option to CLI, closes #298 2020-12-21 07:32:38 -08:00
Rhet Turnbull
aca19f4063 Updated CHANGELOG.md 2020-12-20 22:16:06 -08:00
Rhet Turnbull
2ebd4c33ff remove duplicate keywords with --exiftool and --sidecar, closes #294 2020-12-20 22:11:50 -08:00
Rhet Turnbull
da2f91ffc7 Updated CHANGELOG.md 2020-12-20 20:44:38 -08:00
Rhet Turnbull
ef94933dd8 version bump 2020-12-20 20:40:09 -08:00
Rhet Turnbull
e0e8850e56 Added better exiftool error handling, closes #300 2020-12-20 20:37:23 -08:00
Rhet Turnbull
8d1ccda0c8 README.md updates for tested versions 2020-12-17 22:28:17 -08:00
Rhet Turnbull
6171c4d665 Updated CHANGELOG.md 2020-12-17 20:00:34 -08:00
Rhet Turnbull
4678f15bc8 Version bump 2020-12-17 19:48:23 -08:00
Rhet Turnbull
a7c688cfc2 Fixed issue #296 2020-12-17 19:47:22 -08:00
Rhet Turnbull
880a9b67a1 Added additional test cases for #286, --ignore-signature 2020-12-17 15:21:34 -08:00
Rhet Turnbull
d40b16a456 Updated README.md 2020-12-16 21:56:19 -08:00
Rhet Turnbull
dcd2fde6d0 Updated CHANGELOG.md 2020-12-16 21:52:57 -08:00
Rhet Turnbull
ad860b1500 Add @finestream as a contributor 2020-12-16 21:50:47 -08:00
Rhet Turnbull
7ad4db6c15 Help text update 2020-12-16 21:48:47 -08:00
Rhet Turnbull
0f1cc7cc71 Merge pull request #295 from finestream/master
Documentation fix for #293. Thanks to @finestream
2020-12-16 21:42:06 -08:00
Rhet Turnbull
5e6a6cd5fb Updated CHANGELOG.md 2020-12-16 20:27:10 -08:00
Rhet Turnbull
e394d8e6be Implemented --ignore-signature, issue #286 2020-12-16 20:11:01 -08:00
finestream
8237bc8267 Merge pull request #1 from finestream/patch-1
Patch 1
2020-12-16 21:48:22 +01:00
finestream
e097f3aad5 Update __main__.py
Possible fix of Issue RhetTbull/osxphotos#293
2020-12-16 21:25:52 +01:00
finestream
3155045ec8 Update README.md
Fixed language
2020-12-16 21:17:08 +01:00
finestream
4f64eeb996 Update README.md
Possible documentation improvement to Issue #293
2020-12-16 20:58:04 +01:00
Rhet Turnbull
3c14ace826 Updated CHANGELOG.md 2020-12-13 22:30:14 -08:00
Rhet Turnbull
d5730dd8ae Fix for issue #263 2020-12-13 22:18:39 -08:00
Rhet Turnbull
5c1c0c5c5a Updated CHANGELOG.md 2020-12-13 22:05:33 -08:00
Rhet Turnbull
d8593a01e2 Fix for QuickTime date/time, issue #282 2020-12-12 22:13:01 -08:00
Rhet Turnbull
1dffe894ff Updated CHANGELOG.md 2020-12-12 08:08:44 -08:00
Rhet Turnbull
29721dd4f0 Merge pull request #290 from RhetTbull/save_config
Added --save-config, --load-config
2020-12-12 07:58:04 -08:00
Rhet Turnbull
6559c4d8f6 removed extended_attributes reference 2020-12-12 07:53:18 -08:00
Rhet Turnbull
baf45ccd2a This is why I never use branches 2020-12-12 07:51:36 -08:00
Rhet Turnbull
aca85ee2aa Version bump 2020-12-12 07:45:24 -08:00
Rhet Turnbull
9584a9ccc5 Merge branch 'master' into save_config 2020-12-12 07:38:35 -08:00
Rhet Turnbull
182b816e34 Updated README.md for --save-config, --load-config 2020-12-12 07:29:16 -08:00
Rhet Turnbull
0262e0d97e Added tests for configoptions.py 2020-12-12 07:25:50 -08:00
Rhet Turnbull
73f936e061 Added link to discussions 2020-12-11 06:19:05 -08:00
Rhet Turnbull
09687cfca4 Initial test for --save-config, --load-config 2020-12-11 06:12:32 -08:00
Rhet Turnbull
e17ee0e388 Updated CHANGELOG.md 2020-12-10 20:48:19 -08:00
Rhet Turnbull
d7c81adae8 Updated validate code 2020-12-09 20:17:49 -08:00
Rhet Turnbull
37b1e5ca47 Refactoring of save-config/load-config code 2020-12-08 08:32:37 -08:00
Rhet Turnbull
22355fd446 Initial implementation of configoptions for --save-config, --load-config 2020-12-08 07:22:40 -08:00
62 changed files with 2390 additions and 481 deletions

View File

@@ -109,6 +109,15 @@
"contributions": [
"code"
]
},
{
"login": "finestream",
"name": "finestream",
"avatar_url": "https://avatars1.githubusercontent.com/u/16638513?v=4",
"profile": "https://github.com/finestream",
"contributions": [
"doc"
]
}
],
"contributorsPerLine": 7

View File

@@ -4,6 +4,78 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.38.9](https://github.com/RhetTbull/osxphotos/compare/v0.38.8...v0.38.9)
> 21 December 2020
- Added --exiftool-option to CLI, closes #298 [`#298`](https://github.com/RhetTbull/osxphotos/issues/298)
#### [v0.38.8](https://github.com/RhetTbull/osxphotos/compare/v0.38.7...v0.38.8)
> 21 December 2020
- remove duplicate keywords with --exiftool and --sidecar, closes #294 [`#294`](https://github.com/RhetTbull/osxphotos/issues/294)
#### [v0.38.7](https://github.com/RhetTbull/osxphotos/compare/v0.38.6...v0.38.7)
> 21 December 2020
- Added better exiftool error handling, closes #300 [`#300`](https://github.com/RhetTbull/osxphotos/issues/300)
- README.md updates for tested versions [`8d1ccda`](https://github.com/RhetTbull/osxphotos/commit/8d1ccda0c897f84342caf612c1070d78bff421f5)
- version bump [`ef94933`](https://github.com/RhetTbull/osxphotos/commit/ef94933dd87b9ad2a516163ca50a36753dacd55a)
#### [v0.38.6](https://github.com/RhetTbull/osxphotos/compare/v0.38.5...v0.38.6)
> 18 December 2020
- Documentation fix for #293. Thanks to @finestream [`#295`](https://github.com/RhetTbull/osxphotos/pull/295)
- Added additional test cases for #286, --ignore-signature [`880a9b6`](https://github.com/RhetTbull/osxphotos/commit/880a9b67a14787ef23ae68ad3164d7eda1af16ec)
- Add @finestream as a contributor [`ad860b1`](https://github.com/RhetTbull/osxphotos/commit/ad860b1500dffd846322e05562ba4f2019cd1017)
- Fixed issue #296 [`a7c688c`](https://github.com/RhetTbull/osxphotos/commit/a7c688cfc2221833e0252d71bbe596eee5f9a6e8)
- Updated README.md [`d40b16a`](https://github.com/RhetTbull/osxphotos/commit/d40b16a456c64014674505b7c715c80b977da76a)
- Version bump [`4678f15`](https://github.com/RhetTbull/osxphotos/commit/4678f15bc86b5dedcb73c73f40e5fe11c1b51fa0)
#### [v0.38.5](https://github.com/RhetTbull/osxphotos/compare/v0.38.4...v0.38.5)
> 17 December 2020
- Patch 1 [`#1`](https://github.com/RhetTbull/osxphotos/pull/1)
- Implemented --ignore-signature, issue #286 [`e394d8e`](https://github.com/RhetTbull/osxphotos/commit/e394d8e6be7607a1668029bcb37ccb30a4fa792f)
- Update __main__.py [`e097f3a`](https://github.com/RhetTbull/osxphotos/commit/e097f3aad546b5be5eabab529bd2c35ce3056876)
- Update README.md [`4f64eeb`](https://github.com/RhetTbull/osxphotos/commit/4f64eeb996d43953eb90618465d2bd046282c4bb)
- Update README.md [`3155045`](https://github.com/RhetTbull/osxphotos/commit/3155045ec87d83285f2e66210559f4be0a10e3a2)
#### [v0.38.4](https://github.com/RhetTbull/osxphotos/compare/v0.38.3...v0.38.4)
> 14 December 2020
- Fix for issue #263 [`d5730dd`](https://github.com/RhetTbull/osxphotos/commit/d5730dd8ae92bc819b61ab4df9b10ae64e23569f)
#### [v0.38.3](https://github.com/RhetTbull/osxphotos/compare/v0.38.2...v0.38.3)
> 13 December 2020
- Fix for QuickTime date/time, issue #282 [`d8593a0`](https://github.com/RhetTbull/osxphotos/commit/d8593a01e210a0b914d5668ad5f70976fc43b217)
#### [v0.38.2](https://github.com/RhetTbull/osxphotos/compare/v0.38.0...v0.38.2)
> 12 December 2020
- Added --save-config, --load-config [`#290`](https://github.com/RhetTbull/osxphotos/pull/290)
- removed extended_attributes reference [`6559c4d`](https://github.com/RhetTbull/osxphotos/commit/6559c4d8f64ad41df925182f9f24f6f67eecd1df)
- This is why I never use branches [`baf45cc`](https://github.com/RhetTbull/osxphotos/commit/baf45ccd2aa24858bb1a8f95ef798121ee80af30)
- Version bump [`aca85ee`](https://github.com/RhetTbull/osxphotos/commit/aca85ee2aa01fcdece0224332584082280a3f62c)
- Merge branch 'master' into save_config [`9584a9c`](https://github.com/RhetTbull/osxphotos/commit/9584a9ccc56ac8c6dc5eb96019adf9224f436690)
- Added tests for configoptions.py [`0262e0d`](https://github.com/RhetTbull/osxphotos/commit/0262e0d97e06ee36786b4491efa178608afb5de5)
#### [v0.38.0](https://github.com/RhetTbull/osxphotos/compare/v0.37.7...v0.38.0)
> 11 December 2020
- Initial implementation of configoptions for --save-config, --load-config [`22355fd`](https://github.com/RhetTbull/osxphotos/commit/22355fd44609f42e412c580dfc9e5e0b7cf6c464)
- Refactoring of save-config/load-config code [`37b1e5c`](https://github.com/RhetTbull/osxphotos/commit/37b1e5ca472e9679301fa96d2b7fdd8c4ad438b2)
- Refactored FileUtil to use copy-on-write no APFS, issue #287 [`ec4b53e`](https://github.com/RhetTbull/osxphotos/commit/ec4b53ed9dd2bc1e6b71349efdaf0b81c6d797e5)
#### [v0.37.7](https://github.com/RhetTbull/osxphotos/compare/v0.37.6...v0.37.7)
> 7 December 2020

194
README.md
View File

@@ -3,7 +3,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python package](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-11-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-12-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
- [OSXPhotos](#osxphotos)
@@ -21,6 +21,7 @@
+ [FolderInfo](#folderinfo)
+ [PlaceInfo](#placeinfo)
+ [ScoreInfo](#scoreinfo)
+ [SearchInfo](#searchinfo)
+ [PersonInfo](#personinfo)
+ [FaceInfo](#faceinfo)
+ [CommentInfo](#commentinfo)
@@ -43,13 +44,13 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
## Supported operating systems
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.6 / Photos 5.0.
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.7 / Photos 5.0.
Alpha support for MacOS 10.16/MacOS 11 Big Sur Beta / Photos 6.0.
Beta support for MacOS 10.16/MacOS 11 Big Sur Beta / Photos 6.0. Not tested on M1 / Apple silicon Macs.
Requires python >= 3.7.
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12.
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running MacOS 10.12 and vice versa.
## Installation instructions
@@ -236,6 +237,15 @@ Options:
Deleted' folder.
--update Only export new or updated files. See notes
below on export and --update.
--ignore-signature When used with --update, ignores file
signature when updating files. This is
useful if you have processed or edited
exported photos changing the file signature
(size & modification date). In this case,
--update would normally re-export the
processed files but with --ignore-signature,
files which exist in the export directory
will not be re-exported.
--dry-run Dry run (test) the export but don't actually
export any files; most useful with
--verbose.
@@ -277,7 +287,7 @@ Options:
--jpeg-quality FLOAT RANGE Value in range 0.0 to 1.0 to use with
--convert-to-jpeg. A value of 1.0 specifies
best quality, a value of 0.0 specifies
maximum compression. Defaults to 1.0.
maximum compression. Defaults to 1.0
--download-missing Attempt to download missing photos from
iCloud. The current implementation uses
Applescript to interact with Photos to
@@ -328,11 +338,21 @@ Options:
EXIF:OffsetTimeOriginal; EXIF:ModifyDate
(see --ignore-date-modified);
IPTC:DateCreated; IPTC:TimeCreated; (video
files only): QuickTime:CreationDate (UTC);
QuickTime:ModifyDate (UTC) (see also
--ignore-date-modified);
files only): QuickTime:CreationDate;
QuickTime:CreateDate; QuickTime:ModifyDate
(see also --ignore-date-modified);
QuickTime:GPSCoordinates;
UserData:GPSCoordinates.
--exiftool-option OPTION Optional flag/option to pass to exiftool
when using --exiftool. For example,
--exiftool-option '-m' to ignore minor
warnings. Specify these as you would on the
exiftool command line. See exiftool docs at
https://exiftool.org/exiftool_pod.html for
full list of options. More than one option
may be specified by repeating the option,
e.g. --exiftool-option '-m' --exiftool-
option '-F'.
--ignore-date-modified If used with --exiftool or --sidecar, will
ignore the photo modification date and set
EXIF:ModifyDate to EXIF:DateTimeOriginal;
@@ -374,20 +394,24 @@ Options:
do not include an extension in the FILENAME
template. See below for additional details
on templating system.
--edited-suffix SUFFIX Optional suffix for naming edited photos.
Default name for edited photos is in form
'photoname_edited.ext'. For example, with '
--edited-suffix _bearbeiten', the edited
photo would be named
--edited-suffix SUFFIX Optional suffix template for naming edited
photos. Default name for edited photos is
in form 'photoname_edited.ext'. For example,
with '--edited-suffix _bearbeiten', the
edited photo would be named
'photoname_bearbeiten.ext'. The default
suffix is '_edited'.
--original-suffix SUFFIX Optional suffix for naming original photos.
Default name for original photos is in form
'filename.ext'. For example, with '--
original-suffix _original', the original
suffix is '_edited'. Multi-value templates
(see Templating System) are not permitted
with --edited-suffix.
--original-suffix SUFFIX Optional suffix template for naming original
photos. Default name for original photos is
in form 'filename.ext'. For example, with '
--original-suffix _original', the original
photo would be named
'filename_original.ext'. The default suffix
is '' (no suffix).
is '' (no suffix). Multi-value templates
(see Templating System) are not permitted
with --original-suffix.
--use-photos-export Force the use of AppleScript or PhotoKit to
export even if not missing (see also '--
download-missing' and '--use-photokit').
@@ -398,13 +422,37 @@ Options:
work with iTerm2 (use with Terminal.app).
This is faster and more reliable than the
default AppleScript interface.
--report REPORTNAME.CSV Write a CSV formatted report of all files
--report <path to export report>
Write a CSV formatted report of all files
that were exported.
--cleanup Cleanup export directory by deleting any
files which were not included in this export
set. For example, photos which had
previously been exported and were
subsequently deleted in Photos.
--exportdb EXPORTDB_FILE Specify alternate name for database file
which stores state information for export
and --update. If --exportdb is not
specified, export database will be saved to
'.osxphotos_export.db' in the export
directory. Must be specified as filename
only, not a path, as export database will be
saved in export directory.
--load-config <config file path>
Load options from file as written with
--save-config. This allows you to save a
complex export command to file for later
reuse. For example: 'osxphotos export <lots
of options here> --save-config
osxphotos.toml' then 'osxphotos export
/path/to/export --load-config
osxphotos.toml'. If any other command line
options are used in conjunction with --load-
config, they will override the corresponding
values in the config file.
--save-config <config file path>
Save options to file for use with --load-
config. File format is TOML.
-h, --help Show this message and exit.
** Export **
@@ -419,7 +467,10 @@ the export folder. If a file is changed in the export folder (for example,
you edited the exported image), osxphotos will detect this as a difference and
re-export the original image from the library thus overwriting the changes.
If using --update, the exported library should be treated as a backup, not a
working copy where you intend to make changes.
working copy where you intend to make changes. If you do edit or process the
exported files and do not want them to be overwritten withsubsequent --update,
use --ignore-signature which will match filename but not file signature when
exporting.
Note: The number of files reported for export and the number actually exported
may differ due to live photos, associated raw images, and edited photos which
@@ -442,6 +493,10 @@ will be rendered to substitute template fields with values from the photo.
For example, '{created.month}' would be replaced with the month name of the
photo creation date. e.g. 'November'.
Some options supporting templates may be repeated e.g., --keyword-template
'{label}' --keyword-template '{media_type}' to add both labels and media
types to the keywords.
The general format for a template is '{TEMPLATE_FIELD[,[DEFAULT]]}'. Some
templates have optional modifiers in form
'{[[DELIM]+]TEMPLATE_FIELD[(PATH_SEP)][?VALUE_IF_TRUE][,[DEFAULT]]}'
@@ -553,6 +608,9 @@ Substitution Description
'{photo_or_video,photo=fotos;video=videos}'
{hdr} Photo is HDR?; True/False value, use in
format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'
{edited} Photo has been edited (has adjustments)?;
True/False value, use in format
'{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'
{created.date} Photo's creation date in ISO format, e.g.
'2020-03-22'
{created.year} 4-digit year of photo creation time
@@ -706,6 +764,10 @@ Example: export photos to file structure based on 4-digit year and full name of
`osxphotos export ~/Desktop/export --directory "{created.year}/{created.month}"`
Example: export photos to file structure based on 4-digit year of photo's creation date and add keywords for media type and labels (labels are only awailable on Photos 5 and higher):
`osxphotos export ~/Desktop/export --directory "{created.year}" --keyword-template "{label}" --keyword-template "{media_type}"`
Example: export default library using 'country name/year' as output directory (but use "NoCountry/year" if country not specified), add persons, album names, and year as keywords, write exif metadata to files when exporting, update only changed files, print verbose ouput
`osxphotos export ~/Desktop/export --directory "{place.name.country,NoCountry}/{created.year}" --person-keyword --album-keyword --keyword-template "{created.year}" --exiftool --update --verbose`
@@ -1409,6 +1471,7 @@ Returns image categorization labels associated with the photo as list of str.
#### `labels_normalized`
Returns image categorization labels associated with the photo as list of str. Labels are normalized (e.g. converted to lower case). Use of normalized strings makes it easier to search if you don't how Apple capitalizes a label. For example:
```python
import osxphotos
@@ -1418,12 +1481,23 @@ for photo in photosdb.photos():
print(f"I found a statue! {photo.original_filename}")
```
**Note**: Only valid on Photos 5; on earlier versions, returns empty list. In Photos 5, Photos runs machine learning image categorization against photos in the library and automatically assigns labels to photos such as "People", "Dog", "Water", etc. A photo may have zero or more labels associated with it. See also [labels](#labels).
**Note**: Only valid on Photos 5+; on earlier versions, returns empty list. In Photos 5+, Photos runs machine learning image categorization against photos in the library and automatically assigns labels to photos such as "People", "Dog", "Water", etc. A photo may have zero or more labels associated with it. See also [labels](#labels).
#### <a name="photosearchinfo">`search_info`</a>
Returns [SearchInfo](#searchinfo) object that represents search metadata for the photo.
**Note**: Only valid on Photos 5+; on ealier versions, returns None.
#### <a name="photosearchinfo-normalized">`search_info_normalized`</a>
Returns [SearchInfo](#searchinfo) object that represents normalized search metadata for the photo. This returns a SearchInfo object just as `search_info` but all the properties of the object return normalized text (converted to lowercase).
**Note**: Only valid on Photos 5+; on ealier versions, returns None.
#### `exif_info`
Returns an [ExifInfo](#exifinfo) object with EXIF details from the Photos database. See [ExifInfo](#exifinfo) for additional details.
**Note**: Only valid on Photos 5; on earlier versions, returns `None`. The EXIF details returned are a subset of the actual EXIF data in a typical image. At import Photos stores this subset in the database and it's this stored data that `exif_info` returns.
**Note**: Only valid on Photos 5+; on earlier versions, returns `None`. The EXIF details returned are a subset of the actual EXIF data in a typical image. At import Photos stores this subset in the database and it's this stored data that `exif_info` returns.
See also `exiftool`.
@@ -1811,7 +1885,7 @@ PostalAddress(street='3700 Wailea Alanui Dr', sub_locality=None, city='Kihei', s
'96753'
```
### ScoreInfo
[PhotoInfo.score](#score) returns a ScoreInfo object that exposes the computed aesthetic scores for each photo (**Photos 5 only**). I have not yet reverse engineered the meaning of each score. The `overall` score seems to the most useful and appears to be a composite of the other scores. The following score properties are currently available:
[PhotoInfo.score](#score) returns a ScoreInfo object that exposes the computed aesthetic scores for each photo (**Photos 5+ only**). I have not yet reverse engineered the meaning of each score. The `overall` score seems to the most useful and appears to be a composite of the other scores. The following score properties are currently available:
```python
overall: float
@@ -1850,6 +1924,71 @@ Example: find your "best" photo of food
>>> best_food_photo = sorted([p for p in photos if "food" in p.labels_normalized], key=lambda p: p.score.overall, reverse=True)[0]
```
### SearchInfo
[PhotoInfo.search_info](#photosearchinfo) and [PhotoInfo.search_info_normalized](#photosearchinfo-normalized) return a SearchInfo object that exposes various metadata that Photos uses when searching for photos such as labels, associated holiday, etc. (**Photos 5+ only**).
The following properties are available:
#### `labels`
Returns list of labels applied to photo by Photos image categorization algorithms.
#### `place_names`
Returns list of place names associated with the photo.
#### `streets`
Returns list of street names associated with the photo. (e.g. reverse geolocation of where the photo was taken)
#### `neighborhoods`
Returns list of neighborhood names associated with the photo.
#### `locality_names`
Returns list of locality names associated with the photo.
#### `city`
Returns str of city/town/municipality associated with the photo.
#### `state`
Returns str of state name associated with the photo.
#### `state_abbreviation`
Returns str of state abbreviation associated with the photo.
#### `country`
Returns str of country name associated with the photo.
#### `month`
Returns str of month name associated witht the photo (e.g. month in which the photo was taken)
#### `year`
Returns year associated with the photo.
#### `bodies_of_water`
Returns list of bodies of water associated with the photo.
#### `holidays`
Returns list of holiday names associated with the photo.
#### `activities`
Returns list of activities associated with the photo.
#### `season`
Returns str of season name associated with the photo.
#### `venues`
Returns list of venue names associated with the photo.
#### `venue_types`
Returns list of venue types associated with the photoo.
#### `media_types`
Returns list of media types associated with the photo.
#### `all`
Returns all search_info properties as a single list of strings.
#### `asdict()`
Returns all associated search_info metadata as a dict.
### PersonInfo
[PhotosDB.person_info](#dbpersoninfo) and [PhotoInfo.person_info](#photopersoninfo) return a list of PersonInfo objects represents persons in the database and in a photo, respectively. The PersonInfo class has the following properties and methods.
@@ -2025,6 +2164,7 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{media_type}|Special media type resolved in this precedence: selfie, time_lapse, panorama, slow_mo, screenshot, portrait, live_photo, burst, photo, video. Defaults to 'photo' or 'video' if no special type. Customize one or more media types using format: '{media_type,video=vidéo;time_lapse=vidéo_accélérée}'|
|{photo_or_video}|'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'|
|{hdr}|Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'|
|{edited}|Photo has been edited (has adjustments)?; True/False value, use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'|
|{created.date}|Photo's creation date in ISO format, e.g. '2020-03-22'|
|{created.year}|4-digit year of photo creation time|
|{created.yy}|2-digit year of photo creation time|
@@ -2175,7 +2315,7 @@ if __name__ == "__main__":
## Contributing
Contributing is easy! if you find bugs or want to suggest additional features/changes, please open an [issue](https://github.com/rhettbull/osxphotos/issues/).
Contributing is easy! if you find bugs or want to suggest additional features/changes, please open an [issue](https://github.com/rhettbull/osxphotos/issues/) or join the [discussion](https://github.com/RhetTbull/osxphotos/discussions).
I'll gladly consider pull requests for bug fixes or feature implementations.
@@ -2206,6 +2346,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/agprimatic"><img src="https://avatars1.githubusercontent.com/u/4685054?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ag Primatic</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=agprimatic" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hhoeck"><img src="https://avatars1.githubusercontent.com/u/6313998?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Horst Höck</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hhoeck" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jstrine"><img src="https://avatars1.githubusercontent.com/u/33943447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jonathan Strine</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jstrine" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/finestream"><img src="https://avatars1.githubusercontent.com/u/16638513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>finestream</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=finestream" title="Documentation">📖</a></td>
</tr>
</table>
@@ -2240,9 +2381,10 @@ For additional details about how osxphotos is implemented or if you would like t
- [bpylist2](https://pypi.org/project/bpylist2/)
- [pathvalidate](https://pypi.org/project/pathvalidate/)
- [wurlitzer](https://pypi.org/project/wurlitzer/)
- [toml](https://github.com/uiri/toml)
## Acknowledgements
This project was originally inspired by [photo-export](https://github.com/patrikhson/photo-export) by Patrick Fältström, Copyright (c) 2015 Patrik Fältström paf@frobbit.se
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.

File diff suppressed because it is too large Load Diff

View File

@@ -102,6 +102,63 @@ _OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
SEARCH_CATEGORY_LABEL = 2024
SEARCH_CATEGORY_PLACE_NAME = 1
SEARCH_CATEGORY_STREET = 2
SEARCH_CATEGORY_NEIGHBORHOOD = 3
SEARCH_CATEGORY_LOCALITY_4 = 4
SEARCH_CATEGORY_CITY = 5
SEARCH_CATEGORY_SUB_LOCALITY = 6
SEARCH_CATEGORY_LOCALITY_7 = 7
SEARCH_CATEGORY_LOCALITY_8 = 8
SEARCH_CATEGORY_NAMED_AREA = 9
SEARCH_CATEGORY_ALL_LOCALITY = [
SEARCH_CATEGORY_LOCALITY_4,
SEARCH_CATEGORY_SUB_LOCALITY,
SEARCH_CATEGORY_LOCALITY_7,
SEARCH_CATEGORY_LOCALITY_8,
SEARCH_CATEGORY_NAMED_AREA,
]
SEARCH_CATEGORY_STATE = 10
SEARCH_CATEGORY_STATE_ABBREVIATION = 11
SEARCH_CATEGORY_COUNTRY = 12
SEARCH_CATEGORY_BODY_OF_WATER = 14
SEARCH_CATEGORY_MONTH = 1014
SEARCH_CATEGORY_YEAR = 1015
SEARCH_CATEGORY_KEYWORDS = 2016
SEARCH_CATEGORY_TITLE = 2017
SEARCH_CATEGORY_DESCRIPTION = 2018
SEARCH_CATEGORY_HOME = 2020
SEARCH_CATEGORY_PERSON = 2021
SEARCH_CATEGORY_ACTIVITY = 2027
SEARCH_CATEGORY_HOLIDAY = 2029
SEARCH_CATEGORY_SEASON = 2030
SEARCH_CATEGORY_WORK = 2036
SEARCH_CATEGORY_VENUE = 2038
SEARCH_CATEGORY_VENUE_TYPE = 2039
SEARCH_CATEGORY_PHOTO_TYPE_VIDEO = 2044
SEARCH_CATEGORY_PHOTO_TYPE_SLOMO = 2045
SEARCH_CATEGORY_PHOTO_TYPE_LIVE = 2046
SEARCH_CATEGORY_PHOTO_TYPE_SCREENSHOT = 2047
SEARCH_CATEGORY_PHOTO_TYPE_PANORAMA = 2048
SEARCH_CATEGORY_PHOTO_TYPE_TIMELAPSE = 2049
SEARCH_CATEGORY_PHOTO_TYPE_BURSTS = 2052
SEARCH_CATEGORY_PHOTO_TYPE_PORTRAIT = 2053
SEARCH_CATEGORY_PHOTO_TYPE_SELFIES = 2054
SEARCH_CATEGORY_PHOTO_TYPE_FAVORITES = 2055
SEARCH_CATEGORY_MEDIA_TYPES = [
SEARCH_CATEGORY_PHOTO_TYPE_VIDEO,
SEARCH_CATEGORY_PHOTO_TYPE_SLOMO,
SEARCH_CATEGORY_PHOTO_TYPE_LIVE,
SEARCH_CATEGORY_PHOTO_TYPE_SCREENSHOT,
SEARCH_CATEGORY_PHOTO_TYPE_PANORAMA,
SEARCH_CATEGORY_PHOTO_TYPE_TIMELAPSE,
SEARCH_CATEGORY_PHOTO_TYPE_BURSTS,
SEARCH_CATEGORY_PHOTO_TYPE_PORTRAIT,
SEARCH_CATEGORY_PHOTO_TYPE_SELFIES,
SEARCH_CATEGORY_PHOTO_TYPE_FAVORITES,
]
SEARCH_CATEGORY_PHOTO_NAME = 2056
# Max filename length on MacOS
MAX_FILENAME_LEN = 255
@@ -109,3 +166,15 @@ MAX_FILENAME_LEN = 255
# Max directory name length on MacOS
MAX_DIRNAME_LEN = 255
# Default JPEG quality when converting to JPEG
DEFAULT_JPEG_QUALITY = 1.0
# Default suffix to add to edited images
DEFAULT_EDITED_SUFFIX = "_edited"
# Default suffix to add to original images
DEFAULT_ORIGINAL_SUFFIX = ""
# Colors for print CLI messages
CLI_COLOR_ERROR = "red"
CLI_COLOR_WARNING = "yellow"

View File

@@ -1,4 +1,5 @@
""" version info """
__version__ = "0.38.0"
__version__ = "0.38.11"

173
osxphotos/configoptions.py Normal file
View File

@@ -0,0 +1,173 @@
""" ConfigOptions class to load/save config settings for osxphotos CLI """
import toml
class ConfigOptionsException(Exception):
""" Invalid combination of options. """
def __init__(self, message):
self.message = message
super().__init__(self.message)
class ConfigOptionsInvalidError(ConfigOptionsException):
pass
class ConfigOptionsLoadError(ConfigOptionsException):
pass
class ConfigOptions:
""" data class to store and load options for osxphotos commands """
def __init__(self, name, attrs, ignore=None):
""" init ConfigOptions class
Args:
name: name for these options, will be used for section heading in TOML file when saving/loading from file
attrs: dict with name and default value for all allowed attributes
ignore: optional list of strings of keys to ignore from attrs dict
"""
self._name = name
self._attrs = attrs.copy()
if ignore:
for attrname in ignore:
self._attrs.pop(attrname, None)
self.set_attributes(attrs)
def set_attributes(self, args):
for attr in self._attrs:
try:
arg = args[attr]
# don't test 'not arg'; need to handle empty strings as valid values
if arg is None or arg == False:
if type(self._attrs[attr]) == tuple:
setattr(self, attr, ())
else:
setattr(self, attr, self._attrs[attr])
else:
setattr(self, attr, arg)
except KeyError:
raise KeyError(f"Missing argument: {attr}")
def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False):
""" validate combinations of otions
Args:
exclusive: list of tuples in form [("option_1", "option_2")...] which are exclusive;
ie. either option_1 can be set or option_2 but not both;
inclusive: list of tuples in form [("option_1", "option_2")...] which are inclusive;
ie. if either option_1 or option_2 is set, the other must be set
dependent: list of tuples in form [("option_1", ("option_2", "option_3"))...]
where if option_1 is set, then at least one of the options in the second tuple must also be set
cli: bool, set to True if called to validate CLI options;
will prepend '--' to option names in InvalidOptions.message and change _ to - in option names
Returns:
True if all options valid
Raises:
InvalidOption if any combination of options is invalid
InvalidOption.message will be descriptive message of invalid options
"""
if not any([exclusive, inclusive, dependent]):
return True
prefix = "--" if cli else ""
if exclusive:
for a, b in exclusive:
vala = getattr(self, a)
valb = getattr(self, b)
vala = any(vala) if isinstance(vala, tuple) else vala
valb = any(valb) if isinstance(valb, tuple) else valb
if vala and valb:
stra = a.replace("_", "-") if cli else a
strb = b.replace("_", "-") if cli else b
raise ConfigOptionsInvalidError(
f"{prefix}{stra} and {prefix}{strb} options cannot be used together."
)
if inclusive:
for a, b in inclusive:
vala = getattr(self, a)
valb = getattr(self, b)
vala = any(vala) if isinstance(vala, tuple) else vala
valb = any(valb) if isinstance(valb, tuple) else valb
if any([vala, valb]) and not all([vala, valb]):
stra = a.replace("_", "-") if cli else a
strb = b.replace("_", "-") if cli else b
raise ConfigOptionsInvalidError(
f"{prefix}{stra} and {prefix}{strb} options must be used together."
)
if dependent:
for a, b in dependent:
vala = getattr(self, a)
if not isinstance(b, tuple):
# python unrolls the tuple if there's a single element
b = (b,)
valb = [getattr(self, x) for x in b]
valb = [any(x) if isinstance(x, tuple) else x for x in valb]
if vala and not any(valb):
if cli:
stra = prefix + a.replace("_", "-")
strb = ", ".join(prefix + x.replace("_", "-") for x in b)
else:
stra = a
strb = ", ".join(b)
raise ConfigOptionsInvalidError(
f"{stra} must be used with at least one of: {strb}."
)
return True
def write_to_file(self, filename):
""" Write self to TOML file
Args:
filename: full path to TOML file to write; filename will be overwritten if it exists
"""
# todo: add overwrite and option to merge contents already in TOML file (under different [section] with new content)
data = {}
for attr in sorted(self._attrs.keys()):
val = getattr(self, attr)
if val in [False, ()]:
val = None
else:
val = list(val) if type(val) == tuple else val
data[attr] = val
with open(filename, "w") as fd:
toml.dump({self._name: data}, fd)
def load_from_file(self, filename, override=False):
""" Load options from a TOML file.
Args:
filename: full path to TOML file
override: bool; if True, values in the TOML file will override values already set in the instance
Raises:
ConfigOptionsLoadError if there are any errors during the parsing of the TOML file
"""
loaded = toml.load(filename)
name = self._name
if name not in loaded:
raise ConfigOptionsLoadError(f"[{name}] section missing from {filename}")
for attr in loaded[name]:
if attr not in self._attrs:
raise ConfigOptionsLoadError(
f"Unknown option: {attr} = {loaded[name][attr]}"
)
val = loaded[name][attr]
if not override:
# use value from self if set
val = getattr(self, attr) or val
if type(self._attrs[attr]) == tuple:
val = tuple(val)
setattr(self, attr, val)
return self
def asdict(self):
return {attr: getattr(self, attr) for attr in sorted(self._attrs.keys())}

View File

@@ -132,19 +132,23 @@ class _ExifToolProc:
class ExifTool:
""" Basic exiftool interface for reading and writing EXIF tags """
def __init__(self, filepath, exiftool=None, overwrite=True):
def __init__(self, filepath, exiftool=None, overwrite=True, flags=None):
""" Create ExifTool object
Args:
file: path to image file
exiftool: path to exiftool, if not specified will look in path
overwrite: if True, will overwrite image file without creating backup, default=False
file: path to image file
exiftool: path to exiftool, if not specified will look in path
overwrite: if True, will overwrite image file without creating backup, default=False
flags: optional list of exiftool flags to prepend to exiftool command when writing metadata (e.g. -m or -F)
Returns:
ExifTool instance
"""
self.file = filepath
self.overwrite = overwrite
self.flags = flags or []
self.data = {}
self.warning = None
self.error = None
# if running as a context manager, self._context_mgr will be True
self._context_mgr = False
@@ -163,6 +167,7 @@ class ExifTool:
True if success otherwise False
If error generated by exiftool, returns False and sets self.error to error string
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
If called in context manager, returns True (execution is delayed until exiting context manager)
"""
@@ -175,8 +180,8 @@ class ExifTool:
self._commands.extend(command)
return True
else:
_, self.error = self.run_commands(*command)
return self.error is None
_, _, error = self.run_commands(*command)
return error is None
def addvalues(self, tag, *values):
""" Add one or more value(s) to tag
@@ -190,6 +195,7 @@ class ExifTool:
True if success otherwise False
If error generated by exiftool, returns False and sets self.error to error string
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
If called in context manager, returns True (execution is delayed until exiting context manager)
Notes: exiftool may add duplicate values for some tags so the caller must ensure
@@ -216,8 +222,8 @@ class ExifTool:
self._commands.extend(command)
return True
else:
_, self.error = self.run_commands(*command)
return self.error is None
_, _, error = self.run_commands(*command)
return error is None
def run_commands(self, *commands, no_file=False):
""" Run commands in the exiftool process and return result.
@@ -228,11 +234,12 @@ class ExifTool:
by default, all commands will be run against self.file
use no_file=True to run a command without passing the filename
Returns:
(output, errror)
(output, warning, errror)
output: bytes is containing output of exiftool commands
error: if exiftool generated an error, bytes containing error string otherwise None
warning: if exiftool generated warnings, string containing warning otherwise empty string
error: if exiftool generated errors, string containing otherwise empty string
Note: Also sets self.error if error generated.
Note: Also sets self.warning and self.error if warning or error generated.
"""
if not (hasattr(self, "_process") and self._process):
raise ValueError("exiftool process is not running")
@@ -245,30 +252,42 @@ class ExifTool:
commands.append("-overwrite_original")
filename = os.fsencode(self.file) if not no_file else b""
command_str = (
if self.flags:
command_str = b"\n".join([f.encode("utf-8") for f in self.flags])
command_str += b"\n"
else:
command_str = b""
command_str += (
b"\n".join([c.encode("utf-8") for c in commands])
+ b"\n"
+ filename
+ b"\n"
+ b"-execute\n"
)
# send the command
self._process.stdin.write(command_str)
self._process.stdin.flush()
# read the output
output = b""
warning = b""
error = b""
while EXIFTOOL_STAYOPEN_EOF not in str(output):
line = self._process.stdout.readline()
if line.startswith(b"Warning"):
error += line
warning += line.strip()
elif line.startswith(b"Error"):
error += line.strip()
else:
output += line.strip()
error = None if error == b"" else error
warning = "" if warning == b"" else warning.decode("utf-8")
error = "" if error == b"" else error.decode("utf-8")
self.warning = warning
self.error = error
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], error
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], warning, error
@property
def pid(self):
@@ -278,14 +297,14 @@ class ExifTool:
@property
def version(self):
""" returns exiftool version """
ver, _ = self.run_commands("-ver", no_file=True)
ver, _, _ = self.run_commands("-ver", no_file=True)
return ver.decode("utf-8")
def asdict(self):
""" return dictionary of all EXIF tags and values from exiftool
returns empty dict if no tags
"""
json_str, _ = self.run_commands("-json")
json_str, _, _ = self.run_commands("-json")
if json_str:
exifdict = json.loads(json_str)
return exifdict[0]
@@ -294,7 +313,7 @@ class ExifTool:
def json(self):
""" returns JSON string containing all EXIF tags and values from exiftool """
json, _ = self.run_commands("-json")
json, _, _ = self.run_commands("-json")
return json
def _read_exif(self):
@@ -314,4 +333,5 @@ class ExifTool:
if exc_type:
return False
elif self._commands:
_, self.error = self.run_commands(*self._commands)
# run_commands sets self.warning and self.error as needed
self.run_commands(*self._commands)

View File

@@ -45,24 +45,105 @@ from ..photokit import (
)
from ..utils import dd_to_dms_str, findfiles, noop
ExportResults = namedtuple(
"ExportResults",
[
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
"missing",
"error",
],
)
class ExportResults:
""" holds export results for export2 """
def __init__(
self,
exported=None,
new=None,
updated=None,
skipped=None,
exif_updated=None,
touched=None,
converted_to_jpeg=None,
sidecar_json_written=None,
sidecar_json_skipped=None,
sidecar_xmp_written=None,
sidecar_xmp_skipped=None,
missing=None,
error=None,
exiftool_warning=None,
exiftool_error=None,
):
self.exported = exported or []
self.new = new or []
self.updated = updated or []
self.skipped = skipped or []
self.exif_updated = exif_updated or []
self.touched = touched or []
self.converted_to_jpeg = converted_to_jpeg or []
self.sidecar_json_written = sidecar_json_written or []
self.sidecar_json_skipped = sidecar_json_skipped or []
self.sidecar_xmp_written = sidecar_xmp_written or []
self.sidecar_xmp_skipped = sidecar_xmp_skipped or []
self.missing = missing or []
self.error = error or []
self.exiftool_warning = exiftool_warning or []
self.exiftool_error = exiftool_error or []
def all_files(self):
""" return all filenames contained in results """
files = (
self.exported
+ self.new
+ self.updated
+ self.skipped
+ self.exif_updated
+ self.touched
+ self.converted_to_jpeg
+ self.sidecar_json_written
+ self.sidecar_json_skipped
+ self.sidecar_xmp_written
+ self.sidecar_xmp_skipped
+ self.missing
+ self.error
)
files += [x[0] for x in self.exiftool_warning]
files += [x[0] for x in self.exiftool_error]
files = list(set(files))
return files
def __iadd__(self, other):
self.exported += other.exported
self.new += other.new
self.updated += other.updated
self.skipped += other.skipped
self.exif_updated += other.exif_updated
self.touched += other.touched
self.converted_to_jpeg += other.converted_to_jpeg
self.sidecar_json_written += other.sidecar_json_written
self.sidecar_json_skipped += other.sidecar_json_skipped
self.sidecar_xmp_written += other.sidecar_xmp_written
self.sidecar_xmp_skipped += other.sidecar_xmp_skipped
self.missing += other.missing
self.error += other.error
self.exiftool_warning += other.exiftool_warning
self.exiftool_error += other.exiftool_error
return self
def __str__(self):
return (
"ExportResults("
+ f"exported={self.exported}"
+ f",new={self.new}"
+ f",updated={self.updated}"
+ f",skipped={self.skipped}"
+ f",exif_updated={self.exif_updated}"
+ f",touched={self.touched}"
+ f",converted_to_jpeg={self.converted_to_jpeg}"
+ f",sidecar_json_written={self.sidecar_json_written}"
+ f",sidecar_json_skipped={self.sidecar_json_skipped}"
+ f",sidecar_xmp_written={self.sidecar_xmp_written}"
+ f",sidecar_xmp_skipped={self.sidecar_xmp_skipped}"
+ f",missing={self.missing}"
+ f",error={self.error}"
+ f",exiftool_warning={self.exiftool_warning}"
+ f",exiftool_error={self.exiftool_error}"
+ ")"
)
# hexdigest is not a class method, don't import this into PhotoInfo
@@ -285,7 +366,8 @@ def export(
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
returns: list of photos exported
Returns: list of photos exported
"""
# Implementation note: calls export2 to actually do the work
@@ -333,6 +415,7 @@ def export2(
keyword_template=None,
description_template=None,
update=False,
ignore_signature=False,
export_db=None,
fileutil=FileUtil,
dry_run=False,
@@ -342,6 +425,7 @@ def export2(
ignore_date_modified=False,
use_photokit=False,
verbose=None,
exiftool_flags=None,
):
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -376,6 +460,7 @@ def export2(
description_template: string; optional template string that will be rendered for use as photo description
update: (boolean, 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
ignore_signature: (bool, default=False), ignore file signature when used with update (look only at filename)
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
@@ -385,8 +470,10 @@ def export2(
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.
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
exiftool_flags: optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
Returns: ExportResults namedtuple with fields:
Returns: ExportResults class
ExportResults has attributes:
"exported",
"new",
"updated",
@@ -399,7 +486,10 @@ def export2(
"sidecar_xmp_written",
"sidecar_xmp_skipped",
"missing",
"error"
"error",
"exiftool_warning",
"exiftool_error",
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
@@ -606,6 +696,7 @@ def export2(
fileutil=fileutil,
edited=edited,
jpeg_quality=jpeg_quality,
ignore_signature=ignore_signature,
)
exported_files = results.exported
update_new_files = results.new
@@ -631,6 +722,7 @@ def export2(
touch_file,
False,
fileutil=fileutil,
ignore_signature=ignore_signature,
)
exported_files.extend(results.exported)
update_new_files.extend(results.new)
@@ -657,6 +749,7 @@ def export2(
convert_to_jpeg,
fileutil=fileutil,
jpeg_quality=jpeg_quality,
ignore_signature=ignore_signature,
)
exported_files.extend(results.exported)
update_new_files.extend(results.new)
@@ -847,6 +940,10 @@ def export2(
exif_files = exported_files
exif_files_updated = []
exiftool_warning = []
exiftool_error = []
errors = []
# TODO: remove duplicative code from below
if exiftool and update and exif_files:
for exported_file in exif_files:
files_are_different = False
@@ -870,14 +967,21 @@ def export2(
# or files were different
verbose(f"Writing metadata with exiftool for {exported_file}")
if not dry_run:
self._write_exif_data(
warning_, error_ = self._write_exif_data(
exported_file,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
flags=exiftool_flags,
)
if warning_:
exiftool_warning.append((exported_file, warning_))
if error_:
exiftool_error.append((exported_file, error_))
errors.append(exported_file)
export_db.set_exifdata_for_file(
exported_file,
self._exiftool_json_sidecar(
@@ -898,14 +1002,20 @@ def export2(
for exported_file in exif_files:
verbose(f"Writing metadata with exiftool for {exported_file}")
if not dry_run:
self._write_exif_data(
warning_, error_ = self._write_exif_data(
exported_file,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
flags=exiftool_flags,
)
if warning_:
exiftool_warning.append((exported_file, warning_))
if error_:
exiftool_error.append((exported_file, error_))
errors.append(exported_file)
export_db.set_exifdata_for_file(
exported_file,
@@ -943,8 +1053,9 @@ def export2(
sidecar_json_skipped=sidecar_json_files_skipped,
sidecar_xmp_written=sidecar_xmp_files_written,
sidecar_xmp_skipped=sidecar_xmp_files_skipped,
missing=[],
error=[],
error=errors,
exiftool_error=exiftool_error,
exiftool_warning=exiftool_warning,
)
return results
@@ -963,6 +1074,7 @@ def _export_photo(
fileutil=FileUtil,
edited=False,
jpeg_quality=1.0,
ignore_signature=None,
):
"""Helper function for export()
Does the actual copy or hardlink taking the appropriate
@@ -983,6 +1095,7 @@ def _export_photo(
fileutil: FileUtil class that conforms to fileutil.FileUtilABC
edited: bool; set to True if exporting edited version of photo
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.
ignore_signature: bool, ignore file signature when used with update (look only at filename)
Returns:
ExportResults
@@ -1008,7 +1121,10 @@ def _export_photo(
cmp_touch, cmp_orig = False, False
if dest_exists:
# update, destination exists, but we might not need to replace it...
if exiftool:
if ignore_signature:
cmp_orig = True
cmp_touch = fileutil.cmp(src, dest, mtime1=int(self.date.timestamp()))
elif exiftool:
sig_exif = export_db.get_stat_exif_for_file(dest_str)
cmp_orig = fileutil.cmp_file_sig(dest_str, sig_exif)
sig_exif = (sig_exif[0], sig_exif[1], int(self.date.timestamp()))
@@ -1132,6 +1248,7 @@ def _write_exif_data(
keyword_template=None,
description_template=None,
ignore_date_modified=False,
flags=None,
):
"""write exif data to image file at filepath
@@ -1141,6 +1258,10 @@ def _write_exif_data(
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
flags: optional list of exiftool flags to prepend to exiftool command when writing metadata (e.g. -m or -F)
Returns:
(warning, error) of warning and error strings if exiftool produces warnings or errors
"""
if not os.path.exists(filepath):
raise FileNotFoundError(f"Could not find file {filepath}")
@@ -1152,13 +1273,14 @@ def _write_exif_data(
ignore_date_modified=ignore_date_modified,
)
with ExifTool(filepath) as exiftool:
with ExifTool(filepath, flags=flags) as exiftool:
for exiftag, val in exif_info.items():
if type(val) == list:
for v in val:
exiftool.setvalue(exiftag, v)
else:
exiftool.setvalue(exiftag, val)
return exiftool.warning, exiftool.error
def _exiftool_dict(
@@ -1197,7 +1319,8 @@ def _exiftool_dict(
EXIF:ModifyDate
IPTC:DateCreated
IPTC:TimeCreated
QuickTime:CreationDate (UTC)
QuickTime:CreationDate
QuickTime:CreateDate (UTC)
QuickTime:ModifyDate (UTC)
QuickTime:GPSCoordinates
UserData:GPSCoordinates
@@ -1224,13 +1347,13 @@ def _exiftool_dict(
person_list = []
if self.persons:
# filter out _UNKNOWN_PERSON
person_list = sorted([p for p in self.persons if p != _UNKNOWN_PERSON])
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
if use_persons_as_keywords and person_list:
keyword_list.extend(sorted(person_list))
keyword_list.extend(person_list)
if use_albums_as_keywords and self.albums:
keyword_list.extend(sorted(self.albums))
keyword_list.extend(self.albums)
if keyword_template:
rendered_keywords = []
@@ -1265,16 +1388,19 @@ def _exiftool_dict(
keyword_list.extend(rendered_keywords)
if keyword_list:
# remove duplicates
keyword_list = sorted(list(set(keyword_list)))
exif["XMP:TagsList"] = keyword_list.copy()
exif["IPTC:Keywords"] = keyword_list.copy()
if person_list:
person_list = sorted(list(set(person_list)))
exif["XMP:PersonInImage"] = person_list.copy()
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
# only use Photos' keywords for subject (e.g. don't include template values)
exif["XMP:Subject"] = self.keywords.copy() + person_list.copy()
exif["XMP:Subject"] = sorted(list(set(self.keywords + person_list)))
# if self.favorite():
# exif["Rating"] = 5
@@ -1300,22 +1426,26 @@ def _exiftool_dict(
# [IPTC] Digital Creation Date : 2020:10:30
# [IPTC] Date Created : 2020:10:30
#
# for videos:
# [QuickTime] CreateDate : 2020:12:11 06:10:10
# [QuickTime] ModifyDate : 2020:12:11 06:10:10
# [Keys] CreationDate : 2020:12:10 22:10:10-08:00
# This code deviates from Photos in one regard:
# if photo has modification date, use it otherwise use creation date
if self.isphoto:
date = self.date
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
date = self.date
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
if self.isphoto:
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["EXIF:CreateDate"] = datetimeoriginal
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["EXIF:OffsetTimeOriginal"] = offsettime
dateoriginal = date.strftime("%Y:%m:%d")
@@ -1330,18 +1460,21 @@ def _exiftool_dict(
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
elif self.ismovie:
# QuickTime spec specifies times in UTC
# QuickTime:CreateDate and ModifyDate are in UTC w/ no timezone
# QuickTime:CreationDate must include time offset or Photos shows invalid values
# reference: https://exiftool.org/TagNames/QuickTime.html#Keys
date_utc = datetime_tz_to_utc(self.date)
# https://exiftool.org/forum/index.php?topic=11927.msg64369#msg64369
exif["QuickTime:CreationDate"] = f"{datetimeoriginal}{offsettime}"
date_utc = datetime_tz_to_utc(date)
creationdate = date_utc.strftime("%Y:%m:%d %H:%M:%S")
exif["QuickTime:CreationDate"] = creationdate
exif["QuickTime:CreateDate"] = creationdate
if self.date_modified is not None and not ignore_date_modified:
if self.date_modified is None or ignore_date_modified:
exif["QuickTime:ModifyDate"] = creationdate
else:
exif["QuickTime:ModifyDate"] = datetime_tz_to_utc(
self.date_modified
).strftime("%Y:%m:%d %H:%M:%S")
else:
exif["QuickTime:ModifyDate"] = creationdate
return exif
@@ -1381,7 +1514,8 @@ def _exiftool_json_sidecar(
EXIF:ModifyDate
IPTC:DigitalCreationDate
IPTC:DateCreated
QuickTime:CreationDate (UTC)
QuickTime:CreationDate
QuickTime:CreateDate (UTC)
QuickTime:ModifyDate (UTC)
QuickTime:GPSCoordinates
UserData:GPSCoordinates
@@ -1478,6 +1612,15 @@ def _xmp_sidecar(
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
subject_list = list(self.keywords) + person_list
# remove duplicates
# sorted mainly to make testing the XMP file easier
if keyword_list:
keyword_list = sorted(list(set(keyword_list)))
if subject_list:
subject_list = sorted(list(set(subject_list)))
if person_list:
person_list = sorted(list(set(person_list)))
xmp_str = xmp_template.render(
photo=self,
description=description,

View File

@@ -1,11 +1,32 @@
""" Methods and class for PhotoInfo exposing SearchInfo data such as labels
Adds the following properties to PhotoInfo (valid only for Photos 5):
search_info: returns a SearchInfo object
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 _PHOTOS_4_VERSION, SEARCH_CATEGORY_LABEL
from .._constants import (
_PHOTOS_4_VERSION,
SEARCH_CATEGORY_CITY,
SEARCH_CATEGORY_LABEL,
SEARCH_CATEGORY_NEIGHBORHOOD,
SEARCH_CATEGORY_PLACE_NAME,
SEARCH_CATEGORY_STREET,
SEARCH_CATEGORY_ALL_LOCALITY,
SEARCH_CATEGORY_COUNTRY,
SEARCH_CATEGORY_STATE,
SEARCH_CATEGORY_STATE_ABBREVIATION,
SEARCH_CATEGORY_BODY_OF_WATER,
SEARCH_CATEGORY_MONTH,
SEARCH_CATEGORY_YEAR,
SEARCH_CATEGORY_HOLIDAY,
SEARCH_CATEGORY_ACTIVITY,
SEARCH_CATEGORY_SEASON,
SEARCH_CATEGORY_VENUE,
SEARCH_CATEGORY_VENUE_TYPE,
SEARCH_CATEGORY_MEDIA_TYPES,
)
@property
@@ -24,6 +45,22 @@ def search_info(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
@@ -43,14 +80,15 @@ def labels_normalized(self):
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info.labels_normalized
return self.search_info_normalized.labels
class SearchInfo:
""" Info about search terms such as machine learning labels that Photos knows about a photo """
def __init__(self, photo):
""" photo: PhotoInfo object """
def __init__(self, photo, normalized=False):
""" photo: PhotoInfo object
normalized: if True, all properties return normalized (lower case) results """
if photo._db._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError(
@@ -58,6 +96,7 @@ class SearchInfo:
)
self._photo = photo
self._normalized = normalized
self.uuid = photo.uuid
try:
# get search info for this UUID
@@ -69,25 +108,170 @@ class SearchInfo:
@property
def labels(self):
""" return list of labels associated with Photo """
if self._db_searchinfo:
labels = [
rec["content_string"]
for rec in self._db_searchinfo
if rec["category"] == SEARCH_CATEGORY_LABEL
]
else:
labels = []
return labels
return self._get_text_for_category(SEARCH_CATEGORY_LABEL)
@property
def labels_normalized(self):
""" return list of normalized labels associated with Photo """
def place_names(self):
""" returns list of place names """
return self._get_text_for_category(SEARCH_CATEGORY_PLACE_NAME)
@property
def streets(self):
""" returns list of street names """
return self._get_text_for_category(SEARCH_CATEGORY_STREET)
@property
def neighborhoods(self):
""" returns list of neighborhoods """
return self._get_text_for_category(SEARCH_CATEGORY_NEIGHBORHOOD)
@property
def locality_names(self):
""" returns list of other locality names """
locality = []
for category in SEARCH_CATEGORY_ALL_LOCALITY:
locality += self._get_text_for_category(category)
return locality
@property
def city(self):
""" returns city/town """
city = self._get_text_for_category(SEARCH_CATEGORY_CITY)
return city[0] if city else ""
@property
def state(self):
""" returns state name """
state = self._get_text_for_category(SEARCH_CATEGORY_STATE)
return state[0] if state else ""
@property
def state_abbreviation(self):
""" returns state abbreviation """
abbrev = self._get_text_for_category(SEARCH_CATEGORY_STATE_ABBREVIATION)
return abbrev[0] if abbrev else ""
@property
def country(self):
""" returns country name """
country = self._get_text_for_category(SEARCH_CATEGORY_COUNTRY)
return country[0] if country else ""
@property
def month(self):
""" returns month name """
month = self._get_text_for_category(SEARCH_CATEGORY_MONTH)
return month[0] if month else ""
@property
def year(self):
""" returns year """
year = self._get_text_for_category(SEARCH_CATEGORY_YEAR)
return year[0] if year else ""
@property
def bodies_of_water(self):
""" returns list of body of water names """
return self._get_text_for_category(SEARCH_CATEGORY_BODY_OF_WATER)
@property
def holidays(self):
""" returns list of holiday names """
return self._get_text_for_category(SEARCH_CATEGORY_HOLIDAY)
@property
def activities(self):
""" returns list of activity names """
return self._get_text_for_category(SEARCH_CATEGORY_ACTIVITY)
@property
def season(self):
""" returns season name """
season = self._get_text_for_category(SEARCH_CATEGORY_SEASON)
return season[0] if season else ""
@property
def venues(self):
""" returns list of venue names """
return self._get_text_for_category(SEARCH_CATEGORY_VENUE)
@property
def venue_types(self):
""" returns list of venue types """
return self._get_text_for_category(SEARCH_CATEGORY_VENUE_TYPE)
@property
def media_types(self):
""" returns list of media types (photo, video, panorama, etc) """
types = []
for category in SEARCH_CATEGORY_MEDIA_TYPES:
types += self._get_text_for_category(category)
return types
@property
def all(self):
""" return all search info properties in a single list """
all = (
self.labels
+ self.place_names
+ self.streets
+ self.neighborhoods
+ self.locality_names
+ self.bodies_of_water
+ self.holidays
+ self.activities
+ self.venues
+ self.venue_types
+ self.media_types
)
if self.city:
all += [self.city]
if self.state:
all += [self.state]
if self.state_abbreviation:
all += [self.state_abbreviation]
if self.country:
all += [self.country]
if self.month:
all += [self.month]
if self.year:
all += [self.year]
if self.season:
all += [self.season]
return all
def asdict(self):
""" return dict of search info """
return {
"labels": self.labels,
"place_names": self.place_names,
"streets": self.streets,
"neighborhoods": self.neighborhoods,
"city": self.city,
"locality_names": self.locality_names,
"state": self.state,
"state_abbreviation": self.state_abbreviation,
"country": self.country,
"bodies_of_water": self.bodies_of_water,
"month": self.month,
"year": self.year,
"holidays": self.holidays,
"activities": self.activities,
"season": self.season,
"venues": self.venues,
"venue_types": self.venue_types,
"media_types": self.media_types,
}
def _get_text_for_category(self, category):
""" return list of text for a specified category ID """
if self._db_searchinfo:
labels = [
rec["normalized_string"]
content = "normalized_string" if self._normalized else "content_string"
return [
rec[content]
for rec in self._db_searchinfo
if rec["category"] == SEARCH_CATEGORY_LABEL
if rec["category"] == category
]
else:
labels = []
return labels
return []

View File

@@ -43,6 +43,7 @@ class PhotoInfo:
# import additional methods
from ._photoinfo_searchinfo import (
search_info,
search_info_normalized,
labels,
labels_normalized,
SearchInfo,
@@ -980,6 +981,7 @@ class PhotoInfo:
comments = [comment.asdict() for comment in self.comments]
likes = [like.asdict() for like in self.likes]
faces = [face.asdict() for face in self.face_info]
search_info = self.search_info.asdict() if self.search_info else {}
return {
"library": self._db._library_path,
@@ -1041,6 +1043,7 @@ class PhotoInfo:
"original_filesize": self.original_filesize,
"comments": comments,
"likes": likes,
"search_info": search_info,
}
def json(self):

View File

@@ -104,17 +104,19 @@ def _process_searchinfo(self):
for row in c:
uuid = ints_to_uuid(row[1], row[2])
# strings have null character appended, so strip it
record = {}
record["uuid"] = uuid
record["rowid"] = row[0]
record["uuid_0"] = row[1]
record["uuid_1"] = row[2]
record["groupid"] = row[3]
record["category"] = row[4]
record["owning_groupid"] = row[5]
record["content_string"] = normalize_unicode(row[6].replace("\x00", ""))
record = {
"uuid": uuid,
"rowid": row[0],
"uuid_0": row[1],
"uuid_1": row[2],
"groupid": row[3],
"category": row[4],
"owning_groupid": row[5],
"content_string": normalize_unicode(row[6].replace("\x00", "")),
}
record["normalized_string"] = normalize_unicode(row[7].replace("\x00", ""))
record["lookup_identifier"] = row[8]
record["lookup_identifier"] = normalize_unicode(row[8].replace("\x00", ""))
try:
_db_searchinfo_uuid[uuid].append(record)

View File

@@ -52,6 +52,7 @@ TEMPLATE_SUBSTITUTIONS = {
),
"{photo_or_video}": "'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'",
"{hdr}": "Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
"{edited}": "Photo has been edited (has adjustments)?; True/False value, use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
"{created.year}": "4-digit year of photo creation time",
"{created.yy}": "2-digit year of photo creation time",
@@ -509,7 +510,7 @@ class PhotoTemplate:
subfield = field[9:]
if not self.photo.path:
values = []
values = [None]
else:
exif = ExifTool(self.photo.path)
exifdict = exif.asdict()
@@ -632,7 +633,9 @@ class PhotoTemplate:
elif field == "photo_or_video":
value = self.get_photo_video_type(default)
elif field == "hdr":
value = self.get_photo_hdr(default, bool_val)
value = self.get_photo_bool_attribute("hdr", default, bool_val)
elif field == "edited":
value = self.get_photo_bool_attribute("hasadjustments", default, bool_val)
elif field == "created.date":
value = DateTimeFormatter(self.photo.date).date
elif field == "created.year":
@@ -867,7 +870,7 @@ class PhotoTemplate:
"""
""" return list of values for a multi-valued template field """
values = []
values = []
if field == "album":
values = self.photo.albums
elif field == "keyword":
@@ -962,8 +965,10 @@ class PhotoTemplate:
else:
return default_dict["photo"]
def get_photo_hdr(self, default, bool_val):
if self.photo.hdr:
def get_photo_bool_attribute(self, attr, default, bool_val):
# get value for a PhotoInfo bool attribute
val = getattr(self.photo, attr)
if val:
return bool_val
else:
return default

View File

@@ -80,6 +80,7 @@ setup(
"dataclasses==0.7;python_version<'3.7'",
"wurlitzer>=2.0.1",
"photoscript>=0.1.0",
"toml>=0.10.0",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,

View File

@@ -7,7 +7,7 @@
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<key>pid</key>
<integer>464</integer>
<integer>55247</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

View File

@@ -3,24 +3,24 @@
<plist version="1.0">
<dict>
<key>BackgroundHighlightCollection</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:43Z</date>
<key>BackgroundHighlightEnrichment</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:42Z</date>
<key>BackgroundJobAssetRevGeocode</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:43Z</date>
<key>BackgroundJobSearch</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:43Z</date>
<key>BackgroundPeopleSuggestion</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:41Z</date>
<key>BackgroundUserBehaviorProcessor</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:43Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2020-10-17T23:45:33Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-10-17T23:45:24Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-10-17T23:45:26Z</date>
<date>2020-12-16T05:41:44Z</date>
<key>SiriPortraitDonation</key>
<date>2020-10-17T23:45:25Z</date>
<date>2020-12-16T05:41:43Z</date>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

View File

@@ -18,10 +18,10 @@ PHOTOS_DB = "tests/Test-10.15.7.photoslibrary/database/photos.db"
PHOTOS_DB_PATH = "/Test-10.15.7.photoslibrary/database/photos.db"
PHOTOS_LIBRARY_PATH = "/Test-10.15.7.photoslibrary"
PHOTOS_DB_LEN = 18
PHOTOS_NOT_IN_TRASH_LEN = 16
PHOTOS_DB_LEN = 19
PHOTOS_NOT_IN_TRASH_LEN = 17
PHOTOS_IN_TRASH_LEN = 2
PHOTOS_DB_IMPORT_SESSIONS = 12
PHOTOS_DB_IMPORT_SESSIONS = 14
KEYWORDS = [
"Kids",
@@ -35,6 +35,7 @@ KEYWORDS = [
"United Kingdom",
"foo/bar",
"Travel",
"Maria",
]
# Photos 5 includes blank person for detected face
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
@@ -60,6 +61,7 @@ KEYWORDS_DICT = {
"United Kingdom": 1,
"foo/bar": 1,
"Travel": 2,
"Maria": 1,
}
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 2, _UNKNOWN_PERSON: 1}
ALBUM_DICT = {
@@ -93,7 +95,7 @@ UUID_DICT = {
"not_intrash": "DC99FBDD-7A52-4100-A5BB-344131646C30",
"intrash_person_keywords": "6FD38366-3BF2-407D-81FE-7153EB6125B6",
"import_session": "8846E3E6-8AC8-4857-8448-E3D025784410",
"movie": "2CE332F2-D578-4769-AEFA-7631BB77AA41",
"movie": "D1359D09-1373-4F3B-B0E3-1A4DE573E4A3",
}
UUID_PUMPKIN_FARM = [
@@ -339,7 +341,7 @@ def test_attributes_2(photosdb):
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
assert len(photos) == 1
p = photos[0]
assert p.keywords == ["wedding"]
assert sorted(p.keywords) == ["Maria", "wedding"]
assert p.original_filename == "wedding.jpg"
assert p.filename == "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg"
assert p.date == datetime.datetime(
@@ -971,7 +973,7 @@ def test_from_to_date(photosdb):
time.tzset()
photos = photosdb.photos(from_date=datetime.datetime(2018, 10, 28))
assert len(photos) == 9
assert len(photos) == 10
photos = photosdb.photos(to_date=datetime.datetime(2018, 10, 28))
assert len(photos) == 7

View File

@@ -59,6 +59,8 @@ CLI_EXPORT_FILENAMES = [
"wedding_edited.jpeg",
]
CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES = ["Tulips.jpg", "wedding.jpg"]
CLI_EXPORT_FILENAMES_ALBUM = ["Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg"]
CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"]
@@ -66,7 +68,9 @@ CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"]
CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"]
CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
CLI_EXPORT_EDITED_SUFFIX_TEMPLATE = "{edited?_edited,}"
CLI_EXPORT_ORIGINAL_SUFFIX = "_original"
CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE = "{edited?_original,}"
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"Pumkins1.jpg",
@@ -79,6 +83,17 @@ CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"wedding_bearbeiten.jpeg",
]
CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE = [
"Pumkins1.jpg",
"Pumkins2.jpg",
"Pumpkins3.jpg",
"St James Park.jpg",
"St James Park_edited.jpeg",
"Tulips.jpg",
"wedding.jpg",
"wedding_edited.jpeg",
]
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
"Pumkins1_original.jpg",
"Pumkins2_original.jpg",
@@ -90,6 +105,17 @@ CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
"wedding_edited.jpeg",
]
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE = [
"Pumkins1.jpg",
"Pumkins2.jpg",
"Pumpkins3.jpg",
"St James Park_original.jpg",
"St James Park_edited.jpeg",
"Tulips.jpg",
"wedding_original.jpg",
"wedding_edited.jpeg",
]
CLI_EXPORT_FILENAMES_CURRENT = [
"1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg",
"3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg",
@@ -344,18 +370,18 @@ CLI_EXIFTOOL_QUICKTIME = {
"XMP:TagsList": "Travel",
"XMP:Subject": "Travel",
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
"QuickTime:CreationDate": "2020:01:05 22:13:13",
"QuickTime:CreationDate": "2020:01:05 14:13:13-08:00",
"QuickTime:CreateDate": "2020:01:05 22:13:13",
"QuickTime:ModifyDate": "2020:01:05 22:13:13",
},
"2CE332F2-D578-4769-AEFA-7631BB77AA41": {
"File:FileName": "Jellyfish.mp4",
"D1359D09-1373-4F3B-B0E3-1A4DE573E4A3": {
"File:FileName": "Jellyfish1.mp4",
"XMP:Description": "Jellyfish Video",
"XMP:Title": "Jellyfish",
"XMP:Title": "Jellyfish1",
"XMP:TagsList": "Travel",
"XMP:Subject": "Travel",
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
"QuickTime:CreationDate": "2020:12:05 05:21:52",
"QuickTime:CreationDate": "2020:12:04 21:21:52-08:00",
"QuickTime:CreateDate": "2020:12:05 05:21:52",
"QuickTime:ModifyDate": "2020:12:05 05:21:52",
},
@@ -379,6 +405,12 @@ CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = {
}
}
CLI_EXIFTOOL_ERROR = ["E2078879-A29C-4D6F-BACB-E3BBE6C3EB91"]
CLI_EXIFTOOL_DUPLICATE_KEYWORDS = {
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": "wedding.jpg"
}
LABELS_JSON = {
"labels": {
"Plant": 7,
@@ -492,6 +524,12 @@ UUID_NO_LIKES = [
]
def modify_file(filename):
""" appends data to a file to modify it """
with open(filename, "ab") as fd:
fd.write(b"foo")
@pytest.fixture(autouse=True)
def reset_globals():
""" reset globals in __main__ that tests may have changed """
@@ -875,7 +913,7 @@ def test_export_using_hardlinks_incompat_options():
"-V",
],
)
assert result.exit_code == 0
assert result.exit_code == 1
assert "Incompatible export options" in result.output
@@ -983,7 +1021,10 @@ def test_export_exiftool():
exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict()
for key in CLI_EXIFTOOL[uuid]:
assert exif[key] == CLI_EXIFTOOL[uuid][key]
if type(exif[key]) == list:
assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key])
else:
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
@@ -1017,7 +1058,12 @@ def test_export_exiftool_ignore_date_modified():
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]["File:FileName"]
).asdict()
for key in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]:
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
if type(exif[key]) == list:
assert sorted(exif[key]) == sorted(
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
)
else:
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
@@ -1060,6 +1106,111 @@ def test_export_exiftool_quicktime():
os.unlink(filename)
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_duplicate_keywords():
""" ensure duplicate keywords are removed """
import glob
import os
import os.path
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_EXIFTOOL_DUPLICATE_KEYWORDS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--uuid",
f"{uuid}",
],
)
exif = ExifTool(CLI_EXIFTOOL_DUPLICATE_KEYWORDS[uuid])
exifdict = exif.asdict()
assert sorted(exifdict["IPTC:Keywords"]) == ["Maria", "wedding"]
assert sorted(exifdict["XMP:Subject"]) == ["Maria", "wedding"]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_error():
"""" test --exiftool catching error """
import glob
import os
import os.path
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_EXIFTOOL:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]])
exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict()
for key in CLI_EXIFTOOL[uuid]:
if type(exif[key]) == list:
assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key])
else:
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_option():
""" test --exiftool-option """
import glob
import os
import os.path
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# first export with --exiftool, one file produces a warning
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool"]
)
assert result.exit_code == 0
assert "exiftool warning" in result.output
# run again with exiftool-option = "-m" (ignore minor warnings)
# shouldn't see the warning this time
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--exiftool-option",
"-m",
],
)
assert result.exit_code == 0
assert "exiftool warning" not in result.output
def test_export_edited_suffix():
""" test export with --edited-suffix """
import glob
@@ -1087,6 +1238,33 @@ def test_export_edited_suffix():
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX)
def test_export_edited_suffix_template():
""" test export with --edited-suffix template """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--edited-suffix",
CLI_EXPORT_EDITED_SUFFIX_TEMPLATE,
"-V",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE)
def test_export_original_suffix():
""" test export with --original-suffix """
import glob
@@ -1114,6 +1292,33 @@ def test_export_original_suffix():
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX)
def test_export_original_suffix_template():
""" test export with --original-suffix template """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-suffix",
CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE,
"-V",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE)
@pytest.mark.skipif(
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
reason="Skip if running in Github actions, no GPU.",
@@ -3709,6 +3914,184 @@ def test_export_touch_files_exiftool_update():
assert "skipped: 18" in result.output
def test_export_ignore_signature():
""" test export with --ignore-signature """
import os
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# first, export some files
result = runner.invoke(export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V"])
assert result.exit_code == 0
# modify a couple of files
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
modify_file(f"./{filename}")
# export with --update and --ignore-signature
# which should ignore the two modified files
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--ignore-signature",
],
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
# export with --update and not --ignore-signature
# which should updated the two modified files
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update"]
)
assert result.exit_code == 0
assert "updated: 2" in result.output
# run --update again, should be 0 files exported
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--update"]
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
def test_export_ignore_signature_sidecar():
""" test export with --ignore-signature and --sidecar """
"""
Test the following use cases:
If the metadata (in Photos) that went into the sidecar did not change, the sidecar will not be updated
If the metadata (in Photos) that went into the sidecar did change, a new sidecar is written but a new image file is not
If a sidecar does not exist for the photo, a sidecar will be written whether or not the photo file was written
"""
import osxphotos
import os
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# first, export some files
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--sidecar", "XMP"]
)
assert result.exit_code == 0
# export with --update and --ignore-signature
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--sidecar",
"XMP",
"--ignore-signature",
],
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
assert "Writing XMP sidecar" not in result.output
# modify a couple of files
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
modify_file(f"./{filename}")
# export with --update and --ignore-signature
# which should ignore the two modified files
# sidecar files should not be re-written
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--sidecar",
"XMP",
"--ignore-signature",
],
)
assert result.exit_code == 0
assert "exported: 0" in result.output
assert "Writing XMP sidecar" not in result.output
# change the sidecar data in export DB
# should result in a new sidecar being exported but not the image itself
exportdb = osxphotos.export_db.ExportDB("./.osxphotos_export.db")
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
exportdb.set_sidecar_for_file(f"{filename}.xmp", "FOO", (0, 1, 2))
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--ignore-signature",
"--sidecar",
"XMP",
],
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
assert result.output.count("Writing XMP sidecar") == len(
CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES
)
# run --update again, should be 0 files exported
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--ignore-signature",
"--sidecar",
"XMP",
],
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
assert "Writing XMP sidecar" not in result.output
# remove XMP files and run again to verify the files get written
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
os.unlink(f"./{filename}.xmp")
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--update",
"--ignore-signature",
"--sidecar",
"XMP",
],
)
assert result.exit_code == 0
assert "exported: 0, updated: 0" in result.output
assert result.output.count("Writing XMP sidecar") == len(
CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES
)
def test_labels():
"""Test osxphotos labels """
import json
@@ -3961,3 +4344,171 @@ def test_export_cleanup():
assert not pathlib.Path("./delete_me.txt").is_file()
assert not pathlib.Path("./foo/delete_me_too.txt").is_file()
def test_save_load_config():
""" test --save-config, --load-config """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# test save config file
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--sidecar",
"XMP",
"--touch-file",
"--update",
"--save-config",
"config.toml",
],
)
assert result.exit_code == 0
assert "Saving options to file" in result.output
files = glob.glob("*")
assert "config.toml" in files
# test load config file
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--load-config",
"config.toml",
],
)
assert result.exit_code == 0
assert "Loaded options from file" in result.output
assert "Skipped up to date XMP sidecar" in result.output
# test overwrite existing config file
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--sidecar",
"XMP",
"--touch-file",
"--not-live",
"--update",
"--save-config",
"config.toml",
],
)
assert result.exit_code == 0
assert "Saving options to file" in result.output
files = glob.glob("*")
assert "config.toml" in files
# test load config file with incompat command line option
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--load-config",
"config.toml",
"--live",
],
)
assert result.exit_code != 0
assert "Incompatible export options" in result.output
# test load config file with command line override
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--load-config",
"config.toml",
"--sidecar",
"json",
],
)
assert result.exit_code == 0
assert "Writing exiftool JSON sidecar" in result.output
assert "Writing XMP sidecar" not in result.output
def test_export_exportdb():
""" test --exportdb """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
import re
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "export.db"],
)
assert result.exit_code == 0
assert re.search(r"Created export database.*export\.db", result.output)
files = glob.glob("*")
assert "export.db" in files
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--exportdb",
"export.db",
"--update",
],
)
assert result.exit_code == 0
assert re.search(r"Using export database.*export\.db", result.output)
# export again w/o --exportdb
result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"])
assert result.exit_code == 0
assert re.search(
r"Created export database.*\.osxphotos_export\.db", result.output
)
files = glob.glob(".*")
assert ".osxphotos_export.db" in files
# now try again with --exportdb, should generate warning
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "export.db"],
)
assert result.exit_code == 0
assert (
"Warning: export database is 'export.db' but found '.osxphotos_export.db'"
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
)

View File

@@ -0,0 +1,86 @@
""" test ConfigOptions class """
import pathlib
import pytest
import toml
from osxphotos.configoptions import (
ConfigOptions,
ConfigOptionsInvalidError,
ConfigOptionsLoadError,
)
VARS = {"foo": "bar", "bar": False, "test1": (), "test2": None, "test2_setting": False}
def test_init():
cfg = ConfigOptions("test", VARS)
assert isinstance(cfg, ConfigOptions)
assert cfg.foo is "bar"
assert cfg.bar == False
assert type(cfg.test1) == tuple
def test_init_with_ignore():
cfg = ConfigOptions("test", VARS, ignore=["test2"])
assert isinstance(cfg, ConfigOptions)
assert hasattr(cfg, "test1")
assert not hasattr(cfg, "test2")
def test_write_to_file_load_from_file(tmpdir):
cfg = ConfigOptions("test", VARS)
cfg.bar = True
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
cfg.write_to_file(str(cfg_file))
assert cfg_file.is_file()
cfg_dict = toml.load(str(cfg_file))
assert cfg_dict["test"]["foo"] == "bar"
cfg2 = ConfigOptions("test", VARS).load_from_file(str(cfg_file))
assert cfg2.foo == "bar"
assert cfg2.bar
def test_load_from_file_error(tmpdir):
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
cfg = ConfigOptions("test", VARS)
cfg.write_to_file(str(cfg_file))
# try to load with a section that doesn't exist in the TOML file
with pytest.raises(ConfigOptionsLoadError):
cfg2 = ConfigOptions("FOO", VARS).load_from_file(str(cfg_file))
def test_asdict():
cfg = ConfigOptions("test", VARS)
cfg_dict = cfg.asdict()
assert cfg_dict["foo"] == "bar"
assert cfg_dict["bar"] == False
assert cfg_dict["test1"] == ()
def test_validate():
cfg = ConfigOptions("test", VARS)
# test exclusive
assert cfg.validate(exclusive=[("foo", "bar")])
cfg.bar = True
with pytest.raises(ConfigOptionsInvalidError):
assert cfg.validate(exclusive=[("foo", "bar")])
# test dependent
cfg.test2 = True
cfg.test2_setting = 1.0
assert cfg.validate(dependent=[("test2_setting", ("test2"))])
cfg.test2 = False
with pytest.raises(ConfigOptionsInvalidError):
assert cfg.validate(dependent=[("test2_setting", ("test2"))])
# test inclusive
cfg.foo = "foo"
cfg.bar = True
assert cfg.validate(inclusive=[("foo", "bar")])
cfg.foo = None
with pytest.raises(ConfigOptionsInvalidError):
assert cfg.validate(inclusive=[("foo", "bar")])

View File

@@ -2,6 +2,8 @@ import pytest
from osxphotos.exiftool import get_exiftool_path
TEST_FILE_ONE_KEYWORD = "tests/test-images/wedding.jpg"
TEST_FILE_BAD_IMAGE = "tests/test-images/badimage.jpeg"
TEST_FILE_WARNING = "tests/test-images/exiftool_warning.heic"
TEST_FILE_MULTI_KEYWORD = "tests/test-images/Tulips.jpg"
TEST_MULTI_KEYWORDS = [
"Top Shot",
@@ -109,8 +111,8 @@ def test_setvalue_1():
assert exif.data["IPTC:Keywords"] == "test"
def test_setvalue_error():
# test setting illegal tag value generates error
def test_setvalue_warning():
# test setting illegal tag value generates warning
import os.path
import tempfile
import osxphotos.exiftool
@@ -122,6 +124,22 @@ def test_setvalue_error():
exif = osxphotos.exiftool.ExifTool(tempfile)
exif.setvalue("IPTC:Foo", "test")
assert exif.warning
def test_setvalue_error():
# test setting tag on bad image generates error
import os.path
import tempfile
import osxphotos.exiftool
from osxphotos.fileutil import FileUtil
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_BAD_IMAGE))
FileUtil.copy(TEST_FILE_BAD_IMAGE, tempfile)
exif = osxphotos.exiftool.ExifTool(tempfile)
exif.setvalue("IPTC:Keywords", "test")
assert exif.error
@@ -142,7 +160,7 @@ def test_setvalue_context_manager():
exif.setvalue("XMP:Title", "title")
exif.setvalue("XMP:Subject", "subject")
assert exif.error is None
assert not exif.error
exif2 = osxphotos.exiftool.ExifTool(tempfile)
exif2._read_exif()
@@ -151,8 +169,8 @@ def test_setvalue_context_manager():
assert exif2.data["XMP:Subject"] == "subject"
def test_setvalue_context_manager_error():
# test setting a tag value as context manager when error generated
def test_setvalue_context_manager_warning():
# test setting a tag value as context manager when warning generated
import os.path
import tempfile
import osxphotos.exiftool
@@ -164,9 +182,48 @@ def test_setvalue_context_manager_error():
with osxphotos.exiftool.ExifTool(tempfile) as exif:
exif.setvalue("Foo:Bar", "test1")
assert exif.warning
def test_setvalue_context_manager_error():
# test setting a tag value as context manager when error generated
import os.path
import tempfile
import osxphotos.exiftool
from osxphotos.fileutil import FileUtil
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_BAD_IMAGE))
FileUtil.copy(TEST_FILE_BAD_IMAGE, tempfile)
with osxphotos.exiftool.ExifTool(tempfile) as exif:
exif.setvalue("IPTC:Keywords", "test1")
assert exif.error
def test_flags():
# test that flags work
import os.path
import tempfile
import osxphotos.exiftool
from osxphotos.fileutil import FileUtil
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_WARNING))
FileUtil.copy(TEST_FILE_WARNING, tempfile)
with osxphotos.exiftool.ExifTool(tempfile) as exif:
exif.setvalue("XMP:Subject", "foo/bar")
assert exif.warning
# test again with -m: ignore minor warnings
FileUtil.unlink(tempfile)
FileUtil.copy(TEST_FILE_WARNING, tempfile)
with osxphotos.exiftool.ExifTool(tempfile, flags=["-m"]) as exif:
exif.setvalue("XMP:Subject", "foo/bar")
assert not exif.warning
def test_clear_value():
# test clearing a tag value
import os.path

View File

@@ -22,6 +22,7 @@ KEYWORDS = [
"St. James's Park",
"UK",
"United Kingdom",
"Maria"
]
# Photos 5 includes blank person for detected face
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
@@ -39,6 +40,7 @@ KEYWORDS_DICT = {
"St. James's Park": 1,
"UK": 1,
"United Kingdom": 1,
"Maria": 1,
}
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1, _UNKNOWN_PERSON: 1}
ALBUM_DICT = {
@@ -70,8 +72,8 @@ EXIF_JSON_UUID = UUID_DICT["has_adjustments"]
EXIF_JSON_EXPECTED = """
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding"],
"IPTC:Keywords": ["wedding"],
"XMP:TagsList": ["Maria", "wedding"],
"IPTC:Keywords": ["Maria", "wedding"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
@@ -85,8 +87,8 @@ EXIF_JSON_EXPECTED = """
EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding"],
"IPTC:Keywords": ["wedding"],
"XMP:TagsList": ["Maria", "wedding"],
"IPTC:Keywords": ["Maria", "wedding"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
@@ -522,8 +524,8 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog):
"""
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"XMP:TagsList": ["Maria", "wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"IPTC:Keywords": ["Maria", "wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
@@ -571,8 +573,8 @@ def test_exiftool_json_sidecar_keyword_template():
"""
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"XMP:TagsList": ["Maria", "wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"IPTC:Keywords": ["Maria", "wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
@@ -716,7 +718,7 @@ def test_xmp_sidecar_is_valid(tmp_path):
xmp_file = tmp_path / XMP_FILENAME
assert xmp_file.is_file()
exiftool = ExifTool(str(xmp_file))
output, _ = exiftool.run_commands("-validate", "-warning")
output, _, _ = exiftool.run_commands("-validate", "-warning")
assert output == b"[ExifTool] Validate : 0 0 0"

View File

@@ -427,9 +427,9 @@ def test_xmp_sidecar():
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Katie</rdf:li>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
@@ -438,8 +438,8 @@ def test_xmp_sidecar():
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
<rdf:li>Suzy</rdf:li>
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>

View File

@@ -0,0 +1,95 @@
""" test ExportResults class """
import pytest
from osxphotos.photoinfo import ExportResults
EXPORT_RESULT_ATTRIBUTES = [
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
"missing",
"error",
"exiftool_warning",
"exiftool_error",
]
def test_exportresults_init():
results = ExportResults()
assert results.exported == []
assert results.new == []
assert results.updated == []
assert results.skipped == []
assert results.exif_updated == []
assert results.touched == []
assert results.converted_to_jpeg == []
assert results.sidecar_json_written == []
assert results.sidecar_json_skipped == []
assert results.sidecar_xmp_written == []
assert results.sidecar_xmp_skipped == []
assert results.missing == []
assert results.error == []
assert results.exiftool_warning == []
assert results.exiftool_error == []
def test_exportresults_iadd():
results1 = ExportResults()
results2 = ExportResults()
for x in EXPORT_RESULT_ATTRIBUTES:
setattr(results1, x, [f"{x}1"])
setattr(results2, x, [f"{x}2"])
results1 += results2
for x in EXPORT_RESULT_ATTRIBUTES:
assert getattr(results1, x) == [f"{x}1", f"{x}2"]
# exiftool_warning and exiftool_error are lists of tuples
results1 = ExportResults()
results2 = ExportResults()
results1.exiftool_warning = [("exiftool_warning1", "foo")]
results2.exiftool_warning = [("exiftool_warning2", "bar")]
results1.exiftool_error = [("exiftool_error1", "foo")]
results2.exiftool_error = [("exiftool_error2", "bar")]
results1 += results2
assert results1.exiftool_warning == [
("exiftool_warning1", "foo"),
("exiftool_warning2", "bar"),
]
assert results1.exiftool_error == [
("exiftool_error1", "foo"),
("exiftool_error2", "bar"),
]
def test_all_files():
""" test ExportResults.all_files() """
results = ExportResults()
for x in EXPORT_RESULT_ATTRIBUTES:
setattr(results, x, [f"{x}1"])
results.exiftool_warning = [("exiftool_warning1", "foo")]
results.exiftool_error = [("exiftool_error1", "foo")]
assert sorted(results.all_files()) == 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_xmp_written=[],sidecar_xmp_skipped=[],missing=[],error=[],exiftool_warning=[],exiftool_error=[])"
)

View File

@@ -210,7 +210,7 @@ def test_search_info(photosdb):
def test_labels_normalized(photosdb):
for uuid in LABELS_NORMALIZED_DICT:
photo = photosdb.photos(uuid=[uuid])[0]
assert sorted(photo.search_info.labels_normalized) == sorted(
assert sorted(photo.search_info_normalized.labels) == sorted(
LABELS_NORMALIZED_DICT[uuid]
)
assert sorted(photo.labels_normalized) == sorted(LABELS_NORMALIZED_DICT[uuid])

View File

@@ -349,7 +349,7 @@ def test_labels_normalized(photosdb):
for uuid in LABELS_NORMALIZED_DICT:
photo = photosdb.photos(uuid=[uuid])[0]
logging.warning(f"uuid = {uuid}")
assert sorted(photo.search_info.labels_normalized) == sorted(
assert sorted(photo.search_info_normalized.labels) == sorted(
LABELS_NORMALIZED_DICT[uuid]
)
assert sorted(photo.labels_normalized) == sorted(LABELS_NORMALIZED_DICT[uuid])

View File

@@ -0,0 +1,57 @@
""" test SearchInfo class """
import json
import os
import pytest
import osxphotos
# These tests must be run against the author's personal photo library
skip_test = "OSXPHOTOS_TEST_EXPORT" not in os.environ
pytestmark = pytest.mark.skipif(
skip_test, reason="These tests only run against system Photos library"
)
PHOTOS_DB = "/Users/rhet/Pictures/Photos Library.photoslibrary"
with open("tests/search_info_test_data_10_15_7.json") as fp:
test_data = json.load(fp)
UUID_SEARCH_INFO = test_data["UUID_SEARCH_INFO"]
UUID_SEARCH_INFO_NORMALIZED = test_data["UUID_SEARCH_INFO_NORMALIZED"]
UUID_SEARCH_INFO_ALL = test_data["UUID_SEARCH_INFO_ALL"]
UUID_SEARCH_INFO_ALL_NORMALIZED = test_data["UUID_SEARCH_INFO_ALL_NORMALIZED"]
@pytest.fixture(scope="module")
def photosdb():
return osxphotos.PhotosDB(dbfile=PHOTOS_DB)
def test_search_info(photosdb):
for uuid in UUID_SEARCH_INFO:
photo = photosdb.get_photo(uuid)
assert photo.search_info.asdict() == UUID_SEARCH_INFO[uuid]
def test_search_info_normalized(photosdb):
for uuid in UUID_SEARCH_INFO_NORMALIZED:
photo = photosdb.get_photo(uuid)
assert (
photo.search_info_normalized.asdict() == UUID_SEARCH_INFO_NORMALIZED[uuid]
)
def test_search_info_all(photosdb):
for uuid in UUID_SEARCH_INFO_ALL:
photo = photosdb.get_photo(uuid)
assert sorted(photo.search_info.all) == sorted(UUID_SEARCH_INFO_ALL[uuid])
def test_search_info_all_normalized(photosdb):
for uuid in UUID_SEARCH_INFO_ALL_NORMALIZED:
photo = photosdb.get_photo(uuid)
assert sorted(photo.search_info_normalized.all) == sorted(
UUID_SEARCH_INFO_ALL_NORMALIZED[uuid]
)

View File

@@ -59,10 +59,16 @@ TEMPLATE_VALUES_TITLE = {
}
# Boolean type values that render to True
UUID_BOOL_VALUES = {"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15"}
UUID_BOOL_VALUES = {
"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15",
"edited": "51F2BEF7-431A-4D31-8AC1-3284A57826AE",
}
# Boolean type values that render to False
UUID_BOOL_VALUES_NOT = {"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE"}
UUID_BOOL_VALUES_NOT = {
"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE",
"edited": "CCBE0EB9-AE9F-4479-BFFD-107042C75227",
}
# for exiftool template
UUID_EXIFTOOL = {

View File

@@ -0,0 +1,34 @@
""" Create the test data needed for test_search_info_10_15_7.py """
# reads data from the author's system photo library to build the test data
# used to test SearchInfo
import json
import osxphotos
UUID = [
"C8EAF50A-D891-4E0C-8086-C417E1284153",
"71DFB4C3-E868-4BE4-906E-D96BD8692D7E",
"2C151013-5BBA-4D00-B70F-1C9420418B86",
]
data = {
"UUID_SEARCH_INFO": {},
"UUID_SEARCH_INFO_NORMALIZED": {},
"UUID_SEARCH_INFO_ALL": {},
"UUID_SEARCH_INFO_ALL_NORMALIZED": {},
}
photosdb = osxphotos.PhotosDB()
for uuid in UUID:
photo = photosdb.get_photo(uuid)
search = photo.search_info
search_norm = photo.search_info_normalized
data["UUID_SEARCH_INFO"][uuid] = search.asdict()
data["UUID_SEARCH_INFO_NORMALIZED"][uuid] = search_norm.asdict()
data["UUID_SEARCH_INFO_ALL"][uuid] = search.all
data["UUID_SEARCH_INFO_ALL_NORMALIZED"][uuid] = search_norm.all
print(json.dumps(data))