Compare commits

...

24 Commits

Author SHA1 Message Date
Rhet Turnbull
937da9e617 Added computed aesthetic scores, closes #141, closes #122 2020-06-14 08:09:37 -07:00
Rhet Turnbull
435868a0a7 Updated CHANGELOG.md 2020-06-13 19:46:55 -07:00
Rhet Turnbull
d9802247d9 Added --label to CLI, closes #157 2020-06-13 19:40:46 -07:00
Rhet Turnbull
f39a92a352 Updated CHANGELOG.md 2020-06-13 15:11:10 -07:00
Rhet Turnbull
40dc7d32f2 Extende --ignore-case to --person, --keyword, --album, closes #162 2020-06-13 15:06:27 -07:00
Rhet Turnbull
4cd6c8f617 Updated CHANGELOG.md 2020-06-13 11:32:48 -07:00
Rhet Turnbull
0004250e74 Updated README.md to document template system 2020-06-13 10:52:18 -07:00
Rhet Turnbull
868ee7737b Added hour, min, sec, strftime templates, closes #158 2020-06-13 10:32:04 -07:00
Rhet Turnbull
5387f8e2f9 Added hour, min, sec to template system, issue #158 2020-06-13 09:17:34 -07:00
Rhet Turnbull
73b499f405 Updated CHANGELOG.md 2020-06-13 09:04:23 -07:00
Rhet Turnbull
06fa1edcae Bug fix for issue #136 2020-06-13 08:52:23 -07:00
Rhet Turnbull
cf2615da62 Updated DatetimeFormatter to include hour/min/sec 2020-06-13 08:32:56 -07:00
Rhet Turnbull
4ba1982d74 Added test for issue #156 2020-06-09 23:03:30 -07:00
Rhet Turnbull
abd10b73e8 Updated help text for debug-dump 2020-06-07 14:32:49 -07:00
Rhet Turnbull
7cd7b51598 Added hidden debug-dump command to CLI 2020-06-07 14:17:08 -07:00
Rhet Turnbull
801dc62c4b Updated CHANGELOG.md 2020-06-07 08:29:48 -07:00
Rhet Turnbull
72f034ef85 Fix for bug in handling of deleted albums to address issue #156 2020-06-07 08:14:02 -07:00
Rhet Turnbull
cb993f2e5e Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-06-06 12:12:05 -07:00
Rhet Turnbull
2271d89355 Partial fix for #155 2020-06-06 12:11:50 -07:00
Rhet Turnbull
62d096b5a1 Partial fix for #155 2020-06-06 12:09:51 -07:00
Rhet Turnbull
5c7a0c3a24 Refactoring with sourceryAI 2020-06-01 21:06:09 -07:00
Rhet Turnbull
ec727cc556 Updated CHANGELOG.md 2020-05-31 11:31:24 -07:00
Rhet Turnbull
6c84827ec7 Added --filename to CLI, closes #89 2020-05-31 11:25:34 -07:00
Rhet Turnbull
d47fd46a21 Updated CHANGELOG.md 2020-05-31 06:32:06 -07:00
60 changed files with 2134 additions and 495 deletions

View File

@@ -4,6 +4,71 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.29.18](https://github.com/RhetTbull/osxphotos/compare/v0.29.17...v0.29.18)
> 14 June 2020
- Added --label to CLI, closes #157 [`#157`](https://github.com/RhetTbull/osxphotos/issues/157)
- Updated CHANGELOG.md [`f39a92a`](https://github.com/RhetTbull/osxphotos/commit/f39a92a352a6db133ae84bf12333634adad7a6fd)
#### [v0.29.17](https://github.com/RhetTbull/osxphotos/compare/v0.29.16...v0.29.17)
> 13 June 2020
- Extende --ignore-case to --person, --keyword, --album, closes #162 [`#162`](https://github.com/RhetTbull/osxphotos/issues/162)
- Updated README.md to document template system [`0004250`](https://github.com/RhetTbull/osxphotos/commit/0004250e74eacc19f7986742712225116530a67e)
- Updated CHANGELOG.md [`4cd6c8f`](https://github.com/RhetTbull/osxphotos/commit/4cd6c8f6178f32f72dbd59d471dc24bb1f106869)
#### [v0.29.16](https://github.com/RhetTbull/osxphotos/compare/v0.29.14...v0.29.16)
> 13 June 2020
- Added hour, min, sec, strftime templates, closes #158 [`#158`](https://github.com/RhetTbull/osxphotos/issues/158)
- Added hour, min, sec to template system, issue #158 [`5387f8e`](https://github.com/RhetTbull/osxphotos/commit/5387f8e2f970ff7fa1967ccad87b45a4f7e50d32)
- Updated CHANGELOG.md [`73b499f`](https://github.com/RhetTbull/osxphotos/commit/73b499f40526da7ee214178f72b684b77c60d11a)
#### [v0.29.14](https://github.com/RhetTbull/osxphotos/compare/v0.29.13...v0.29.14)
> 13 June 2020
- Updated DatetimeFormatter to include hour/min/sec [`cf2615d`](https://github.com/RhetTbull/osxphotos/commit/cf2615da62801f1fbde61c7905431963e121e2e9)
- Added test for issue #156 [`4ba1982`](https://github.com/RhetTbull/osxphotos/commit/4ba1982d745f0d532ead090177051d928465ed03)
- Bug fix for issue #136 [`06fa1ed`](https://github.com/RhetTbull/osxphotos/commit/06fa1edcae7139b543e17ec63810c37c18cc2780)
#### [v0.29.13](https://github.com/RhetTbull/osxphotos/compare/v0.29.12...v0.29.13)
> 7 June 2020
- Added hidden debug-dump command to CLI [`7cd7b51`](https://github.com/RhetTbull/osxphotos/commit/7cd7b5159845fce15d50a7bfc0ac50d122bee527)
- Updated CHANGELOG.md [`801dc62`](https://github.com/RhetTbull/osxphotos/commit/801dc62c4b7e24e1b92965ef6348113c440b1f9b)
#### [v0.29.12](https://github.com/RhetTbull/osxphotos/compare/v0.29.9...v0.29.12)
> 7 June 2020
- Refactoring with sourceryAI [`5c7a0c3`](https://github.com/RhetTbull/osxphotos/commit/5c7a0c3a246cd5fec329b4fd4979d2b77352f916)
- Partial fix for #155 [`2271d89`](https://github.com/RhetTbull/osxphotos/commit/2271d8935507ecc27e6227b11b4796f2f4d2f10d)
- Partial fix for #155 [`62d096b`](https://github.com/RhetTbull/osxphotos/commit/62d096b5a1a7e960195ec5c48fc9cffbebf2c735)
#### [v0.29.9](https://github.com/RhetTbull/osxphotos/compare/v0.29.8...v0.29.9)
> 31 May 2020
- Added --filename to CLI, closes #89 [`#89`](https://github.com/RhetTbull/osxphotos/issues/89)
- Updated CHANGELOG.md [`d47fd46`](https://github.com/RhetTbull/osxphotos/commit/d47fd46a21881bea86d1bc624c6027e2cbe08d9c)
#### [v0.29.8](https://github.com/RhetTbull/osxphotos/compare/v0.29.5...v0.29.8)
> 31 May 2020
- Added --edited-suffix to CLI, closes #145 [`#145`](https://github.com/RhetTbull/osxphotos/issues/145)
- refactored render_template, closes #149 [`#149`](https://github.com/RhetTbull/osxphotos/issues/149)
- Added test for Photos 5 on 10.15.5 [`2243395`](https://github.com/RhetTbull/osxphotos/commit/2243395bff9e1cc379626cc5007e44e6e63b95e0)
- Refactored template code out of PhotoInfo into PhotoTemplate [`16f802b`](https://github.com/RhetTbull/osxphotos/commit/16f802bf717610e13712b8aa477d05d94b14d294)
- Added test for SearchInfo on 10.15.5 [`3a8bef1`](https://github.com/RhetTbull/osxphotos/commit/3a8bef1572e4d83b1e0a4b85c8f06e329cc7e8de)
- performance improvements for update and export_db [`42b89d3`](https://github.com/RhetTbull/osxphotos/commit/42b89d34f3d14818daefbd3bfabc1be9344d2e1a)
- More refactoring in PhotoTemplate [`f35ea70`](https://github.com/RhetTbull/osxphotos/commit/f35ea70b72e8c6743b1f6009466d2a15d40338ac)
#### [v0.29.5](https://github.com/RhetTbull/osxphotos/compare/v0.29.2...v0.29.5) #### [v0.29.5](https://github.com/RhetTbull/osxphotos/compare/v0.29.2...v0.29.5)
> 25 May 2020 > 25 May 2020

118
README.md
View File

@@ -17,6 +17,7 @@
+ [AlbumInfo](#albuminfo) + [AlbumInfo](#albuminfo)
+ [FolderInfo](#folderinfo) + [FolderInfo](#folderinfo)
+ [PlaceInfo](#placeinfo) + [PlaceInfo](#placeinfo)
+ [ScoreInfo](#scoreinfo)
+ [Template Substitutions](#template-substitutions) + [Template Substitutions](#template-substitutions)
+ [Utility Functions](#utility-functions) + [Utility Functions](#utility-functions)
* [Examples](#examples) * [Examples](#examples)
@@ -34,7 +35,7 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
## Supported operating systems ## 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.4 / 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.5 / Photos 5.0.
Requires python >= 3.8. You can probably get this to run with Python 3.6 or 3.7 (see notes [below](#Installation-instructions)) but only 3.8+ is officially supported. Requires python >= 3.8. You can probably get this to run with Python 3.6 or 3.7 (see notes [below](#Installation-instructions)) but only 3.8+ is officially supported.
@@ -59,7 +60,7 @@ You can also install directly from [pypi](https://pypi.org/) but you must use py
This package will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos` This package will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
If you only care about the command line tool, I recommend installing with [pipx](https://github.com/pipxproject/pipx) If you only care about the command line tool, you can download an executable of the latest [release](https://github.com/RhetTbull/osxphotos/releases). Alternatively, I recommend installing with [pipx](https://github.com/pipxproject/pipx)
After installing pipx: After installing pipx:
`pipx install osxphotos` `pipx install osxphotos`
@@ -90,6 +91,7 @@ Commands:
help Print help; for help on commands: help <command>. help Print help; for help on commands: help <command>.
info Print out descriptive info of the Photos library database. info Print out descriptive info of the Photos library database.
keywords Print out keywords found in the Photos library. keywords Print out keywords found in the Photos library.
labels Print out image classification labels found in the Photos...
list Print list of Photos libraries found on the system. list Print list of Photos libraries found on the system.
persons Print out persons (faces) found in the Photos library. persons Print out persons (faces) found in the Photos library.
places Print out places found in the Photos library. places Print out places found in the Photos library.
@@ -125,13 +127,13 @@ Options:
-V, --verbose Print verbose output. -V, --verbose Print verbose output.
--keyword KEYWORD Search for photos with keyword KEYWORD. If --keyword KEYWORD Search for photos with keyword KEYWORD. If
more than one keyword, treated as "OR", e.g. more than one keyword, treated as "OR", e.g.
find photos match any keyword find photos matching any keyword
--person PERSON Search for photos with person PERSON. If --person PERSON Search for photos with person PERSON. If
more than one person, treated as "OR", e.g. more than one person, treated as "OR", e.g.
find photos match any person find photos matching any person
--album ALBUM Search for photos in album ALBUM. If more --album ALBUM Search for photos in album ALBUM. If more
than one album, treated as "OR", e.g. find than one album, treated as "OR", e.g. find
photos match any album photos matching any album
--folder FOLDER Search for photos in an album in folder --folder FOLDER Search for photos in an album in folder
FOLDER. If more than one folder, treated as FOLDER. If more than one folder, treated as
"OR", e.g. find photos in any FOLDER. Only "OR", e.g. find photos in any FOLDER. Only
@@ -146,11 +148,15 @@ Options:
geolocation info geolocation info
--no-place Search for photos with no associated place --no-place Search for photos with no associated place
name info (no reverse geolocation info) name info (no reverse geolocation info)
--label LABEL Search for photos with image classification
label LABEL (Photos 5 only). If more than
one label, treated as "OR", e.g. find photos
matching any label
--uti UTI Search for photos whose uniform type --uti UTI Search for photos whose uniform type
identifier (UTI) matches UTI identifier (UTI) matches UTI
-i, --ignore-case Case insensitive search for title, -i, --ignore-case Case insensitive search for title,
description, or place. Does not apply to description, place, keyword, person, or
keyword, person, or album. album.
--edited Search for photos that have been edited. --edited Search for photos that have been edited.
--external-edit Search for photos edited in external editor. --external-edit Search for photos edited in external editor.
--favorite Search for photos marked favorite. --favorite Search for photos marked favorite.
@@ -282,6 +288,12 @@ Options:
output directory in the form output directory in the form
'{name,DEFAULT}'. See below for additional '{name,DEFAULT}'. See below for additional
details on templating system. details on templating system.
--filename FILENAME Optional template for specifying name of
output file in the form '{name,DEFAULT}'.
File extension will be added automatically--
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. --edited-suffix SUFFIX Optional suffix for naming edited photos.
Default name for edited photos is in form Default name for edited photos is in form
'photoname_edited.ext'. For example, with ' 'photoname_edited.ext'. For example, with '
@@ -326,13 +338,16 @@ option to re-export the entire library thus rebuilding the
** Templating System ** ** Templating System **
With the --directory option you may specify a template for the export With the --directory and --filename options you may specify a template for the
directory. This directory will be appended to the export path specified in export directory or filename, respectively. The directory will be appended to
the export DEST argument to export. For example, if template is the export path specified in the export DEST argument to export. For example,
'{created.year}/{created.month}', and export desitnation DEST is if template is '{created.year}/{created.month}', and export desitnation DEST
'/Users/maria/Pictures/export', the actual export directory for a photo would is '/Users/maria/Pictures/export', the actual export directory for a photo
be '/Users/maria/Pictures/export/2020/March' if the photo was created in March would be '/Users/maria/Pictures/export/2020/March' if the photo was created in
2020. March 2020. Some template substitutions may result in more than one value, for
example '{album}' if photo is in more than one album or '{keyword}' if photo
has more than one keyword. In this case, more than one copy of the photo will
be exported, each in a separate directory or with a different filename.
The templating system may also be used with the --keyword-template option to The templating system may also be used with the --keyword-template option to
set keywords on export (with --exiftool or --sidecar), for example, to set a set keywords on export (with --exiftool or --sidecar), for example, to set a
@@ -358,9 +373,8 @@ contain a brace symbol ('{' or '}').
If you do not specify a default value and the template substitution has no If you do not specify a default value and the template substitution has no
value, '_' (underscore) will be used as the default value. For example, in the value, '_' (underscore) will be used as the default value. For example, in the
above example, this would result in '2020/_/photoname.jpg' if address was null above example, this would result in '2020/_/photoname.jpg' if address was
I plan to eventually extend the templating system to the exported filename so null.
you can specify the filename using a template.
Substitution Description Substitution Description
{name} Current filename of the photo {name} Current filename of the photo
@@ -384,6 +398,18 @@ Substitution Description
creation time creation time
{created.doy} 3-digit day of year (e.g Julian day) of file {created.doy} 3-digit day of year (e.g Julian day) of file
creation time, starting from 1 (zero padded) creation time, starting from 1 (zero padded)
{created.hour} 2-digit hour of the file creation time
{created.min} 2-digit minute of the file creation time
{created.sec} 2-digit second of the file creation time
{created.strftime} Apply strftime template to file creation
date/time. Should be used in form
{created.strftime,TEMPLATE} where TEMPLATE
is a valid strftime template, e.g.
{created.strftime,%Y-%U} would result in
year-week number of year: '2020-23'. If used
with no template will return null value. See
https://strftime.org/ for help on strftime
templates.
{modified.date} Photo's modification date in ISO format, {modified.date} Photo's modification date in ISO format,
e.g. '2020-03-22' e.g. '2020-03-22'
{modified.year} 4-digit year of file modification time {modified.year} 4-digit year of file modification time
@@ -399,6 +425,9 @@ Substitution Description
{modified.doy} 3-digit day of year (e.g Julian day) of file {modified.doy} 3-digit day of year (e.g Julian day) of file
modification time, starting from 1 (zero modification time, starting from 1 (zero
padded) padded)
{modified.hour} 2-digit hour of the file modification time
{modified.min} 2-digit minute of the file modification time
{modified.sec} 2-digit second of the file modification time
{place.name} Place name from the photo's reverse {place.name} Place name from the photo's reverse
geolocation data, as displayed in Photos geolocation data, as displayed in Photos
{place.country_code} The ISO country code from the photo's {place.country_code} The ISO country code from the photo's
@@ -1121,7 +1150,12 @@ photo.exiftool.setvalue("XMP:Title", "Title of photo")
photo.exiftool.addvalues("IPTC:Keywords", "vacation", "beach") photo.exiftool.addvalues("IPTC:Keywords", "vacation", "beach")
``` ```
**Caution**: I caution against writing new EXIF data to photos in the Photos library because this will overwrite the original copy of the photo and could adversely affect how Photos behaves. `exiftool.as_dict()` is useful for getting access to all the photos information but if you want to write new EXIF data, I recommend you export the photo first then write the data. [PhotoInfo.export()](#export) does this if called with `exiftool=True`. **Caution**: I caution against writing new EXIF data to photos in the Photos library because this will overwrite the original copy of the photo and could adversely affect how Photos behaves. `exiftool.as_dict()` is useful for getting access to all the photos information but if you want to write new EXIF data, I recommend you export the photo first then write the data. [PhotoInfo.export()](#export) does this if called with `exiftool=True`.
#### `score`
Returns a [ScoreInfo](#scoreinfo) data class object which provides access to the computed aesthetic scores for each photo.
**Note**: Valid only for Photos 5; returns None for earlier Photos versions.
#### `json()` #### `json()`
Returns a JSON representation of all photo info Returns a JSON representation of all photo info
@@ -1367,6 +1401,45 @@ PostalAddress(street='3700 Wailea Alanui Dr', sub_locality=None, city='Kihei', s
>>> photo.place.address.postal_code >>> photo.place.address.postal_code
'96753' '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:
```python
overall: float
curation: float
promotion: float
highlight_visibility: float
behavioral: float
failure: float
harmonious_color: float
immersiveness: float
interaction: float
interesting_subject: float
intrusive_object_presence: float
lively_color: float
low_light: float
noise: float
pleasant_camera_tilt: float
pleasant_composition: float
pleasant_lighting: float
pleasant_pattern: float
pleasant_perspective: float
pleasant_post_processing: float
pleasant_reflection: float
pleasant_symmetry: float
sharply_focused_subject: float
tastefully_blurred: float
well_chosen_subject: float
well_framed_subject: float
well_timed_shot: float
```
Example: find your "best" photo of food
```python
>>> import osxphotos
>>> photos = osxphotos.PhotosDB().photos()
>>> best_food_photo = sorted([p for p in photos if "food" in p.labels_normalized], key=lambda p: p.score.overall, reverse=True)[0]
```
### Template Substitutions ### Template Substitutions
@@ -1385,7 +1458,12 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|{created.month}|Month name in user's locale of the file creation time| |{created.month}|Month name in user's locale of the file creation time|
|{created.mon}|Month abbreviation in the user's locale of the file creation time| |{created.mon}|Month abbreviation in the user's locale of the file creation time|
|{created.dd}|2-digit day of the month (zero padded) of file creation time| |{created.dd}|2-digit day of the month (zero padded) of file creation time|
|{created.dow}|Day of week in user's locale of the file creation time|
|{created.doy}|3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)| |{created.doy}|3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)|
|{created.hour}|2-digit hour of the file creation time|
|{created.min}|2-digit minute of the file creation time|
|{created.sec}|2-digit second of the file creation time|
|{created.strftime}|Apply strftime template to file creation date/time. Should be used in form {created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates.|
|{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'| |{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'|
|{modified.year}|4-digit year of file modification time| |{modified.year}|4-digit year of file modification time|
|{modified.yy}|2-digit year of file modification time| |{modified.yy}|2-digit year of file modification time|
@@ -1394,6 +1472,9 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|{modified.mon}|Month abbreviation in the user's locale of the file modification time| |{modified.mon}|Month abbreviation in the user's locale of the file modification time|
|{modified.dd}|2-digit day of the month (zero padded) of the file modification time| |{modified.dd}|2-digit day of the month (zero padded) of the file modification time|
|{modified.doy}|3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)| |{modified.doy}|3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)|
|{modified.hour}|2-digit hour of the file modification time|
|{modified.min}|2-digit minute of the file modification time|
|{modified.sec}|2-digit second of the file modification time|
|{place.name}|Place name from the photo's reverse geolocation data, as displayed in Photos| |{place.name}|Place name from the photo's reverse geolocation data, as displayed in Photos|
|{place.country_code}|The ISO country code from the photo's reverse geolocation data| |{place.country_code}|The ISO country code from the photo's reverse geolocation data|
|{place.name.country}|Country name from the photo's reverse geolocation data| |{place.name.country}|Country name from the photo's reverse geolocation data|
@@ -1414,7 +1495,6 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|{label}|Image categorization label associated with a photo (Photos 5 only)| |{label}|Image categorization label associated with a photo (Photos 5 only)|
|{label_normalized}|All lower case version of 'label' (Photos 5 only)| |{label_normalized}|All lower case version of 'label' (Photos 5 only)|
### Utility Functions ### Utility Functions
The following functions are located in osxphotos.utils The following functions are located in osxphotos.utils

View File

@@ -20,11 +20,7 @@ from osxphotos.__main__ import get_photos_db, _list_libraries
def main(): def main():
db = None db = None
if len(sys.argv) > 1: db = sys.argv[1] if len(sys.argv) > 1 else get_photos_db()
db = sys.argv[1]
else:
db = get_photos_db()
if db: if db:
print("loading database") print("loading database")
tic = time.perf_counter() tic = time.perf_counter()

File diff suppressed because it is too large Load Diff

View File

@@ -445,10 +445,7 @@ class ExportDB(ExportDB_ABC):
dt = datetime.datetime.utcnow().isoformat() dt = datetime.datetime.utcnow().isoformat()
python_path = sys.executable python_path = sys.executable
cmd = sys.argv[0] cmd = sys.argv[0]
if len(sys.argv) > 1: args = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else ""
args = " ".join(sys.argv[1:])
else:
args = ""
cwd = os.getcwd() cwd = os.getcwd()
conn = self._conn conn = self._conn
try: try:

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.29.8" __version__ = "0.29.19"

View File

@@ -62,10 +62,7 @@ class AlbumInfo:
try: try:
return self._folder_names return self._folder_names
except AttributeError: except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION: self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
else:
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
return self._folder_names return self._folder_names
@property @property

View File

@@ -12,53 +12,59 @@ class DateTimeFormatter:
@property @property
def date(self): def date(self):
""" ISO date in form 2020-03-22 """ """ ISO date in form 2020-03-22 """
date = self.dt.date().isoformat() return self.dt.date().isoformat()
return date
@property @property
def year(self): def year(self):
""" 4 digit year """ """ 4 digit year """
year = f"{self.dt.year}" return f"{self.dt.year}"
return year
@property @property
def yy(self): def yy(self):
""" 2 digit year """ """ 2 digit year """
yy = f"{self.dt.strftime('%y')}" return f"{self.dt.strftime('%y')}"
return yy
@property @property
def mm(self): def mm(self):
""" 2 digit month """ """ 2 digit month """
mm = f"{self.dt.strftime('%m')}" return f"{self.dt.strftime('%m')}"
return mm
@property @property
def month(self): def month(self):
""" Month as locale's full name """ """ Month as locale's full name """
month = f"{self.dt.strftime('%B')}" return f"{self.dt.strftime('%B')}"
return month
@property @property
def mon(self): def mon(self):
""" Month as locale's abbreviated name """ """ Month as locale's abbreviated name """
mon = f"{self.dt.strftime('%b')}" return f"{self.dt.strftime('%b')}"
return mon
@property @property
def dd(self): def dd(self):
""" 2-digit day of the month """ """ 2-digit day of the month """
dd = f"{self.dt.strftime('%d')}" return f"{self.dt.strftime('%d')}"
return dd
@property @property
def dow(self): def dow(self):
""" Day of week as locale's name """ """ Day of week as locale's name """
dow = f"{self.dt.strftime('%A')}" return f"{self.dt.strftime('%A')}"
return dow
@property @property
def doy(self): def doy(self):
""" Julian day of year starting from 001 """ """ Julian day of year starting from 001 """
doy = f"{self.dt.strftime('%j')}" return f"{self.dt.strftime('%j')}"
return doy
@property
def hour(self):
""" 2-digit hour """
return f"{self.dt.strftime('%H')}"
@property
def min(self):
""" 2-digit minute """
return f"{self.dt.strftime('%M')}"
@property
def sec(self):
""" 2-digit second """
return f"{self.dt.strftime('%S')}"

View File

@@ -59,11 +59,7 @@ class _ExifToolProc:
) )
return return
if exiftool: self._exiftool = exiftool if exiftool else get_exiftool_path()
self._exiftool = exiftool
else:
self._exiftool = get_exiftool_path()
self._process_running = False self._process_running = False
self._start_proc() self._start_proc()
@@ -156,8 +152,7 @@ class ExifTool:
if value is None: if value is None:
value = "" value = ""
command = [] command = [f"-{tag}={value}"]
command.append(f"-{tag}={value}")
if self.overwrite: if self.overwrite:
command.append("-overwrite_original") command.append("-overwrite_original")
self.run_commands(*command) self.run_commands(*command)
@@ -193,7 +188,7 @@ class ExifTool:
no_file: (bool) do not pass the filename to exiftool (default=False) no_file: (bool) do not pass the filename to exiftool (default=False)
by default, all commands will be run against self.file by default, all commands will be run against self.file
use no_file=True to run a command without passing the filename """ use no_file=True to run a command without passing the filename """
if not hasattr(self, "_process") or not self._process: if not (hasattr(self, "_process") and self._process):
raise ValueError("exiftool process is not running") raise ValueError("exiftool process is not running")
if not commands: if not commands:
@@ -245,8 +240,7 @@ class ExifTool:
def json(self): def json(self):
""" returns JSON string containing all EXIF tags and values from exiftool """ """ returns JSON string containing all EXIF tags and values from exiftool """
json_str = self.run_commands("-json") return self.run_commands("-json")
return json_str
def _read_exif(self): def _read_exif(self):
""" read exif data from file """ """ read exif data from file """
@@ -254,5 +248,4 @@ class ExifTool:
self.data = {k: v for k, v in data.items()} self.data = {k: v for k, v in data.items()}
def __str__(self): def __str__(self):
str_ = f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}" return f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
return str_

View File

@@ -11,6 +11,7 @@ from abc import ABC, abstractmethod
class FileUtilABC(ABC): class FileUtilABC(ABC):
""" Abstract base class for FileUtil """ """ Abstract base class for FileUtil """
@classmethod @classmethod
@abstractmethod @abstractmethod
def hardlink(cls, src, dest): def hardlink(cls, src, dest):
@@ -39,6 +40,7 @@ class FileUtilABC(ABC):
class FileUtilMacOS(FileUtilABC): class FileUtilMacOS(FileUtilABC):
""" Various file utilities """ """ Various file utilities """
@classmethod @classmethod
def hardlink(cls, src, dest): def hardlink(cls, src, dest):
""" Hardlinks a file from src path to dest path """ Hardlinks a file from src path to dest path
@@ -119,9 +121,7 @@ class FileUtilMacOS(FileUtilABC):
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG: if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
return False return False
if s1 == s2: return s1 == s2
return True
return False
@classmethod @classmethod
def file_sig(cls, f1): def file_sig(cls, f1):
@@ -135,14 +135,17 @@ class FileUtilMacOS(FileUtilABC):
class FileUtil(FileUtilMacOS): class FileUtil(FileUtilMacOS):
""" Various file utilities """ """ Various file utilities """
pass pass
class FileUtilNoOp(FileUtil): class FileUtilNoOp(FileUtil):
""" No-Op implementation of FileUtil for testing / dry-run mode """ No-Op implementation of FileUtil for testing / dry-run mode
all methods with exception of cmp_sig and file_cmp are no-op all methods with exception of cmp_sig and file_cmp are no-op
cmp_sig functions as FileUtil.cmp_sig does cmp_sig functions as FileUtil.cmp_sig does
file_cmp returns mock data file_cmp returns mock data
""" """
@staticmethod @staticmethod
def noop(*args): def noop(*args):
pass pass
@@ -155,7 +158,7 @@ class FileUtilNoOp(FileUtil):
cls.verbose = verbose cls.verbose = verbose
else: else:
raise ValueError(f"verbose {verbose} not callable") raise ValueError(f"verbose {verbose} not callable")
return super(FileUtilNoOp, cls).__new__(cls) return super(FileUtilNoOp, cls).__new__(cls)
@classmethod @classmethod
def hardlink(cls, src, dest): def hardlink(cls, src, dest):
@@ -164,7 +167,7 @@ class FileUtilNoOp(FileUtil):
@classmethod @classmethod
def copy(cls, src, dest, norsrc=False): def copy(cls, src, dest, norsrc=False):
cls.verbose(f"copy: {src} {dest}") cls.verbose(f"copy: {src} {dest}")
@classmethod @classmethod
def unlink(cls, dest): def unlink(cls, dest):
cls.verbose(f"unlink: {dest}") cls.verbose(f"unlink: {dest}")

View File

@@ -6,4 +6,5 @@ PhotosDB.photos() returns a list of PhotoInfo objects
from ._photoinfo_exifinfo import ExifInfo from ._photoinfo_exifinfo import ExifInfo
from ._photoinfo_export import ExportResults from ._photoinfo_export import ExportResults
from ._photoinfo_scoreinfo import ScoreInfo
from .photoinfo import PhotoInfo from .photoinfo import PhotoInfo

View File

@@ -156,6 +156,46 @@ def _export_photo_uuid_applescript(
return None return None
# _check_export_suffix is not a class method, don't import this into PhotoInfo
def _check_export_suffix(src, dest, edited):
"""Helper function for exporting photos to check file extensions of destination path.
Checks that dst file extension is appropriate for the src.
If edited=True, will use src file extension of ".jpeg" if None provided for src.
Args:
src: path to source file or None.
dest: path to destination file.
edited: set to True if exporting an edited photo.
Returns:
True if src and dest extensions are OK, else False.
Raises:
ValueError if edited is False and src is None
"""
# check extension of destination
if src is not None:
# use suffix from edited file
actual_suffix = pathlib.Path(src).suffix
elif edited:
# use .jpeg as that's probably correct
actual_suffix = ".jpeg"
else:
raise ValueError("src must not be None if edited=False")
# Photo's often converts .JPG to .jpeg or .tif to .tiff on import
dest_ext = dest.suffix.lower()
actual_ext = actual_suffix.lower()
suffixes = sorted([dest_ext, actual_ext])
return (
dest_ext == actual_ext
or suffixes == [".jpeg", ".jpg"]
or suffixes == [".tif", ".tiff"]
)
def export( def export(
self, self,
dest, dest,
@@ -182,8 +222,11 @@ def export(
**NOTE**: if provided, user must ensure file extension (suffix) is correct. **NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg. For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is, If you provide an extension different than what the actual file is,
export will print a warning but will happily export the photo using the export will print a warning but will export the photo using the
incorrect file extension. e.g. to get the extension of the edited photo, incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version) (or raise exception if no edited version)
@@ -260,13 +303,16 @@ def export2(
dry_run=False, dry_run=False,
): ):
""" export photo, like export but with update and dry_run options """ export photo, like export but with update and dry_run options
dest: must be valid destination path (or exception raised) dest: must be valid destination path or exception raised
filename: (optional): name of exported picture; if not provided, will use current filename filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct. **NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg. For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is, If you provide an extension different than what the actual file is,
export will print a warning but will happily export the photo using the export will print a warning but will export the photo using the
incorrect file extension. e.g. to get the extension of the edited photo, incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version) (or raise exception if no edited version)
@@ -370,27 +416,6 @@ def export2(
fname = pathlib.Path(fname) fname = pathlib.Path(fname)
dest = dest / fname dest = dest / fname
# check extension of destination
if edited and self.path_edited is not None:
# use suffix from edited file
actual_suffix = pathlib.Path(self.path_edited).suffix
elif edited:
# use .jpeg as that's probably correct
# if edited and path_edited is None, will raise FileNotFoundError below
# unless use_photos_export is True
actual_suffix = ".jpeg"
else:
# use suffix from the non-edited file
actual_suffix = pathlib.Path(self.filename).suffix
# warn if suffixes don't match but ignore .JPG / .jpeg as
# Photo's often converts .JPG to .jpeg
suffixes = sorted([x.lower() for x in [dest.suffix, actual_suffix]])
if dest.suffix.lower() != actual_suffix.lower() and suffixes != [".jpeg", ".jpg"]:
logging.warning(
f"Invalid destination suffix: {dest.suffix}, should be {actual_suffix}"
)
# check to see if file exists and if so, add (1), (2), etc until we find one that works # check to see if file exists and if so, add (1), (2), etc until we find one that works
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars # Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
# e.g. exporting sidecar for file1.png and file1.jpeg # e.g. exporting sidecar for file1.png and file1.jpeg
@@ -438,6 +463,13 @@ def export2(
if not os.path.isfile(src): if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist") raise FileNotFoundError(f"{src} does not appear to exist")
if not _check_export_suffix(src, dest, edited):
logging.warning(
f"Invalid destination suffix: {dest.suffix} for {self.path}, "
+ f"edited={edited}, path_edited={self.path_edited}, "
+ f"original_filename={self.original_filename}, filename={self.filename}"
)
logging.debug( logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, increment={increment}, dest exists: {dest.exists()}" f"exporting {src} to {dest}, overwrite={overwrite}, increment={increment}, dest exists: {dest.exists()}"
) )
@@ -757,6 +789,8 @@ def _export_photo(
action depending on update, overwrite action depending on update, overwrite
Assumes destination is the right destination (e.g. UUID matches) Assumes destination is the right destination (e.g. UUID matches)
sets UUID and JSON info foo exported file using set_uuid_for_file, set_inf_for_uuido sets UUID and JSON info foo exported file using set_uuid_for_file, set_inf_for_uuido
Args:
src: src path (string) src: src path (string)
dest: dest path (pathlib.Path) dest: dest path (pathlib.Path)
update: bool update: bool
@@ -766,7 +800,9 @@ def _export_photo(
export_as_hardlink: bool export_as_hardlink: bool
exiftool: bool exiftool: bool
fileutil: FileUtil class that conforms to fileutil.FileUtilABC fileutil: FileUtil class that conforms to fileutil.FileUtilABC
Returns: ExportResults
Returns:
ExportResults
""" """
exported_files = [] exported_files = []
@@ -1153,7 +1189,7 @@ def _xmp_sidecar(
def _write_sidecar(self, filename, sidecar_str): def _write_sidecar(self, filename, sidecar_str):
""" write sidecar_str to filename """ write sidecar_str to filename
used for exporting sidecar info """ used for exporting sidecar info """
if not filename and not sidecar_str: if not (filename or sidecar_str):
raise ( raise (
ValueError( ValueError(
f"filename {filename} and sidecar_str {sidecar_str} must not be None" f"filename {filename} and sidecar_str {sidecar_str} must not be None"

View File

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

View File

@@ -29,6 +29,7 @@ from ..placeinfo import PlaceInfo4, PlaceInfo5
from ..phototemplate import PhotoTemplate from ..phototemplate import PhotoTemplate
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
class PhotoInfo: class PhotoInfo:
""" """
Info about a specific photo, contains all the details about the photo Info about a specific photo, contains all the details about the photo
@@ -54,6 +55,7 @@ class PhotoInfo:
_xmp_sidecar, _xmp_sidecar,
ExportResults, ExportResults,
) )
from ._photoinfo_scoreinfo import score, ScoreInfo
def __init__(self, db=None, uuid=None, info=None): def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid self._uuid = uuid
@@ -636,6 +638,9 @@ class PhotoInfo:
none_str: a str to use if template field renders to None, default is "_". none_str: a str to use if template field renders to None, default is "_".
path_sep: a single character str to use as path separator when joining path_sep: a single character str to use as path separator when joining
fields like folder_album; if not provided, defaults to os.path.sep fields like folder_album; if not provided, defaults to os.path.sep
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
""" """
template = PhotoTemplate(self) template = PhotoTemplate(self)
return template.render(template_str, none_str, path_sep) return template.render(template_str, none_str, path_sep)
@@ -660,6 +665,8 @@ class PhotoInfo:
date_modified_iso = ( date_modified_iso = (
self.date_modified.isoformat() if self.date_modified else None self.date_modified.isoformat() if self.date_modified else None
) )
exif = str(self.exif_info) if self.exif_info else None
score = str(self.score) if self.score else None
info = { info = {
"uuid": self.uuid, "uuid": self.uuid,
@@ -700,6 +707,9 @@ class PhotoInfo:
"has_raw": self.has_raw, "has_raw": self.has_raw,
"uti_raw": self.uti_raw, "uti_raw": self.uti_raw,
"path_raw": self.path_raw, "path_raw": self.path_raw,
"place": self.place,
"exif": exif,
"score": score,
} }
return yaml.dump(info, sort_keys=False) return yaml.dump(info, sort_keys=False)
@@ -712,6 +722,7 @@ class PhotoInfo:
folders = {album.title: album.folder_names for album in self.album_info} folders = {album.title: album.folder_names for album in self.album_info}
exif = dataclasses.asdict(self.exif_info) if self.exif_info else {} exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
place = self.place.as_dict() if self.place else {} place = self.place.as_dict() if self.place else {}
score = dataclasses.asdict(self.score) if self.score else {}
pic = { pic = {
"uuid": self.uuid, "uuid": self.uuid,
@@ -757,6 +768,7 @@ class PhotoInfo:
"path_raw": self.path_raw, "path_raw": self.path_raw,
"place": place, "place": place,
"exif": exif, "exif": exif,
"score": score,
} }
return json.dumps(pic) return json.dumps(pic)

View File

@@ -0,0 +1,145 @@
""" Methods for PhotosDB to add Photos 5 photo score info
ref: https://simonwillison.net/2020/May/21/dogsheep-photos/
"""
import logging
from .._constants import _PHOTOS_4_VERSION
from ..utils import _open_sql_file
"""
This module should be imported in the class defintion of PhotosDB in photosdb.py
Do not import this module directly
This module adds the following method to PhotosDB:
_process_scoreinfo: process photo score info
The following data structures are added to PhotosDB
self._db_scoreinfo_uuid
These methods only work on Photos 5 databases. Will print warning on earlier library versions.
"""
def _process_scoreinfo(self):
""" Process computed photo scores
Note: Only works on Photos version == 5.0
"""
# _db_scoreinfo_uuid is dict in form {uuid: {score values}}
self._db_scoreinfo_uuid = {}
if self._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError(
f"search info not implemented for this database version"
)
else:
_process_scoreinfo_5(self)
def _process_scoreinfo_5(photosdb):
""" Process computed photo scores for Photos 5 databases
Args:
photosdb: an OSXPhotosDB instance
"""
db = photosdb._tmp_db
(conn, cursor) = _open_sql_file(db)
result = cursor.execute(
"""
SELECT
ZGENERICASSET.ZUUID,
ZGENERICASSET.ZOVERALLAESTHETICSCORE,
ZGENERICASSET.ZCURATIONSCORE,
ZGENERICASSET.ZPROMOTIONSCORE,
ZGENERICASSET.ZHIGHLIGHTVISIBILITYSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZBEHAVIORALSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZFAILURESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZHARMONIOUSCOLORSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZIMMERSIVENESSSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZINTERACTIONSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZINTERESTINGSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZINTRUSIVEOBJECTPRESENCESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZLIVELYCOLORSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZLOWLIGHT,
ZCOMPUTEDASSETATTRIBUTES.ZNOISESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCAMERATILTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCOMPOSITIONSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTLIGHTINGSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPATTERNSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPERSPECTIVESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPOSTPROCESSINGSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTREFLECTIONSSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTSYMMETRYSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZSHARPLYFOCUSEDSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZTASTEFULLYBLURREDSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLCHOSENSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLFRAMEDSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLTIMEDSHOTSCORE
FROM ZGENERICASSET
JOIN ZCOMPUTEDASSETATTRIBUTES ON ZCOMPUTEDASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
"""
)
# 0 ZGENERICASSET.ZUUID,
# 1 ZGENERICASSET.ZOVERALLAESTHETICSCORE,
# 2 ZGENERICASSET.ZCURATIONSCORE,
# 3 ZGENERICASSET.ZPROMOTIONSCORE,
# 4 ZGENERICASSET.ZHIGHLIGHTVISIBILITYSCORE,
# 5 ZCOMPUTEDASSETATTRIBUTES.ZBEHAVIORALSCORE,
# 6 ZCOMPUTEDASSETATTRIBUTES.ZFAILURESCORE,
# 7 ZCOMPUTEDASSETATTRIBUTES.ZHARMONIOUSCOLORSCORE,
# 8 ZCOMPUTEDASSETATTRIBUTES.ZIMMERSIVENESSSCORE,
# 9 ZCOMPUTEDASSETATTRIBUTES.ZINTERACTIONSCORE,
# 10 ZCOMPUTEDASSETATTRIBUTES.ZINTERESTINGSUBJECTSCORE,
# 11 ZCOMPUTEDASSETATTRIBUTES.ZINTRUSIVEOBJECTPRESENCESCORE,
# 12 ZCOMPUTEDASSETATTRIBUTES.ZLIVELYCOLORSCORE,
# 13 ZCOMPUTEDASSETATTRIBUTES.ZLOWLIGHT,
# 14 ZCOMPUTEDASSETATTRIBUTES.ZNOISESCORE,
# 15 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCAMERATILTSCORE,
# 16 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCOMPOSITIONSCORE,
# 17 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTLIGHTINGSCORE,
# 18 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPATTERNSCORE,
# 19 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPERSPECTIVESCORE,
# 20 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPOSTPROCESSINGSCORE,
# 21 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTREFLECTIONSSCORE,
# 22 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTSYMMETRYSCORE,
# 23 ZCOMPUTEDASSETATTRIBUTES.ZSHARPLYFOCUSEDSUBJECTSCORE,
# 24 ZCOMPUTEDASSETATTRIBUTES.ZTASTEFULLYBLURREDSCORE,
# 25 ZCOMPUTEDASSETATTRIBUTES.ZWELLCHOSENSUBJECTSCORE,
# 26 ZCOMPUTEDASSETATTRIBUTES.ZWELLFRAMEDSUBJECTSCORE,
# 27 ZCOMPUTEDASSETATTRIBUTES.ZWELLTIMEDSHOTSCORE
for row in result:
uuid = row[0]
scores = {"uuid": uuid}
scores["overall_aesthetic"] = row[1]
scores["curation"] = row[2]
scores["promotion"] = row[3]
scores["highlight_visibility"] = row[4]
scores["behavioral"] = row[5]
scores["failure"] = row[6]
scores["harmonious_color"] = row[7]
scores["immersiveness"] = row[8]
scores["interaction"] = row[9]
scores["interesting_subject"] = row[10]
scores["intrusive_object_presence"] = row[11]
scores["lively_color"] = row[12]
scores["low_light"] = row[13]
scores["noise"] = row[14]
scores["pleasant_camera_tilt"] = row[15]
scores["pleasant_composition"] = row[16]
scores["pleasant_lighting"] = row[17]
scores["pleasant_pattern"] = row[18]
scores["pleasant_perspective"] = row[19]
scores["pleasant_post_processing"] = row[20]
scores["pleasant_reflection"] = row[21]
scores["pleasant_symmetry"] = row[22]
scores["sharply_focused_subject"] = row[23]
scores["tastefully_blurred"] = row[24]
scores["well_chosen_subject"] = row[25]
scores["well_framed_subject"] = row[26]
scores["well_timed_shot"] = row[27]
photosdb._db_scoreinfo_uuid[uuid] = scores

View File

@@ -102,7 +102,7 @@ def _process_searchinfo(self):
# 8: groups.lookup_identifier # 8: groups.lookup_identifier
for row in c: for row in c:
uuid = ints_to_uuid(row[1],row[2]) uuid = ints_to_uuid(row[1], row[2])
# strings have null character appended, so strip it # strings have null character appended, so strip it
record = {} record = {}
record["uuid"] = uuid record["uuid"] = uuid
@@ -123,13 +123,9 @@ def _process_searchinfo(self):
category = record["category"] category = record["category"]
try: try:
_db_searchinfo_categories[category].append( _db_searchinfo_categories[category].append(record["normalized_string"])
record["normalized_string"]
)
except KeyError: except KeyError:
_db_searchinfo_categories[category] = [ _db_searchinfo_categories[category] = [record["normalized_string"]]
record["normalized_string"]
]
if category == SEARCH_CATEGORY_LABEL: if category == SEARCH_CATEGORY_LABEL:
label = record["content_string"] label = record["content_string"]
@@ -198,6 +194,7 @@ def labels_normalized_as_dict(self):
# The following method is not imported into PhotosDB # The following method is not imported into PhotosDB
@lru_cache(maxsize=128) @lru_cache(maxsize=128)
def ints_to_uuid(uuid_0, uuid_1): def ints_to_uuid(uuid_0, uuid_1):
""" convert two signed ints into a UUID strings """ convert two signed ints into a UUID strings

View File

@@ -64,6 +64,7 @@ class PhotosDB:
labels_as_dict, labels_as_dict,
labels_normalized_as_dict, labels_normalized_as_dict,
) )
from ._photosdb_process_scoreinfo import _process_scoreinfo
def __init__(self, *dbfile_, dbfile=None): def __init__(self, *dbfile_, dbfile=None):
""" create a new PhotosDB object """ create a new PhotosDB object
@@ -294,18 +295,17 @@ class PhotosDB:
@property @property
def keywords_as_dict(self): def keywords_as_dict(self):
""" return keywords as dict of keyword, count in reverse sorted order (descending) """ """ return keywords as dict of keyword, count in reverse sorted order (descending) """
keywords = {} keywords = {
for k in self._dbkeywords_keyword.keys(): k: len(self._dbkeywords_keyword[k]) for k in self._dbkeywords_keyword.keys()
keywords[k] = len(self._dbkeywords_keyword[k]) }
keywords = dict(sorted(keywords.items(), key=lambda kv: kv[1], reverse=True)) keywords = dict(sorted(keywords.items(), key=lambda kv: kv[1], reverse=True))
return keywords return keywords
@property @property
def persons_as_dict(self): def persons_as_dict(self):
""" return persons as dict of person, count in reverse sorted order (descending) """ """ return persons as dict of person, count in reverse sorted order (descending) """
persons = {} persons = {k: len(self._dbfaces_person[k]) for k in self._dbfaces_person.keys()}
for k in self._dbfaces_person.keys():
persons[k] = len(self._dbfaces_person[k])
persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True)) persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True))
return persons return persons
@@ -413,13 +413,12 @@ class PhotosDB:
def album_info(self): def album_info(self):
""" return list of AlbumInfo objects for each album in the photos database """ """ return list of AlbumInfo objects for each album in the photos database """
albums = [ return [
AlbumInfo(db=self, uuid=album) AlbumInfo(db=self, uuid=album)
for album in self._dbalbums_album.keys() for album in self._dbalbums_album.keys()
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is None if self._dbalbum_details[album]["cloudownerhashedpersonid"] is None
and not self._dbalbum_details[album]["intrash"] and not self._dbalbum_details[album]["intrash"]
] ]
return albums
@property @property
def album_info_shared(self): def album_info_shared(self):
@@ -433,13 +432,12 @@ class PhotosDB:
) )
return [] return []
albums_shared = [ return [
AlbumInfo(db=self, uuid=album) AlbumInfo(db=self, uuid=album)
for album in self._dbalbums_album.keys() for album in self._dbalbums_album.keys()
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is not None if self._dbalbum_details[album]["cloudownerhashedpersonid"] is not None
and not self._dbalbum_details[album]["intrash"] and not self._dbalbum_details[album]["intrash"]
] ]
return albums_shared
@property @property
def albums(self): def albums(self):
@@ -1724,8 +1722,6 @@ class PhotosDB:
# determine if a photo is missing in Photos 5 # determine if a photo is missing in Photos 5
# Get info on remote/local availability for photos in shared albums # Get info on remote/local availability for photos in shared albums
# Shared photos have a null fingerprint (and some other photos do too)
# TODO: There may be a bug here, perhaps ZDATASTORESUBTYPE should be 1 --> it's the longest ZDATALENGTH (is this the original)
c.execute( c.execute(
""" SELECT """ SELECT
ZGENERICASSET.ZUUID, ZGENERICASSET.ZUUID,
@@ -1734,10 +1730,7 @@ class PhotosDB:
FROM ZGENERICASSET FROM ZGENERICASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
WHERE ZDATASTORESUBTYPE = 0 OR ZDATASTORESUBTYPE = 3 """ WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
# WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
# WHERE ZDATASTORESUBTYPE = 0 OR ZDATASTORESUBTYPE = 3 """
# WHERE ZINTERNALRESOURCE.ZFINGERPRINT IS NULL AND ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 3 """
) )
for row in c: for row in c:
@@ -1870,6 +1863,9 @@ class PhotosDB:
# process exif info # process exif info
self._process_exifinfo() self._process_exifinfo()
# process computed scores
self._process_scoreinfo()
# done processing, dump debug data if requested # done processing, dump debug data if requested
if _debug(): if _debug():
logging.debug("Faces (_dbfaces_uuid):") logging.debug("Faces (_dbfaces_uuid):")
@@ -2136,7 +2132,11 @@ class PhotosDB:
if album in self._dbalbum_titles: if album in self._dbalbum_titles:
title_set = set() title_set = set()
for album_id in self._dbalbum_titles[album]: for album_id in self._dbalbum_titles[album]:
title_set.update(self._dbalbums_album[album_id]) try:
title_set.update(self._dbalbums_album[album_id])
except KeyError:
# an empty album will be in _dbalbum_titles but not _dbalbums_album
pass
album_set.update(title_set) album_set.update(title_set)
else: else:
logging.debug(f"Could not find album '{album}' in database") logging.debug(f"Could not find album '{album}' in database")

View File

@@ -1,5 +1,6 @@
""" Custom template system for osxphotos (implemented in PhotoInfo.render_template) """ """ Custom template system for osxphotos (implemented in PhotoInfo.render_template) """
# Rolled my own template system because: # Rolled my own template system because:
# 1. Needed to handle multiple values (e.g. album, keyword) # 1. Needed to handle multiple values (e.g. album, keyword)
# 2. Needed to handle default values if template not found # 2. Needed to handle default values if template not found
@@ -8,7 +9,6 @@
# 4. Couldn't figure out how to do #1 and #2 with str.format() # 4. Couldn't figure out how to do #1 and #2 with str.format()
# #
# This code isn't elegant but it seems to work well. PRs gladly accepted. # This code isn't elegant but it seems to work well. PRs gladly accepted.
import locale import locale
import os import os
import re import re
@@ -35,6 +35,14 @@ TEMPLATE_SUBSTITUTIONS = {
"{created.dd}": "2-digit day of the month (zero padded) of file creation time", "{created.dd}": "2-digit day of the month (zero padded) of file creation time",
"{created.dow}": "Day of week in user's locale of the file creation time", "{created.dow}": "Day of week in user's locale of the file creation time",
"{created.doy}": "3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)", "{created.doy}": "3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)",
"{created.hour}": "2-digit hour of the file creation time",
"{created.min}": "2-digit minute of the file creation time",
"{created.sec}": "2-digit second of the file creation time",
"{created.strftime}": "Apply strftime template to file creation date/time. Should be used in form "
+ "{created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
+ "{created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
+ "If used with no template will return null value. "
+ "See https://strftime.org/ for help on strftime templates.",
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'", "{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'",
"{modified.year}": "4-digit year of file modification time", "{modified.year}": "4-digit year of file modification time",
"{modified.yy}": "2-digit year of file modification time", "{modified.yy}": "2-digit year of file modification time",
@@ -43,6 +51,14 @@ TEMPLATE_SUBSTITUTIONS = {
"{modified.mon}": "Month abbreviation in the user's locale of the file modification time", "{modified.mon}": "Month abbreviation in the user's locale of the file modification time",
"{modified.dd}": "2-digit day of the month (zero padded) of the file modification time", "{modified.dd}": "2-digit day of the month (zero padded) of the file modification time",
"{modified.doy}": "3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)", "{modified.doy}": "3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)",
"{modified.hour}": "2-digit hour of the file modification time",
"{modified.min}": "2-digit minute of the file modification time",
"{modified.sec}": "2-digit second of the file modification time",
# "{modified.strftime}": "Apply strftime template to file modification date/time. Should be used in form "
# + "{modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
# + "{modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
# + "If used with no template will return null value. "
# + "See https://strftime.org/ for help on strftime templates.",
"{place.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos", "{place.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos",
"{place.country_code}": "The ISO country code from the photo's reverse geolocation data", "{place.country_code}": "The ISO country code from the photo's reverse geolocation data",
"{place.name.country}": "Country name from the photo's reverse geolocation data", "{place.name.country}": "Country name from the photo's reverse geolocation data",
@@ -71,7 +87,7 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
# Just the multi-valued substitution names without the braces # Just the multi-valued substitution names without the braces
MULTI_VALUE_SUBSTITUTIONS = [ MULTI_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "") field.replace("{", "").replace("}", "")
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.keys() for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
] ]
@@ -87,10 +103,17 @@ class PhotoTemplate:
self.photo = photo self.photo = photo
def render(self, template, none_str="_", path_sep=None): def render(self, template, none_str="_", path_sep=None):
""" render a filename or directory template """ Render a filename or directory template
Args:
template: str template template: str template
none_str: str to use default for None values, default is '_' none_str: str to use default for None values, default is '_'
path_sep: optional character to use as path separator, default is os.path.sep """ path_sep: optional character to use as path separator, default is os.path.sep
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
if path_sep is None: if path_sep is None:
path_sep = os.path.sep path_sep = os.path.sep
elif path_sep is not None and len(path_sep) != 1: elif path_sep is not None and len(path_sep) != 1:
@@ -107,7 +130,7 @@ class PhotoTemplate:
# regex to find {template_field,optional_default} in strings # regex to find {template_field,optional_default} in strings
# for explanation of regex see https://regex101.com/r/4JJg42/1 # for explanation of regex see https://regex101.com/r/4JJg42/1
# pylint: disable=anomalous-backslash-in-string # pylint: disable=anomalous-backslash-in-string
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)(?=\}(?!\}))\}" regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-\%. ]+))?)(?=\}(?!\}))\}"
if type(template) is not str: if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}") raise TypeError(f"template must be type str, not {type(template)}")
@@ -122,7 +145,7 @@ class PhotoTemplate:
groups = len(matchobj.groups()) groups = len(matchobj.groups())
if groups == 4: if groups == 4:
try: try:
val = get_func(matchobj.group(1)) val = get_func(matchobj.group(1), matchobj.group(3))
except ValueError: except ValueError:
return matchobj.group(0) return matchobj.group(0)
@@ -172,7 +195,7 @@ class PhotoTemplate:
rendered_strings = set([rendered]) rendered_strings = set([rendered])
for field in MULTI_VALUE_SUBSTITUTIONS: for field in MULTI_VALUE_SUBSTITUTIONS:
# Build a regex that matches only the field being processed # Build a regex that matches only the field being processed
re_str = r"(?<!\\)\{(" + field + r")(,(([\w\-. ]{0,})))?\}" re_str = r"(?<!\\)\{(" + field + r")(,(([\w\-\%. ]{0,})))?\}"
regex_multi = re.compile(re_str) regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, set() to avoid duplicates # holds each of the new rendered_strings, set() to avoid duplicates
@@ -183,10 +206,11 @@ class PhotoTemplate:
values = self.get_template_value_multi(field, path_sep) values = self.get_template_value_multi(field, path_sep)
for val in values: for val in values:
def lookup_template_value_multi(lookup_value): def lookup_template_value_multi(lookup_value, default):
""" Closure passed to make_subst_function get_func """ Closure passed to make_subst_function get_func
Capture val and field in the closure Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification """ Allows make_subst_function to be re-used w/o modification
default is not used but required so signature matches get_template_value """
if lookup_value == field: if lookup_value == field:
return val return val
else: else:
@@ -220,11 +244,12 @@ class PhotoTemplate:
return rendered_strings, unmatched return rendered_strings, unmatched
def get_template_value(self, field): def get_template_value(self, field, default):
"""lookup value for template field (single-value template substitutions) """lookup value for template field (single-value template substitutions)
Args: Args:
field: template field to find value for. field: template field to find value for.
default: the default value provided by the user
Returns: Returns:
The matching template value (which may be None). The matching template value (which may be None).
@@ -234,172 +259,228 @@ class PhotoTemplate:
""" """
# must be a valid keyword # must be a valid keyword
if field =="name": if field == "name":
return pathlib.Path(self.photo.filename).stem return pathlib.Path(self.photo.filename).stem
if field =="original_name": if field == "original_name":
return pathlib.Path(self.photo.original_filename).stem return pathlib.Path(self.photo.original_filename).stem
if field =="title": if field == "title":
return self.photo.title return self.photo.title
if field =="descr": if field == "descr":
return self.photo.description return self.photo.description
if field =="created.date": if field == "created.date":
return DateTimeFormatter(self.photo.date).date return DateTimeFormatter(self.photo.date).date
if field =="created.year": if field == "created.year":
return DateTimeFormatter(self.photo.date).year return DateTimeFormatter(self.photo.date).year
if field =="created.yy": if field == "created.yy":
return DateTimeFormatter(self.photo.date).yy return DateTimeFormatter(self.photo.date).yy
if field =="created.mm": if field == "created.mm":
return DateTimeFormatter(self.photo.date).mm return DateTimeFormatter(self.photo.date).mm
if field =="created.month": if field == "created.month":
return DateTimeFormatter(self.photo.date).month return DateTimeFormatter(self.photo.date).month
if field =="created.mon": if field == "created.mon":
return DateTimeFormatter(self.photo.date).mon return DateTimeFormatter(self.photo.date).mon
if field =="created.dd": if field == "created.dd":
return DateTimeFormatter(self.photo.date).dd return DateTimeFormatter(self.photo.date).dd
if field =="created.dow": if field == "created.dow":
return DateTimeFormatter(self.photo.date).dow return DateTimeFormatter(self.photo.date).dow
if field =="created.doy": if field == "created.doy":
return DateTimeFormatter(self.photo.date).doy return DateTimeFormatter(self.photo.date).doy
if field =="modified.date": if field == "created.hour":
return DateTimeFormatter(self.photo.date).hour
if field == "created.min":
return DateTimeFormatter(self.photo.date).min
if field == "created.sec":
return DateTimeFormatter(self.photo.date).sec
if field == "created.strftime":
if default:
try:
return self.photo.date.strftime(default)
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
return None
if field == "modified.date":
return ( return (
DateTimeFormatter(self.photo.date_modified).date DateTimeFormatter(self.photo.date_modified).date
if self.photo.date_modified if self.photo.date_modified
else None else None
) )
if field =="modified.year": if field == "modified.year":
return ( return (
DateTimeFormatter(self.photo.date_modified).year DateTimeFormatter(self.photo.date_modified).year
if self.photo.date_modified if self.photo.date_modified
else None else None
) )
if field =="modified.yy": if field == "modified.yy":
return ( return (
DateTimeFormatter(self.photo.date_modified).yy if self.photo.date_modified else None DateTimeFormatter(self.photo.date_modified).yy
if self.photo.date_modified
else None
) )
if field =="modified.mm": if field == "modified.mm":
return ( return (
DateTimeFormatter(self.photo.date_modified).mm if self.photo.date_modified else None DateTimeFormatter(self.photo.date_modified).mm
if self.photo.date_modified
else None
) )
if field =="modified.month": if field == "modified.month":
return ( return (
DateTimeFormatter(self.photo.date_modified).month DateTimeFormatter(self.photo.date_modified).month
if self.photo.date_modified if self.photo.date_modified
else None else None
) )
if field =="modified.mon": if field == "modified.mon":
return ( return (
DateTimeFormatter(self.photo.date_modified).mon DateTimeFormatter(self.photo.date_modified).mon
if self.photo.date_modified if self.photo.date_modified
else None else None
) )
if field =="modified.dd": if field == "modified.dd":
return ( return (
DateTimeFormatter(self.photo.date_modified).dd if self.photo.date_modified else None DateTimeFormatter(self.photo.date_modified).dd
if self.photo.date_modified
else None
) )
if field =="modified.doy": if field == "modified.doy":
return ( return (
DateTimeFormatter(self.photo.date_modified).doy DateTimeFormatter(self.photo.date_modified).doy
if self.photo.date_modified if self.photo.date_modified
else None else None
) )
if field =="place.name": if field == "modified.hour":
return (
DateTimeFormatter(self.photo.date_modified).hour
if self.photo.date_modified
else None
)
if field == "modified.min":
return (
DateTimeFormatter(self.photo.date_modified).min
if self.photo.date_modified
else None
)
if field == "modified.sec":
return (
DateTimeFormatter(self.photo.date_modified).sec
if self.photo.date_modified
else None
)
# TODO: disabling modified.strftime for now because now clean way to pass
# a default value if modified time is None
# if field == "modified.strftime":
# if default and self.photo.date_modified:
# try:
# return self.photo.date_modified.strftime(default)
# except:
# raise ValueError(f"Invalid strftime template: '{default}'")
# else:
# return None
if field == "place.name":
return self.photo.place.name if self.photo.place else None return self.photo.place.name if self.photo.place else None
if field =="place.country_code": if field == "place.country_code":
return self.photo.place.country_code if self.photo.place else None return self.photo.place.country_code if self.photo.place else None
if field =="place.name.country": if field == "place.name.country":
return ( return (
self.photo.place.names.country[0] self.photo.place.names.country[0]
if self.photo.place and self.photo.place.names.country if self.photo.place and self.photo.place.names.country
else None else None
) )
if field =="place.name.state_province": if field == "place.name.state_province":
return ( return (
self.photo.place.names.state_province[0] self.photo.place.names.state_province[0]
if self.photo.place and self.photo.place.names.state_province if self.photo.place and self.photo.place.names.state_province
else None else None
) )
if field =="place.name.city": if field == "place.name.city":
return ( return (
self.photo.place.names.city[0] self.photo.place.names.city[0]
if self.photo.place and self.photo.place.names.city if self.photo.place and self.photo.place.names.city
else None else None
) )
if field =="place.name.area_of_interest": if field == "place.name.area_of_interest":
return ( return (
self.photo.place.names.area_of_interest[0] self.photo.place.names.area_of_interest[0]
if self.photo.place and self.photo.place.names.area_of_interest if self.photo.place and self.photo.place.names.area_of_interest
else None else None
) )
if field =="place.address": if field == "place.address":
return ( return (
self.photo.place.address_str self.photo.place.address_str
if self.photo.place and self.photo.place.address_str if self.photo.place and self.photo.place.address_str
else None else None
) )
if field =="place.address.street": if field == "place.address.street":
return ( return (
self.photo.place.address.street self.photo.place.address.street
if self.photo.place and self.photo.place.address.street if self.photo.place and self.photo.place.address.street
else None else None
) )
if field =="place.address.city": if field == "place.address.city":
return ( return (
self.photo.place.address.city self.photo.place.address.city
if self.photo.place and self.photo.place.address.city if self.photo.place and self.photo.place.address.city
else None else None
) )
if field =="place.address.state_province": if field == "place.address.state_province":
return ( return (
self.photo.place.address.state_province self.photo.place.address.state_province
if self.photo.place and self.photo.place.address.state_province if self.photo.place and self.photo.place.address.state_province
else None else None
) )
if field =="place.address.postal_code": if field == "place.address.postal_code":
return ( return (
self.photo.place.address.postal_code self.photo.place.address.postal_code
if self.photo.place and self.photo.place.address.postal_code if self.photo.place and self.photo.place.address.postal_code
else None else None
) )
if field =="place.address.country": if field == "place.address.country":
return ( return (
self.photo.place.address.country self.photo.place.address.country
if self.photo.place and self.photo.place.address.country if self.photo.place and self.photo.place.address.country
else None else None
) )
if field =="place.address.country_code": if field == "place.address.country_code":
return ( return (
self.photo.place.address.iso_country_code self.photo.place.address.iso_country_code
if self.photo.place and self.photo.place.address.iso_country_code if self.photo.place and self.photo.place.address.iso_country_code

View File

@@ -87,19 +87,19 @@ class PLRevGeoLocationInfo:
self.postalAddress = postalAddress self.postalAddress = postalAddress
def __eq__(self, other): def __eq__(self, other):
for field in [ return all(
"addressString", getattr(self, field) == getattr(other, field)
"countryCode", for field in [
"isHome", "addressString",
"compoundNames", "countryCode",
"compoundSecondaryNames", "isHome",
"version", "compoundNames",
"geoServiceProvider", "compoundSecondaryNames",
"postalAddress", "version",
]: "geoServiceProvider",
if getattr(self, field) != getattr(other, field): "postalAddress",
return False ]
return True )
def __ne__(self, other): def __ne__(self, other):
return not self.__eq__(other) return not self.__eq__(other)
@@ -151,21 +151,17 @@ class PLRevGeoMapItem:
self.finalPlaceInfos = finalPlaceInfos self.finalPlaceInfos = finalPlaceInfos
def __eq__(self, other): def __eq__(self, other):
for field in ["sortedPlaceInfos", "finalPlaceInfos"]: return all(
if getattr(self, field) != getattr(other, field): getattr(self, field) == getattr(other, field)
return False for field in ["sortedPlaceInfos", "finalPlaceInfos"]
return True )
def __ne__(self, other): def __ne__(self, other):
return not self.__eq__(other) return not self.__eq__(other)
def __str__(self): def __str__(self):
sortedPlaceInfos = [] sortedPlaceInfos = [str(place) for place in self.sortedPlaceInfos]
finalPlaceInfos = [] finalPlaceInfos = [str(place) for place in self.finalPlaceInfos]
for place in self.sortedPlaceInfos:
sortedPlaceInfos.append(str(place))
for place in self.finalPlaceInfos:
finalPlaceInfos.append(str(place))
return ( return (
f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}" f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}"
) )
@@ -192,10 +188,10 @@ class PLRevGeoMapItemAdditionalPlaceInfo:
self.dominantOrderType = dominantOrderType self.dominantOrderType = dominantOrderType
def __eq__(self, other): def __eq__(self, other):
for field in ["area", "name", "placeType", "dominantOrderType"]: return all(
if getattr(self, field) != getattr(other, field): getattr(self, field) == getattr(other, field)
return False for field in ["area", "name", "placeType", "dominantOrderType"]
return True )
def __ne__(self, other): def __ne__(self, other):
return not self.__eq__(other) return not self.__eq__(other)
@@ -245,19 +241,19 @@ class CNPostalAddress:
self._subLocality = _subLocality self._subLocality = _subLocality
def __eq__(self, other): def __eq__(self, other):
for field in [ return all(
"_ISOCountryCode", getattr(self, field) == getattr(other, field)
"_city", for field in [
"_country", "_ISOCountryCode",
"_postalCode", "_city",
"_state", "_country",
"_street", "_postalCode",
"_subAdministrativeArea", "_state",
"_subLocality", "_street",
]: "_subAdministrativeArea",
if getattr(self, field) != getattr(other, field): "_subLocality",
return False ]
return True )
def __ne__(self, other): def __ne__(self, other):
return not self.__eq__(other) return not self.__eq__(other)
@@ -490,16 +486,14 @@ class PlaceInfo4(PlaceInfo):
"names": self.names, "names": self.names,
"country_code": self.country_code, "country_code": self.country_code,
} }
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")" return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
return strval
def as_dict(self): def as_dict(self):
info = { return {
"name": self.name, "name": self.name,
"names": self.names._asdict(), "names": self.names._asdict(),
"country_code": self.country_code, "country_code": self.country_code,
} }
return info
class PlaceInfo5(PlaceInfo): class PlaceInfo5(PlaceInfo):
@@ -541,7 +535,7 @@ class PlaceInfo5(PlaceInfo):
@property @property
def address(self): def address(self):
addr = self._plrevgeoloc.postalAddress addr = self._plrevgeoloc.postalAddress
address = PostalAddress( return PostalAddress(
street=addr._street, street=addr._street,
sub_locality=addr._subLocality, sub_locality=addr._subLocality,
city=addr._city, city=addr._city,
@@ -551,7 +545,6 @@ class PlaceInfo5(PlaceInfo):
country=addr._country, country=addr._country,
iso_country_code=addr._ISOCountryCode, iso_country_code=addr._ISOCountryCode,
) )
return address
def _process_place_info(self): def _process_place_info(self):
""" Process sortedPlaceInfos to set self._name and self._names """ """ Process sortedPlaceInfos to set self._name and self._names """
@@ -630,11 +623,10 @@ class PlaceInfo5(PlaceInfo):
"address_str": self.address_str, "address_str": self.address_str,
"address": str(self.address), "address": str(self.address),
} }
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")" return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
return strval
def as_dict(self): def as_dict(self):
info = { return {
"name": self.name, "name": self.name,
"names": self.names._asdict(), "names": self.names._asdict(),
"country_code": self.country_code, "country_code": self.country_code,
@@ -642,4 +634,3 @@ class PlaceInfo5(PlaceInfo):
"address_str": self.address_str, "address_str": self.address_str,
"address": self.address._asdict(), "address": self.address._asdict(),
} }
return info

View File

@@ -261,10 +261,9 @@ def get_preferred_uti_extension(uti):
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc # reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
ext = CoreServices.UTTypeCopyPreferredTagWithClass( return CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension uti, CoreServices.kUTTagClassFilenameExtension
) )
return ext
def findfiles(pattern, path_): def findfiles(pattern, path_):

View File

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

View File

@@ -3,24 +3,24 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>BackgroundHighlightCollection</key> <key>BackgroundHighlightCollection</key>
<date>2020-05-30T01:45:51Z</date> <date>2020-06-06T14:26:31Z</date>
<key>BackgroundHighlightEnrichment</key> <key>BackgroundHighlightEnrichment</key>
<date>2020-05-30T01:45:51Z</date> <date>2020-06-06T14:26:29Z</date>
<key>BackgroundJobAssetRevGeocode</key> <key>BackgroundJobAssetRevGeocode</key>
<date>2020-05-30T04:01:24Z</date> <date>2020-06-06T14:26:31Z</date>
<key>BackgroundJobSearch</key> <key>BackgroundJobSearch</key>
<date>2020-05-30T01:45:51Z</date> <date>2020-06-06T14:26:31Z</date>
<key>BackgroundPeopleSuggestion</key> <key>BackgroundPeopleSuggestion</key>
<date>2020-05-30T01:45:51Z</date> <date>2020-06-06T14:26:29Z</date>
<key>BackgroundUserBehaviorProcessor</key> <key>BackgroundUserBehaviorProcessor</key>
<date>2020-05-29T04:31:38Z</date> <date>2020-06-06T14:26:31Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key> <key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2020-05-30T02:16:06Z</date> <date>2020-05-30T02:16:06Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key> <key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-05-29T04:31:37Z</date> <date>2020-05-29T04:31:37Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key> <key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-05-30T04:01:24Z</date> <date>2020-06-06T14:26:33Z</date>
<key>SiriPortraitDonation</key> <key>SiriPortraitDonation</key>
<date>2020-05-29T04:31:38Z</date> <date>2020-06-06T14:26:31Z</date>
</dict> </dict>
</plist> </plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

View File

@@ -7,6 +7,8 @@ PHOTOS_DB = "tests/Test-10.15.5.photoslibrary/database/photos.db"
PHOTOS_DB_PATH = "/Test-10.15.5.photoslibrary/database/photos.db" PHOTOS_DB_PATH = "/Test-10.15.5.photoslibrary/database/photos.db"
PHOTOS_LIBRARY_PATH = "/Test-10.15.5.photoslibrary" PHOTOS_LIBRARY_PATH = "/Test-10.15.5.photoslibrary"
PHOTOS_DB_LEN = 13
KEYWORDS = [ KEYWORDS = [
"Kids", "Kids",
"wedding", "wedding",
@@ -22,10 +24,11 @@ KEYWORDS = [
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON] PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
ALBUMS = [ ALBUMS = [
"Pumpkin Farm", "Pumpkin Farm",
"Test Album", "Test Album", # there are 2 albums named "Test Album" for testing duplicate album names
"AlbumInFolder", "AlbumInFolder",
"Raw" "Raw",
] # Note: there are 2 albums named "Test Album" for testing duplicate album names "I have a deleted twin", # there's an empty album with same name that has been deleted
]
KEYWORDS_DICT = { KEYWORDS_DICT = {
"Kids": 4, "Kids": 4,
"wedding": 2, "wedding": 2,
@@ -43,6 +46,7 @@ ALBUM_DICT = {
"Test Album": 2, "Test Album": 2,
"AlbumInFolder": 2, "AlbumInFolder": 2,
"Raw": 4, "Raw": 4,
"I have a deleted twin": 1,
} # Note: there are 2 albums named "Test Album" for testing duplicate album names } # Note: there are 2 albums named "Test Album" for testing duplicate album names
UUID_DICT = { UUID_DICT = {
@@ -58,8 +62,15 @@ UUID_DICT = {
"external_edit": "DC99FBDD-7A52-4100-A5BB-344131646C30", "external_edit": "DC99FBDD-7A52-4100-A5BB-344131646C30",
"no_external_edit": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", "no_external_edit": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51",
"export": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg" "export": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg"
"export_tif": "8846E3E6-8AC8-4857-8448-E3D025784410",
} }
UUID_PUMPKIN_FARM = [
"F12384F6-CD17-4151-ACBA-AE0E3688539E",
"D79B8D77-BFFC-460B-9312-034F2877D35B",
"1EB2B765-0765-43BA-A90C-0D0580E6172C",
]
def test_init1(): def test_init1():
# test named argument # test named argument
@@ -109,14 +120,14 @@ def test_init4():
def test_init5(mocker): def test_init5(mocker):
# test failed get_last_library_path # test failed get_last_library_path
import osxphotos import osxphotos
def bad_library(): def bad_library():
return None return None
# get_last_library actually in utils but need to patch it in photosdb because it's imported into photosdb # get_last_library actually in utils but need to patch it in photosdb because it's imported into photosdb
# because of the layout of photosdb/ need to patch it this way...don't really understand why, but it works # because of the layout of photosdb/ need to patch it this way...don't really understand why, but it works
mocker.patch("osxphotos.photosdb.photosdb.get_last_library_path", new=bad_library) mocker.patch("osxphotos.photosdb.photosdb.get_last_library_path", new=bad_library)
with pytest.raises(Exception): with pytest.raises(Exception):
assert osxphotos.PhotosDB() assert osxphotos.PhotosDB()
@@ -126,7 +137,7 @@ def test_db_len():
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS # assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS
assert len(photosdb) == 12 assert len(photosdb) == PHOTOS_DB_LEN
def test_db_version(): def test_db_version():
@@ -229,7 +240,7 @@ def test_missing():
photos = photosdb.photos(uuid=[UUID_DICT["missing"]]) photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
assert len(photos) == 1 assert len(photos) == 1
p = photos[0] p = photos[0]
assert p.path == None assert p.path is None
assert p.ismissing == True assert p.ismissing == True
@@ -378,7 +389,7 @@ def test_count():
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos() photos = photosdb.photos()
assert len(photos) == 12 assert len(photos) == PHOTOS_DB_LEN
def test_keyword_2(): def test_keyword_2():
@@ -402,6 +413,16 @@ def test_keyword_not_in_album():
assert photos3[0].uuid == "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C" assert photos3[0].uuid == "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C"
def test_album_folder_name():
"""Test query with album name same as a folder name """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(albums=["Pumpkin Farm"])
assert sorted(p.uuid for p in photos) == sorted(UUID_PUMPKIN_FARM)
def test_get_db_path(): def test_get_db_path():
import osxphotos import osxphotos
@@ -730,6 +751,31 @@ def test_export_13():
assert e.type == type(FileNotFoundError()) assert e.type == type(FileNotFoundError())
def test_export_14(caplog):
# test export with user provided filename with different (but valid) extension than source
import os
import os.path
import tempfile
import time
import osxphotos
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["export_tif"]])
timestamp = time.time()
filename = f"osxphotos-export-2-test-{timestamp}.tif"
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest, filename)[0]
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
assert "Invalid destination suffix" not in caplog.text
def test_eq(): def test_eq():
import osxphotos import osxphotos
@@ -781,7 +827,7 @@ def test_from_to_date():
photosdb = osxphotos.PhotosDB(PHOTOS_DB) photosdb = osxphotos.PhotosDB(PHOTOS_DB)
photos = photosdb.photos(from_date=dt.datetime(2018, 10, 28)) photos = photosdb.photos(from_date=dt.datetime(2018, 10, 28))
assert len(photos) ==6 assert len(photos) == 7
photos = photosdb.photos(to_date=dt.datetime(2018, 10, 28)) photos = photosdb.photos(to_date=dt.datetime(2018, 10, 28))
assert len(photos) == 6 assert len(photos) == 6

View File

@@ -1,4 +1,5 @@
import os import os
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
@@ -10,6 +11,7 @@ RAW_PHOTOS_DB = "tests/Test-RAW-10.15.1.photoslibrary"
PLACES_PHOTOS_DB = "tests/Test-Places-Catalina-10_15_1.photoslibrary" PLACES_PHOTOS_DB = "tests/Test-Places-Catalina-10_15_1.photoslibrary"
PLACES_PHOTOS_DB_13 = "tests/Test-Places-High-Sierra-10.13.6.photoslibrary" PLACES_PHOTOS_DB_13 = "tests/Test-Places-High-Sierra-10.13.6.photoslibrary"
PHOTOS_DB_15_4 = "tests/Test-10.15.4.photoslibrary" PHOTOS_DB_15_4 = "tests/Test-10.15.4.photoslibrary"
PHOTOS_DB_15_5 = "tests/Test-10.15.5.photoslibrary"
PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary" PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary"
CLI_OUTPUT_NO_SUBCOMMAND = [ CLI_OUTPUT_NO_SUBCOMMAND = [
@@ -28,6 +30,7 @@ CLI_OUTPUT_NO_SUBCOMMAND = [
" help Print help; for help on commands: help <command>.", " help Print help; for help on commands: help <command>.",
" info Print out descriptive info of the Photos library database.", " info Print out descriptive info of the Photos library database.",
" keywords Print out keywords found in the Photos library.", " keywords Print out keywords found in the Photos library.",
" labels Print out image classification labels found in the Photos",
" list Print list of Photos libraries found on the system.", " list Print list of Photos libraries found on the system.",
" persons Print out persons (faces) found in the Photos library.", " persons Print out persons (faces) found in the Photos library.",
" places Print out places found in the Photos library.", " places Print out places found in the Photos library.",
@@ -47,6 +50,10 @@ CLI_EXPORT_FILENAMES = [
"wedding_edited.jpeg", "wedding_edited.jpeg",
] ]
CLI_EXPORT_FILENAMES_ALBUM = ["Pumkins1.jpg", "Pumkins2.jpg", "Pumpkins3.jpg"]
CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"]
CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten" CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [ CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
@@ -132,6 +139,41 @@ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES3 = [
"2018/{foo}/Pumkins1.jpg", "2018/{foo}/Pumkins1.jpg",
] ]
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1 = [
"2019-wedding.jpg",
"2019-wedding_edited.jpeg",
"2019-Tulips.jpg",
"2018-St James Park.jpg",
"2018-St James Park_edited.jpeg",
"2018-Pumpkins3.jpg",
"2018-Pumkins2.jpg",
"2018-Pumkins1.jpg",
]
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2 = [
"Folder1_SubFolder2_AlbumInFolder-IMG_4547.jpg",
"Folder1_SubFolder2_AlbumInFolder-wedding.jpg",
"Folder1_SubFolder2_AlbumInFolder-wedding_edited.jpeg",
"Folder2_Raw-DSC03584.dng",
"Folder2_Raw-IMG_1994.cr2",
"Folder2_Raw-IMG_1994.JPG",
"Folder2_Raw-IMG_1997.cr2",
"Folder2_Raw-IMG_1997.JPG",
"None-St James Park.jpg",
"None-St James Park_edited.jpeg",
"None-Tulips.jpg",
"None-Tulips_edited.jpeg",
"Pumpkin Farm-Pumkins1.jpg",
"Pumpkin Farm-Pumkins2.jpg",
"Pumpkin Farm-Pumpkins3.jpg",
"Test Album-Pumkins1.jpg",
"Test Album-Pumkins2.jpg",
"None-IMG_1693.tif",
"I have a deleted twin-wedding.jpg",
"I have a deleted twin-wedding_edited.jpeg",
]
CLI_EXPORT_UUID = "D79B8D77-BFFC-460B-9312-034F2877D35B" CLI_EXPORT_UUID = "D79B8D77-BFFC-460B-9312-034F2877D35B"
CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg" CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg"
@@ -169,6 +211,62 @@ CLI_EXIFTOOL = {
"XMP:Subject": ["Kids", "Katie"], "XMP:Subject": ["Kids", "Katie"],
} }
} }
LABELS_JSON = {
"labels": {
"Plant": 5,
"Tree": 2,
"Sky": 2,
"Outdoor": 2,
"Art": 2,
"Foliage": 2,
"Waterways": 1,
"River": 1,
"Cloudy": 1,
"Land": 1,
"Water Body": 1,
"Water": 1,
"Statue": 1,
"Window": 1,
"Decorative Plant": 1,
"Blue Sky": 1,
"Palm Tree": 1,
"Flower": 1,
"Flower Arrangement": 1,
"Bouquet": 1,
"Vase": 1,
"Container": 1,
"Camera": 1,
}
}
KEYWORDS_JSON = {
"keywords": {
"Kids": 4,
"wedding": 2,
"London 2018": 1,
"St. James's Park": 1,
"England": 1,
"United Kingdom": 1,
"UK": 1,
"London": 1,
"flowers": 1,
}
}
ALBUMS_JSON = {
"albums": {
"Raw": 4,
"Pumpkin Farm": 3,
"AlbumInFolder": 2,
"Test Album": 2,
"I have a deleted twin": 1,
},
"shared albums": {},
}
PERSONS_JSON = {"persons": {"Katie": 3, "Suzy": 2, "_UNKNOWN_": 1, "Maria": 1}}
# determine if exiftool installed so exiftool tests can be skipped # determine if exiftool installed so exiftool tests can be skipped
try: try:
exiftool = get_exiftool_path() exiftool = get_exiftool_path()
@@ -183,9 +281,10 @@ def test_osxphotos():
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, []) result = runner.invoke(cli, [])
output = result.output output = result.output
assert result.exit_code == 0 assert result.exit_code == 0
for line in CLI_OUTPUT_NO_SUBCOMMAND: for line in CLI_OUTPUT_NO_SUBCOMMAND:
assert line in output assert line.strip() in output
def test_osxphotos_help_1(): def test_osxphotos_help_1():
@@ -198,7 +297,7 @@ def test_osxphotos_help_1():
output = result.output output = result.output
assert result.exit_code == 0 assert result.exit_code == 0
for line in CLI_OUTPUT_NO_SUBCOMMAND: for line in CLI_OUTPUT_NO_SUBCOMMAND:
assert line in output assert line.strip() in output
def test_osxphotos_help_2(): def test_osxphotos_help_2():
@@ -208,7 +307,6 @@ def test_osxphotos_help_2():
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["help", "persons"]) result = runner.invoke(cli, ["help", "persons"])
output = result.output
assert result.exit_code == 0 assert result.exit_code == 0
assert "Print out persons (faces) found in the Photos library." in result.output assert "Print out persons (faces) found in the Photos library." in result.output
@@ -220,7 +318,6 @@ def test_osxphotos_help_3():
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["help", "foo"]) result = runner.invoke(cli, ["help", "foo"])
output = result.output
assert result.exit_code == 0 assert result.exit_code == 0
assert "Invalid command: foo" in result.output assert "Invalid command: foo" in result.output
@@ -475,14 +572,387 @@ def test_query_date():
], ],
) )
assert result.exit_code == 0 assert result.exit_code == 0
import logging
logging.warning(result.output)
json_got = json.loads(result.output) json_got = json.loads(result.output)
assert len(json_got) == 4 assert len(json_got) == 4
def test_query_keyword_1():
"""Test query --keyword """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--keyword", "Kids"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 4
def test_query_keyword_2():
"""Test query --keyword with lower case keyword"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--keyword", "kids"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 0
def test_query_keyword_3():
"""Test query --keyword with lower case keyword and --ignore-case"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--keyword",
"kids",
"--ignore-case",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 4
def test_query_keyword_4():
"""Test query with more than one --keyword"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--keyword",
"Kids",
"--keyword",
"wedding",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 6
def test_query_person_1():
"""Test query --person"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--person", "Katie"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 3
def test_query_person_2():
"""Test query --person with lower case person"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--person", "katie"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 0
def test_query_person_3():
"""Test query --person with lower case person and --ignore-case"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--person",
"katie",
"--ignore-case",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 3
def test_query_person_4():
"""Test query with multiple --person"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--person",
"Katie",
"--person",
"Maria",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 4
def test_query_album_1():
"""Test query --album"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--album",
"Pumpkin Farm",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 3
def test_query_album_2():
"""Test query --album with lower case album"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--album",
"pumpkin farm",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 0
def test_query_album_3():
"""Test query --album with lower case album and --ignore-case"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--album",
"pumpkin farm",
"--ignore-case",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 3
def test_query_album_4():
"""Test query with multipl --album"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--album",
"Pumpkin Farm",
"--album",
"Raw",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 7
def test_query_label_1():
"""Test query --label"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--label", "Statue"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 1
def test_query_label_2():
"""Test query --label with lower case label """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--label", "statue"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 0
def test_query_label_3():
"""Test query --label with lower case label and --ignore-case"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--label",
"statue",
"--ignore-case",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 1
def test_query_label_4():
"""Test query with more than one --label"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--label",
"Statue",
"--label",
"Plant",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 6
def test_export_sidecar(): def test_export_sidecar():
import glob import glob
import os import os
@@ -706,7 +1176,7 @@ def test_export_directory_template_2():
def test_export_directory_template_3(): def test_export_directory_template_3():
# test export using directory template with unmatched substituion value # test export using directory template with unmatched substitution value
import glob import glob
import os import os
import os.path import os.path
@@ -728,7 +1198,7 @@ def test_export_directory_template_3():
], ],
) )
assert result.exit_code == 2 assert result.exit_code == 2
assert "Error: Invalid substitution in template" in result.output assert "Error: Invalid template" in result.output
def test_export_directory_template_album_1(): def test_export_directory_template_album_1():
@@ -825,6 +1295,141 @@ def test_export_directory_template_locale():
assert os.path.isfile(os.path.join(workdir, filepath)) assert os.path.isfile(os.path.join(workdir, filepath))
def test_export_filename_template_1():
""" export photos using filename template """
import glob
import locale
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
locale.setlocale(locale.LC_ALL, "en_US")
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",
"--filename",
"{created.year}-{original_name}",
],
)
assert result.exit_code == 0
workdir = os.getcwd()
files = glob.glob("*.*")
assert sorted(files) == sorted(CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1)
def test_export_filename_template_2():
""" export photos using filename template with folder_album and path_sep """
import glob
import locale
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
locale.setlocale(locale.LC_ALL, "en_US")
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_5),
".",
"-V",
"--filename",
"{folder_album,None}-{original_name}",
],
)
assert result.exit_code == 0
files = glob.glob("*.*")
assert sorted(files) == sorted(CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2)
def test_export_filename_template_3():
""" test --filename with invalid 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),
".",
"-V",
"--directory",
"{foo}-{original_filename}",
],
)
assert result.exit_code == 2
assert "Error: Invalid template" in result.output
def test_export_album():
"""Test export of an album """
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, PHOTOS_DB_15_5), ".", "--album", "Pumpkin Farm", "-V"],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ALBUM)
def test_export_album_deleted_twin():
"""Test export of an album where album of same name has been deleted """
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, PHOTOS_DB_15_5),
".",
"--album",
"I have a deleted twin",
"-V",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_DELETED_TWIN)
def test_places(): def test_places():
import json import json
import os import os
@@ -1084,8 +1689,6 @@ def test_export_sidecar_keyword_template():
"EXIF:ModifyDate": "2020:04:11 12:34:16"}]""" "EXIF:ModifyDate": "2020:04:11 12:34:16"}]"""
)[0] )[0]
import logging
json_file = open("Pumkins2.json", "r") json_file = open("Pumkins2.json", "r")
json_got = json.load(json_file)[0] json_got = json.load(json_file)[0]
json_file.close() json_file.close()
@@ -1450,3 +2053,79 @@ def test_export_directory_template_1_dry_run():
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1: for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1:
assert f"Exported {filepath}" in result.output assert f"Exported {filepath}" in result.output
assert not os.path.isfile(os.path.join(workdir, filepath)) assert not os.path.isfile(os.path.join(workdir, filepath))
def test_labels():
"""Test osxphotos labels """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import labels
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
labels, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert json_got == LABELS_JSON
def test_keywords():
"""Test osxphotos keywords """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import keywords
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
keywords, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert json_got == KEYWORDS_JSON
def test_albums():
"""Test osxphotos albums """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import albums
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
albums, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert json_got == ALBUMS_JSON
def test_persons():
"""Test osxphotos albums """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import persons
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
persons, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert json_got == PERSONS_JSON

View File

@@ -1,14 +1,16 @@
""" test datetime_formatter.DateTimeFormatter """ """ test datetime_formatter.DateTimeFormatter """
import pytest import pytest
def test_datetime_formatter():
def test_datetime_formatter_1():
"""Test DateTimeFormatter """
import datetime import datetime
import locale import locale
from osxphotos.datetime_formatter import DateTimeFormatter from osxphotos.datetime_formatter import DateTimeFormatter
locale.setlocale(locale.LC_ALL, "en_US") locale.setlocale(locale.LC_ALL, "en_US")
dt = datetime.datetime(2020,5,23) dt = datetime.datetime(2020, 5, 23, 12, 42, 33)
dtf = DateTimeFormatter(dt) dtf = DateTimeFormatter(dt)
assert dtf.date == "2020-05-23" assert dtf.date == "2020-05-23"
@@ -19,3 +21,54 @@ def test_datetime_formatter():
assert dtf.mm == "05" assert dtf.mm == "05"
assert dtf.dd == "23" assert dtf.dd == "23"
assert dtf.doy == "144" assert dtf.doy == "144"
assert dtf.hour == "12"
assert dtf.min == "42"
assert dtf.sec == "33"
def test_datetime_formatter_2():
"""Test DateTimeFormatter with hour > 12 """
import datetime
import locale
from osxphotos.datetime_formatter import DateTimeFormatter
locale.setlocale(locale.LC_ALL, "en_US")
dt = datetime.datetime(2020, 5, 23, 14, 42, 33)
dtf = DateTimeFormatter(dt)
assert dtf.date == "2020-05-23"
assert dtf.year == "2020"
assert dtf.yy == "20"
assert dtf.month == "May"
assert dtf.mon == "May"
assert dtf.mm == "05"
assert dtf.dd == "23"
assert dtf.doy == "144"
assert dtf.hour == "14"
assert dtf.min == "42"
assert dtf.sec == "33"
def test_datetime_formatter_3():
"""Test DateTimeFormatter zero-padding """
import datetime
import locale
from osxphotos.datetime_formatter import DateTimeFormatter
locale.setlocale(locale.LC_ALL, "en_US")
dt = datetime.datetime(2020, 5, 2, 9, 3, 6)
dtf = DateTimeFormatter(dt)
assert dtf.date == "2020-05-02"
assert dtf.year == "2020"
assert dtf.yy == "20"
assert dtf.month == "May"
assert dtf.mon == "May"
assert dtf.mm == "05"
assert dtf.dd == "02"
assert dtf.doy == "123"
assert dtf.hour == "09"
assert dtf.min == "03"
assert dtf.sec == "06"

97
tests/test_score_info.py Normal file
View File

@@ -0,0 +1,97 @@
""" Test ScoreInfo """
from math import isclose
import pytest
from osxphotos.photoinfo import ScoreInfo
PHOTOS_DB_5 = "tests/Test-10.15.5.photoslibrary"
PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary"
SCORE_DICT = {
"4D521201-92AC-43E5-8F7C-59BC41C37A96": ScoreInfo(
overall=0.470703125,
curation=0.5,
promotion=0.0,
highlight_visibility=0.03816793893129771,
behavioral=0.0,
failure=-0.0006928443908691406,
harmonious_color=0.017852783203125,
immersiveness=0.003086090087890625,
interaction=0.019999999552965164,
interesting_subject=-0.0885009765625,
intrusive_object_presence=-0.037872314453125,
lively_color=0.10540771484375,
low_light=0.00824737548828125,
noise=-0.015655517578125,
pleasant_camera_tilt=-0.006256103515625,
pleasant_composition=0.028564453125,
pleasant_lighting=-0.00439453125,
pleasant_pattern=0.09088134765625,
pleasant_perspective=0.11859130859375,
pleasant_post_processing=0.00698089599609375,
pleasant_reflection=-0.01523590087890625,
pleasant_symmetry=0.01242828369140625,
sharply_focused_subject=0.08538818359375,
tastefully_blurred=0.022125244140625,
well_chosen_subject=0.05596923828125,
well_framed_subject=0.5986328125,
well_timed_shot=0.0134124755859375,
),
"6191423D-8DB8-4D4C-92BE-9BBBA308AAC4": ScoreInfo(
overall=0.853515625,
curation=0.75,
promotion=0.0,
highlight_visibility=0.05725190839694656,
behavioral=0.0,
failure=-0.0004916191101074219,
harmonious_color=0.382080078125,
immersiveness=0.0133209228515625,
interaction=0.03999999910593033,
interesting_subject=0.1632080078125,
intrusive_object_presence=-0.00966644287109375,
lively_color=0.44091796875,
low_light=0.01322174072265625,
noise=-0.0026721954345703125,
pleasant_camera_tilt=0.028045654296875,
pleasant_composition=0.33642578125,
pleasant_lighting=0.46142578125,
pleasant_pattern=0.1944580078125,
pleasant_perspective=0.494384765625,
pleasant_post_processing=0.4970703125,
pleasant_reflection=0.00910186767578125,
pleasant_symmetry=0.00930023193359375,
sharply_focused_subject=0.52490234375,
tastefully_blurred=0.63916015625,
well_chosen_subject=0.64208984375,
well_framed_subject=0.485595703125,
well_timed_shot=0.01531219482421875,
),
}
@pytest.fixture
def photosdb():
import osxphotos
return osxphotos.PhotosDB(dbfile=PHOTOS_DB_5)
def test_score_info_v5(photosdb):
""" test score """
# use math.isclose to compare floats
# on MacOS x64 these can probably compared for equality but would possibly
# fail if osxphotos ever ported to other platforms
for uuid in SCORE_DICT:
photo = photosdb.photos(uuid=[uuid], movies=True)[0]
for attr in photo.score.__dict__:
assert isclose(getattr(photo.score, attr), getattr(SCORE_DICT[uuid], attr))
def test_score_info_v4():
""" test version 4, score should be None """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_4)
for photo in photosdb.photos():
assert photo.score is None

View File

@@ -32,6 +32,9 @@ TEMPLATE_VALUES = {
"{created.dd}": "04", "{created.dd}": "04",
"{created.dow}": "Tuesday", "{created.dow}": "Tuesday",
"{created.doy}": "035", "{created.doy}": "035",
"{created.hour}": "19",
"{created.min}": "07",
"{created.sec}": "38",
"{modified.date}": "2020-03-21", "{modified.date}": "2020-03-21",
"{modified.year}": "2020", "{modified.year}": "2020",
"{modified.yy}": "20", "{modified.yy}": "20",
@@ -40,6 +43,9 @@ TEMPLATE_VALUES = {
"{modified.mon}": "Mar", "{modified.mon}": "Mar",
"{modified.dd}": "21", "{modified.dd}": "21",
"{modified.doy}": "081", "{modified.doy}": "081",
"{modified.hour}": "01",
"{modified.min}": "33",
"{modified.sec}": "08",
"{place.name}": "Washington, District of Columbia, United States", "{place.name}": "Washington, District of Columbia, United States",
"{place.country_code}": "US", "{place.country_code}": "US",
"{place.name.country}": "United States", "{place.name.country}": "United States",
@@ -106,7 +112,7 @@ def test_lookup():
for subst in TEMPLATE_SUBSTITUTIONS: for subst in TEMPLATE_SUBSTITUTIONS:
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1) lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
lookup = template.get_template_value(lookup_str) lookup = template.get_template_value(lookup_str, None)
assert lookup or lookup is None assert lookup or lookup is None
@@ -115,7 +121,10 @@ def test_lookup_multi():
import os import os
import re import re
import osxphotos import osxphotos
from osxphotos.phototemplate import TEMPLATE_SUBSTITUTIONS_MULTI_VALUED, PhotoTemplate from osxphotos.phototemplate import (
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
PhotoTemplate,
)
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
@@ -123,10 +132,11 @@ def test_lookup_multi():
for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED: for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED:
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1) lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
lookup = template.get_template_value_multi(lookup_str,path_sep=os.path.sep) lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep)
assert isinstance(lookup, list) assert isinstance(lookup, list)
assert len(lookup) >= 1 assert len(lookup) >= 1
def test_subst(): def test_subst():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
import locale import locale
@@ -432,3 +442,19 @@ def test_subst_multi_folder_albums_3():
rendered, unknown = photo.render_template(template) rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
assert unknown == [] assert unknown == []
def test_subst_strftime():
""" Test that strftime substitutions are correct """
import locale
import osxphotos
locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
rendered, unmatched = photo.render_template("{created.strftime,%Y-%m-%d-%H%M%S}")
assert rendered[0] == "2020-02-04-190738"
rendered, unmatched = photo.render_template("{created.strftime}")
assert rendered[0] == "_"

View File

@@ -1,6 +1,6 @@
""" Builds the template table in markdown format for README.md """ """ Builds the template table in markdown format for README.md """
from osxphotos.photoinfo.template import ( from osxphotos.phototemplate import (
TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
) )