Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f469cccc4b | ||
|
|
4ece5c0d1c | ||
|
|
9ca5d8f0fd | ||
|
|
2a49255277 | ||
|
|
f3b7134af1 | ||
|
|
73716f12cd | ||
|
|
a4bbb6492d | ||
|
|
aca19f4063 | ||
|
|
2ebd4c33ff | ||
|
|
da2f91ffc7 | ||
|
|
ef94933dd8 | ||
|
|
e0e8850e56 | ||
|
|
8d1ccda0c8 | ||
|
|
6171c4d665 | ||
|
|
4678f15bc8 | ||
|
|
a7c688cfc2 | ||
|
|
880a9b67a1 | ||
|
|
d40b16a456 | ||
|
|
dcd2fde6d0 | ||
|
|
ad860b1500 | ||
|
|
7ad4db6c15 | ||
|
|
0f1cc7cc71 | ||
|
|
5e6a6cd5fb | ||
|
|
e394d8e6be | ||
|
|
8237bc8267 | ||
|
|
e097f3aad5 | ||
|
|
3155045ec8 | ||
|
|
4f64eeb996 | ||
|
|
3c14ace826 | ||
|
|
d5730dd8ae | ||
|
|
5c1c0c5c5a | ||
|
|
d8593a01e2 | ||
|
|
1dffe894ff | ||
|
|
29721dd4f0 | ||
|
|
6559c4d8f6 | ||
|
|
baf45ccd2a | ||
|
|
aca85ee2aa | ||
|
|
9584a9ccc5 | ||
|
|
182b816e34 | ||
|
|
0262e0d97e | ||
|
|
73f936e061 | ||
|
|
09687cfca4 | ||
|
|
e17ee0e388 | ||
|
|
d7c81adae8 | ||
|
|
37b1e5ca47 | ||
|
|
22355fd446 |
@@ -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
|
||||
|
||||
72
CHANGELOG.md
@@ -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
@@ -3,7 +3,7 @@
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
[](#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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.38.0"
|
||||
__version__ = "0.38.11"
|
||||
|
||||
|
||||
|
||||
173
osxphotos/configoptions.py
Normal 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())}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
1
setup.py
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
After Width: | Height: | Size: 550 KiB |
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 54 KiB |
1
tests/search_info_test_data_10_15_7.json
Normal file
BIN
tests/test-images/badimage.jpeg
Normal file
|
After Width: | Height: | Size: 500 KiB |
BIN
tests/test-images/exiftool_warning.heic
Normal file
BIN
tests/test-images/screenshot-really-a-png.jpeg
Normal file
|
After Width: | Height: | Size: 550 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
86
tests/test_configoptions.py
Normal 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")])
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
95
tests/test_exportresults.py
Normal 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=[])"
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
|
||||
57
tests/test_search_info_10_15_7.py
Normal 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]
|
||||
)
|
||||
@@ -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 = {
|
||||
|
||||
34
utils/generate_search_info_test_data.py
Normal 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))
|
||||