Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
937da9e617 | ||
|
|
435868a0a7 | ||
|
|
d9802247d9 | ||
|
|
f39a92a352 | ||
|
|
40dc7d32f2 | ||
|
|
4cd6c8f617 | ||
|
|
0004250e74 | ||
|
|
868ee7737b | ||
|
|
5387f8e2f9 | ||
|
|
73b499f405 | ||
|
|
06fa1edcae | ||
|
|
cf2615da62 | ||
|
|
4ba1982d74 | ||
|
|
abd10b73e8 | ||
|
|
7cd7b51598 | ||
|
|
801dc62c4b | ||
|
|
72f034ef85 | ||
|
|
cb993f2e5e | ||
|
|
2271d89355 | ||
|
|
62d096b5a1 | ||
|
|
5c7a0c3a24 | ||
|
|
ec727cc556 | ||
|
|
6c84827ec7 | ||
|
|
d47fd46a21 |
65
CHANGELOG.md
65
CHANGELOG.md
@@ -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).
|
||||
|
||||
#### [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)
|
||||
|
||||
> 25 May 2020
|
||||
|
||||
118
README.md
118
README.md
@@ -17,6 +17,7 @@
|
||||
+ [AlbumInfo](#albuminfo)
|
||||
+ [FolderInfo](#folderinfo)
|
||||
+ [PlaceInfo](#placeinfo)
|
||||
+ [ScoreInfo](#scoreinfo)
|
||||
+ [Template Substitutions](#template-substitutions)
|
||||
+ [Utility Functions](#utility-functions)
|
||||
* [Examples](#examples)
|
||||
@@ -34,7 +35,7 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
|
||||
|
||||
## Supported operating systems
|
||||
|
||||
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 & 10.15.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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
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:
|
||||
`pipx install osxphotos`
|
||||
@@ -90,6 +91,7 @@ Commands:
|
||||
help Print help; for help on commands: help <command>.
|
||||
info Print out descriptive info of the Photos library database.
|
||||
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.
|
||||
persons Print out persons (faces) found in the Photos library.
|
||||
places Print out places found in the Photos library.
|
||||
@@ -125,13 +127,13 @@ Options:
|
||||
-V, --verbose Print verbose output.
|
||||
--keyword KEYWORD Search for photos with keyword KEYWORD. If
|
||||
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
|
||||
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
|
||||
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. If more than one folder, treated as
|
||||
"OR", e.g. find photos in any FOLDER. Only
|
||||
@@ -146,11 +148,15 @@ Options:
|
||||
geolocation info
|
||||
--no-place Search for photos with no associated place
|
||||
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
|
||||
identifier (UTI) matches UTI
|
||||
-i, --ignore-case Case insensitive search for title,
|
||||
description, or place. Does not apply to
|
||||
keyword, person, or album.
|
||||
description, place, keyword, person, or
|
||||
album.
|
||||
--edited Search for photos that have been edited.
|
||||
--external-edit Search for photos edited in external editor.
|
||||
--favorite Search for photos marked favorite.
|
||||
@@ -282,6 +288,12 @@ Options:
|
||||
output directory in the form
|
||||
'{name,DEFAULT}'. See below for additional
|
||||
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.
|
||||
Default name for edited photos is in form
|
||||
'photoname_edited.ext'. For example, with '
|
||||
@@ -326,13 +338,16 @@ option to re-export the entire library thus rebuilding the
|
||||
|
||||
** Templating System **
|
||||
|
||||
With the --directory option you may specify a template for the export
|
||||
directory. This directory will be appended to the export path specified in
|
||||
the export DEST argument to export. For example, if template is
|
||||
'{created.year}/{created.month}', and export desitnation DEST is
|
||||
'/Users/maria/Pictures/export', the actual export directory for a photo would
|
||||
be '/Users/maria/Pictures/export/2020/March' if the photo was created in March
|
||||
2020.
|
||||
With the --directory and --filename options you may specify a template for the
|
||||
export directory or filename, respectively. The directory will be appended to
|
||||
the export path specified in the export DEST argument to export. For example,
|
||||
if template is '{created.year}/{created.month}', and export desitnation DEST
|
||||
is '/Users/maria/Pictures/export', the actual export directory for a photo
|
||||
would be '/Users/maria/Pictures/export/2020/March' if the photo was created in
|
||||
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
|
||||
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
|
||||
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
|
||||
I plan to eventually extend the templating system to the exported filename so
|
||||
you can specify the filename using a template.
|
||||
above example, this would result in '2020/_/photoname.jpg' if address was
|
||||
null.
|
||||
|
||||
Substitution Description
|
||||
{name} Current filename of the photo
|
||||
@@ -384,6 +398,18 @@ Substitution Description
|
||||
creation time
|
||||
{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.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
|
||||
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.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")
|
||||
```
|
||||
|
||||
**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()`
|
||||
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
|
||||
'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
|
||||
|
||||
@@ -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.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.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.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.year}|4-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.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.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.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|
|
||||
@@ -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_normalized}|All lower case version of 'label' (Photos 5 only)|
|
||||
|
||||
|
||||
### Utility Functions
|
||||
|
||||
The following functions are located in osxphotos.utils
|
||||
|
||||
@@ -20,11 +20,7 @@ from osxphotos.__main__ import get_photos_db, _list_libraries
|
||||
def main():
|
||||
db = None
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
db = sys.argv[1]
|
||||
else:
|
||||
db = get_photos_db()
|
||||
|
||||
db = sys.argv[1] if len(sys.argv) > 1 else get_photos_db()
|
||||
if db:
|
||||
print("loading database")
|
||||
tic = time.perf_counter()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -445,10 +445,7 @@ class ExportDB(ExportDB_ABC):
|
||||
dt = datetime.datetime.utcnow().isoformat()
|
||||
python_path = sys.executable
|
||||
cmd = sys.argv[0]
|
||||
if len(sys.argv) > 1:
|
||||
args = " ".join(sys.argv[1:])
|
||||
else:
|
||||
args = ""
|
||||
args = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else ""
|
||||
cwd = os.getcwd()
|
||||
conn = self._conn
|
||||
try:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.29.8"
|
||||
__version__ = "0.29.19"
|
||||
|
||||
@@ -62,10 +62,7 @@ class AlbumInfo:
|
||||
try:
|
||||
return self._folder_names
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
|
||||
else:
|
||||
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
|
||||
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
|
||||
return self._folder_names
|
||||
|
||||
@property
|
||||
|
||||
@@ -12,53 +12,59 @@ class DateTimeFormatter:
|
||||
@property
|
||||
def date(self):
|
||||
""" ISO date in form 2020-03-22 """
|
||||
date = self.dt.date().isoformat()
|
||||
return date
|
||||
return self.dt.date().isoformat()
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
""" 4 digit year """
|
||||
year = f"{self.dt.year}"
|
||||
return year
|
||||
return f"{self.dt.year}"
|
||||
|
||||
@property
|
||||
def yy(self):
|
||||
""" 2 digit year """
|
||||
yy = f"{self.dt.strftime('%y')}"
|
||||
return yy
|
||||
return f"{self.dt.strftime('%y')}"
|
||||
|
||||
@property
|
||||
def mm(self):
|
||||
""" 2 digit month """
|
||||
mm = f"{self.dt.strftime('%m')}"
|
||||
return mm
|
||||
return f"{self.dt.strftime('%m')}"
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
""" Month as locale's full name """
|
||||
month = f"{self.dt.strftime('%B')}"
|
||||
return month
|
||||
return f"{self.dt.strftime('%B')}"
|
||||
|
||||
@property
|
||||
def mon(self):
|
||||
""" Month as locale's abbreviated name """
|
||||
mon = f"{self.dt.strftime('%b')}"
|
||||
return mon
|
||||
return f"{self.dt.strftime('%b')}"
|
||||
|
||||
@property
|
||||
def dd(self):
|
||||
""" 2-digit day of the month """
|
||||
dd = f"{self.dt.strftime('%d')}"
|
||||
return dd
|
||||
return f"{self.dt.strftime('%d')}"
|
||||
|
||||
@property
|
||||
def dow(self):
|
||||
""" Day of week as locale's name """
|
||||
dow = f"{self.dt.strftime('%A')}"
|
||||
return dow
|
||||
return f"{self.dt.strftime('%A')}"
|
||||
|
||||
@property
|
||||
def doy(self):
|
||||
""" Julian day of year starting from 001 """
|
||||
doy = f"{self.dt.strftime('%j')}"
|
||||
return doy
|
||||
return f"{self.dt.strftime('%j')}"
|
||||
|
||||
@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')}"
|
||||
|
||||
@@ -59,11 +59,7 @@ class _ExifToolProc:
|
||||
)
|
||||
return
|
||||
|
||||
if exiftool:
|
||||
self._exiftool = exiftool
|
||||
else:
|
||||
self._exiftool = get_exiftool_path()
|
||||
|
||||
self._exiftool = exiftool if exiftool else get_exiftool_path()
|
||||
self._process_running = False
|
||||
self._start_proc()
|
||||
|
||||
@@ -156,8 +152,7 @@ class ExifTool:
|
||||
|
||||
if value is None:
|
||||
value = ""
|
||||
command = []
|
||||
command.append(f"-{tag}={value}")
|
||||
command = [f"-{tag}={value}"]
|
||||
if self.overwrite:
|
||||
command.append("-overwrite_original")
|
||||
self.run_commands(*command)
|
||||
@@ -193,7 +188,7 @@ class ExifTool:
|
||||
no_file: (bool) do not pass the filename to exiftool (default=False)
|
||||
by default, all commands will be run against self.file
|
||||
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")
|
||||
|
||||
if not commands:
|
||||
@@ -245,8 +240,7 @@ class ExifTool:
|
||||
|
||||
def json(self):
|
||||
""" returns JSON string containing all EXIF tags and values from exiftool """
|
||||
json_str = self.run_commands("-json")
|
||||
return json_str
|
||||
return self.run_commands("-json")
|
||||
|
||||
def _read_exif(self):
|
||||
""" read exif data from file """
|
||||
@@ -254,5 +248,4 @@ class ExifTool:
|
||||
self.data = {k: v for k, v in data.items()}
|
||||
|
||||
def __str__(self):
|
||||
str_ = f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
|
||||
return str_
|
||||
return f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
|
||||
|
||||
@@ -11,6 +11,7 @@ from abc import ABC, abstractmethod
|
||||
|
||||
class FileUtilABC(ABC):
|
||||
""" Abstract base class for FileUtil """
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def hardlink(cls, src, dest):
|
||||
@@ -39,6 +40,7 @@ class FileUtilABC(ABC):
|
||||
|
||||
class FileUtilMacOS(FileUtilABC):
|
||||
""" Various file utilities """
|
||||
|
||||
@classmethod
|
||||
def hardlink(cls, src, dest):
|
||||
""" 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:
|
||||
return False
|
||||
if s1 == s2:
|
||||
return True
|
||||
return False
|
||||
return s1 == s2
|
||||
|
||||
@classmethod
|
||||
def file_sig(cls, f1):
|
||||
@@ -135,14 +135,17 @@ class FileUtilMacOS(FileUtilABC):
|
||||
|
||||
class FileUtil(FileUtilMacOS):
|
||||
""" Various file utilities """
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FileUtilNoOp(FileUtil):
|
||||
""" No-Op implementation of FileUtil for testing / dry-run mode
|
||||
all methods with exception of cmp_sig and file_cmp are no-op
|
||||
cmp_sig functions as FileUtil.cmp_sig does
|
||||
file_cmp returns mock data
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def noop(*args):
|
||||
pass
|
||||
@@ -155,7 +158,7 @@ class FileUtilNoOp(FileUtil):
|
||||
cls.verbose = verbose
|
||||
else:
|
||||
raise ValueError(f"verbose {verbose} not callable")
|
||||
return super(FileUtilNoOp, cls).__new__(cls)
|
||||
return super(FileUtilNoOp, cls).__new__(cls)
|
||||
|
||||
@classmethod
|
||||
def hardlink(cls, src, dest):
|
||||
@@ -164,7 +167,7 @@ class FileUtilNoOp(FileUtil):
|
||||
@classmethod
|
||||
def copy(cls, src, dest, norsrc=False):
|
||||
cls.verbose(f"copy: {src} {dest}")
|
||||
|
||||
|
||||
@classmethod
|
||||
def unlink(cls, dest):
|
||||
cls.verbose(f"unlink: {dest}")
|
||||
|
||||
@@ -6,4 +6,5 @@ PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
|
||||
from ._photoinfo_exifinfo import ExifInfo
|
||||
from ._photoinfo_export import ExportResults
|
||||
from ._photoinfo_scoreinfo import ScoreInfo
|
||||
from .photoinfo import PhotoInfo
|
||||
|
||||
@@ -156,6 +156,46 @@ def _export_photo_uuid_applescript(
|
||||
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(
|
||||
self,
|
||||
dest,
|
||||
@@ -182,8 +222,11 @@ def export(
|
||||
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
|
||||
For example, if photo is .CR2 file, edited image may be .jpeg.
|
||||
If you provide an extension different than what the actual file is,
|
||||
export will print a warning but will happily export the photo using the
|
||||
incorrect file extension. e.g. to get the extension of the edited photo,
|
||||
export will print a warning but will export the photo using the
|
||||
incorrect file extension (unless use_photos_export is true, in which case export will
|
||||
use the extension provided by Photos upon export; in this case, an incorrect extension is
|
||||
silently ignored).
|
||||
e.g. to get the extension of the edited photo,
|
||||
reference PhotoInfo.path_edited
|
||||
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||
(or raise exception if no edited version)
|
||||
@@ -260,13 +303,16 @@ def export2(
|
||||
dry_run=False,
|
||||
):
|
||||
""" 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
|
||||
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
|
||||
For example, if photo is .CR2 file, edited image may be .jpeg.
|
||||
If you provide an extension different than what the actual file is,
|
||||
export will print a warning but will happily export the photo using the
|
||||
incorrect file extension. e.g. to get the extension of the edited photo,
|
||||
export will print a warning but will export the photo using the
|
||||
incorrect file extension (unless use_photos_export is true, in which case export will
|
||||
use the extension provided by Photos upon export; in this case, an incorrect extension is
|
||||
silently ignored).
|
||||
e.g. to get the extension of the edited photo,
|
||||
reference PhotoInfo.path_edited
|
||||
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||
(or raise exception if no edited version)
|
||||
@@ -370,27 +416,6 @@ def export2(
|
||||
fname = pathlib.Path(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
|
||||
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
|
||||
# e.g. exporting sidecar for file1.png and file1.jpeg
|
||||
@@ -438,6 +463,13 @@ def export2(
|
||||
if not os.path.isfile(src):
|
||||
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(
|
||||
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
|
||||
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
|
||||
|
||||
Args:
|
||||
src: src path (string)
|
||||
dest: dest path (pathlib.Path)
|
||||
update: bool
|
||||
@@ -766,7 +800,9 @@ def _export_photo(
|
||||
export_as_hardlink: bool
|
||||
exiftool: bool
|
||||
fileutil: FileUtil class that conforms to fileutil.FileUtilABC
|
||||
Returns: ExportResults
|
||||
|
||||
Returns:
|
||||
ExportResults
|
||||
"""
|
||||
|
||||
exported_files = []
|
||||
@@ -1153,7 +1189,7 @@ def _xmp_sidecar(
|
||||
def _write_sidecar(self, filename, sidecar_str):
|
||||
""" write sidecar_str to filename
|
||||
used for exporting sidecar info """
|
||||
if not filename and not sidecar_str:
|
||||
if not (filename or sidecar_str):
|
||||
raise (
|
||||
ValueError(
|
||||
f"filename {filename} and sidecar_str {sidecar_str} must not be None"
|
||||
|
||||
119
osxphotos/photoinfo/_photoinfo_scoreinfo.py
Normal file
119
osxphotos/photoinfo/_photoinfo_scoreinfo.py
Normal 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
|
||||
@@ -29,6 +29,7 @@ from ..placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from ..phototemplate import PhotoTemplate
|
||||
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
|
||||
|
||||
|
||||
class PhotoInfo:
|
||||
"""
|
||||
Info about a specific photo, contains all the details about the photo
|
||||
@@ -54,6 +55,7 @@ class PhotoInfo:
|
||||
_xmp_sidecar,
|
||||
ExportResults,
|
||||
)
|
||||
from ._photoinfo_scoreinfo import score, ScoreInfo
|
||||
|
||||
def __init__(self, db=None, uuid=None, info=None):
|
||||
self._uuid = uuid
|
||||
@@ -636,6 +638,9 @@ class PhotoInfo:
|
||||
none_str: a str to use if template field renders to None, default is "_".
|
||||
path_sep: a single character str to use as path separator when joining
|
||||
fields like folder_album; if not provided, defaults to os.path.sep
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
"""
|
||||
template = PhotoTemplate(self)
|
||||
return template.render(template_str, none_str, path_sep)
|
||||
@@ -660,6 +665,8 @@ class PhotoInfo:
|
||||
date_modified_iso = (
|
||||
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 = {
|
||||
"uuid": self.uuid,
|
||||
@@ -700,6 +707,9 @@ class PhotoInfo:
|
||||
"has_raw": self.has_raw,
|
||||
"uti_raw": self.uti_raw,
|
||||
"path_raw": self.path_raw,
|
||||
"place": self.place,
|
||||
"exif": exif,
|
||||
"score": score,
|
||||
}
|
||||
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}
|
||||
exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
|
||||
place = self.place.as_dict() if self.place else {}
|
||||
score = dataclasses.asdict(self.score) if self.score else {}
|
||||
|
||||
pic = {
|
||||
"uuid": self.uuid,
|
||||
@@ -757,6 +768,7 @@ class PhotoInfo:
|
||||
"path_raw": self.path_raw,
|
||||
"place": place,
|
||||
"exif": exif,
|
||||
"score": score,
|
||||
}
|
||||
return json.dumps(pic)
|
||||
|
||||
|
||||
145
osxphotos/photosdb/_photosdb_process_scoreinfo.py
Normal file
145
osxphotos/photosdb/_photosdb_process_scoreinfo.py
Normal 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
|
||||
@@ -102,7 +102,7 @@ def _process_searchinfo(self):
|
||||
# 8: groups.lookup_identifier
|
||||
|
||||
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
|
||||
record = {}
|
||||
record["uuid"] = uuid
|
||||
@@ -123,13 +123,9 @@ def _process_searchinfo(self):
|
||||
|
||||
category = record["category"]
|
||||
try:
|
||||
_db_searchinfo_categories[category].append(
|
||||
record["normalized_string"]
|
||||
)
|
||||
_db_searchinfo_categories[category].append(record["normalized_string"])
|
||||
except KeyError:
|
||||
_db_searchinfo_categories[category] = [
|
||||
record["normalized_string"]
|
||||
]
|
||||
_db_searchinfo_categories[category] = [record["normalized_string"]]
|
||||
|
||||
if category == SEARCH_CATEGORY_LABEL:
|
||||
label = record["content_string"]
|
||||
@@ -198,6 +194,7 @@ def labels_normalized_as_dict(self):
|
||||
|
||||
# The following method is not imported into PhotosDB
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def ints_to_uuid(uuid_0, uuid_1):
|
||||
""" convert two signed ints into a UUID strings
|
||||
|
||||
@@ -64,6 +64,7 @@ class PhotosDB:
|
||||
labels_as_dict,
|
||||
labels_normalized_as_dict,
|
||||
)
|
||||
from ._photosdb_process_scoreinfo import _process_scoreinfo
|
||||
|
||||
def __init__(self, *dbfile_, dbfile=None):
|
||||
""" create a new PhotosDB object
|
||||
@@ -294,18 +295,17 @@ class PhotosDB:
|
||||
@property
|
||||
def keywords_as_dict(self):
|
||||
""" return keywords as dict of keyword, count in reverse sorted order (descending) """
|
||||
keywords = {}
|
||||
for k in self._dbkeywords_keyword.keys():
|
||||
keywords[k] = len(self._dbkeywords_keyword[k])
|
||||
keywords = {
|
||||
k: len(self._dbkeywords_keyword[k]) for k in self._dbkeywords_keyword.keys()
|
||||
}
|
||||
|
||||
keywords = dict(sorted(keywords.items(), key=lambda kv: kv[1], reverse=True))
|
||||
return keywords
|
||||
|
||||
@property
|
||||
def persons_as_dict(self):
|
||||
""" return persons as dict of person, count in reverse sorted order (descending) """
|
||||
persons = {}
|
||||
for k in self._dbfaces_person.keys():
|
||||
persons[k] = len(self._dbfaces_person[k])
|
||||
persons = {k: len(self._dbfaces_person[k]) for k in self._dbfaces_person.keys()}
|
||||
persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True))
|
||||
return persons
|
||||
|
||||
@@ -413,13 +413,12 @@ class PhotosDB:
|
||||
def album_info(self):
|
||||
""" return list of AlbumInfo objects for each album in the photos database """
|
||||
|
||||
albums = [
|
||||
return [
|
||||
AlbumInfo(db=self, uuid=album)
|
||||
for album in self._dbalbums_album.keys()
|
||||
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is None
|
||||
and not self._dbalbum_details[album]["intrash"]
|
||||
]
|
||||
return albums
|
||||
|
||||
@property
|
||||
def album_info_shared(self):
|
||||
@@ -433,13 +432,12 @@ class PhotosDB:
|
||||
)
|
||||
return []
|
||||
|
||||
albums_shared = [
|
||||
return [
|
||||
AlbumInfo(db=self, uuid=album)
|
||||
for album in self._dbalbums_album.keys()
|
||||
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is not None
|
||||
and not self._dbalbum_details[album]["intrash"]
|
||||
]
|
||||
return albums_shared
|
||||
|
||||
@property
|
||||
def albums(self):
|
||||
@@ -1724,8 +1722,6 @@ class PhotosDB:
|
||||
# determine if a photo is missing in Photos 5
|
||||
|
||||
# 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(
|
||||
""" SELECT
|
||||
ZGENERICASSET.ZUUID,
|
||||
@@ -1734,10 +1730,7 @@ class PhotosDB:
|
||||
FROM ZGENERICASSET
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
|
||||
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
|
||||
WHERE ZDATASTORESUBTYPE = 0 OR ZDATASTORESUBTYPE = 3 """
|
||||
# WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
|
||||
# WHERE ZDATASTORESUBTYPE = 0 OR ZDATASTORESUBTYPE = 3 """
|
||||
# WHERE ZINTERNALRESOURCE.ZFINGERPRINT IS NULL AND ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 3 """
|
||||
WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
|
||||
)
|
||||
|
||||
for row in c:
|
||||
@@ -1870,6 +1863,9 @@ class PhotosDB:
|
||||
# process exif info
|
||||
self._process_exifinfo()
|
||||
|
||||
# process computed scores
|
||||
self._process_scoreinfo()
|
||||
|
||||
# done processing, dump debug data if requested
|
||||
if _debug():
|
||||
logging.debug("Faces (_dbfaces_uuid):")
|
||||
@@ -2136,7 +2132,11 @@ class PhotosDB:
|
||||
if album in self._dbalbum_titles:
|
||||
title_set = set()
|
||||
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)
|
||||
else:
|
||||
logging.debug(f"Could not find album '{album}' in database")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
""" Custom template system for osxphotos (implemented in PhotoInfo.render_template) """
|
||||
|
||||
|
||||
# Rolled my own template system because:
|
||||
# 1. Needed to handle multiple values (e.g. album, keyword)
|
||||
# 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()
|
||||
#
|
||||
# This code isn't elegant but it seems to work well. PRs gladly accepted.
|
||||
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
@@ -35,6 +35,14 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{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.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.year}": "4-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.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.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.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",
|
||||
@@ -71,7 +87,7 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
# Just the multi-valued substitution names without the braces
|
||||
MULTI_VALUE_SUBSTITUTIONS = [
|
||||
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
|
||||
|
||||
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
|
||||
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:
|
||||
path_sep = os.path.sep
|
||||
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
|
||||
# for explanation of regex see https://regex101.com/r/4JJg42/1
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)(?=\}(?!\}))\}"
|
||||
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-\%. ]+))?)(?=\}(?!\}))\}"
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
|
||||
@@ -122,7 +145,7 @@ class PhotoTemplate:
|
||||
groups = len(matchobj.groups())
|
||||
if groups == 4:
|
||||
try:
|
||||
val = get_func(matchobj.group(1))
|
||||
val = get_func(matchobj.group(1), matchobj.group(3))
|
||||
except ValueError:
|
||||
return matchobj.group(0)
|
||||
|
||||
@@ -172,7 +195,7 @@ class PhotoTemplate:
|
||||
rendered_strings = set([rendered])
|
||||
for field in MULTI_VALUE_SUBSTITUTIONS:
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
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
|
||||
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:
|
||||
return val
|
||||
else:
|
||||
@@ -220,11 +244,12 @@ class PhotoTemplate:
|
||||
|
||||
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)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
default: the default value provided by the user
|
||||
|
||||
Returns:
|
||||
The matching template value (which may be None).
|
||||
@@ -234,172 +259,228 @@ class PhotoTemplate:
|
||||
"""
|
||||
|
||||
# must be a valid keyword
|
||||
if field =="name":
|
||||
if field == "name":
|
||||
return pathlib.Path(self.photo.filename).stem
|
||||
|
||||
if field =="original_name":
|
||||
if field == "original_name":
|
||||
return pathlib.Path(self.photo.original_filename).stem
|
||||
|
||||
if field =="title":
|
||||
if field == "title":
|
||||
return self.photo.title
|
||||
|
||||
if field =="descr":
|
||||
if field == "descr":
|
||||
return self.photo.description
|
||||
|
||||
if field =="created.date":
|
||||
if field == "created.date":
|
||||
return DateTimeFormatter(self.photo.date).date
|
||||
|
||||
if field =="created.year":
|
||||
if field == "created.year":
|
||||
return DateTimeFormatter(self.photo.date).year
|
||||
|
||||
if field =="created.yy":
|
||||
if field == "created.yy":
|
||||
return DateTimeFormatter(self.photo.date).yy
|
||||
|
||||
if field =="created.mm":
|
||||
if field == "created.mm":
|
||||
return DateTimeFormatter(self.photo.date).mm
|
||||
|
||||
if field =="created.month":
|
||||
if field == "created.month":
|
||||
return DateTimeFormatter(self.photo.date).month
|
||||
|
||||
if field =="created.mon":
|
||||
if field == "created.mon":
|
||||
return DateTimeFormatter(self.photo.date).mon
|
||||
|
||||
if field =="created.dd":
|
||||
if field == "created.dd":
|
||||
return DateTimeFormatter(self.photo.date).dd
|
||||
|
||||
if field =="created.dow":
|
||||
if field == "created.dow":
|
||||
return DateTimeFormatter(self.photo.date).dow
|
||||
|
||||
if field =="created.doy":
|
||||
if field == "created.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 (
|
||||
DateTimeFormatter(self.photo.date_modified).date
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="modified.year":
|
||||
if field == "modified.year":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).year
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="modified.yy":
|
||||
if field == "modified.yy":
|
||||
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 (
|
||||
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 (
|
||||
DateTimeFormatter(self.photo.date_modified).month
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="modified.mon":
|
||||
if field == "modified.mon":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).mon
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="modified.dd":
|
||||
if field == "modified.dd":
|
||||
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 (
|
||||
DateTimeFormatter(self.photo.date_modified).doy
|
||||
if self.photo.date_modified
|
||||
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
|
||||
|
||||
if field =="place.country_code":
|
||||
if field == "place.country_code":
|
||||
return self.photo.place.country_code if self.photo.place else None
|
||||
|
||||
if field =="place.name.country":
|
||||
if field == "place.name.country":
|
||||
return (
|
||||
self.photo.place.names.country[0]
|
||||
if self.photo.place and self.photo.place.names.country
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="place.name.state_province":
|
||||
if field == "place.name.state_province":
|
||||
return (
|
||||
self.photo.place.names.state_province[0]
|
||||
if self.photo.place and self.photo.place.names.state_province
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="place.name.city":
|
||||
if field == "place.name.city":
|
||||
return (
|
||||
self.photo.place.names.city[0]
|
||||
if self.photo.place and self.photo.place.names.city
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="place.name.area_of_interest":
|
||||
if field == "place.name.area_of_interest":
|
||||
return (
|
||||
self.photo.place.names.area_of_interest[0]
|
||||
if self.photo.place and self.photo.place.names.area_of_interest
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="place.address":
|
||||
if field == "place.address":
|
||||
return (
|
||||
self.photo.place.address_str
|
||||
if self.photo.place and self.photo.place.address_str
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="place.address.street":
|
||||
if field == "place.address.street":
|
||||
return (
|
||||
self.photo.place.address.street
|
||||
if self.photo.place and self.photo.place.address.street
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="place.address.city":
|
||||
if field == "place.address.city":
|
||||
return (
|
||||
self.photo.place.address.city
|
||||
if self.photo.place and self.photo.place.address.city
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="place.address.state_province":
|
||||
if field == "place.address.state_province":
|
||||
return (
|
||||
self.photo.place.address.state_province
|
||||
if self.photo.place and self.photo.place.address.state_province
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="place.address.postal_code":
|
||||
if field == "place.address.postal_code":
|
||||
return (
|
||||
self.photo.place.address.postal_code
|
||||
if self.photo.place and self.photo.place.address.postal_code
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="place.address.country":
|
||||
if field == "place.address.country":
|
||||
return (
|
||||
self.photo.place.address.country
|
||||
if self.photo.place and self.photo.place.address.country
|
||||
else None
|
||||
)
|
||||
|
||||
if field =="place.address.country_code":
|
||||
if field == "place.address.country_code":
|
||||
return (
|
||||
self.photo.place.address.iso_country_code
|
||||
if self.photo.place and self.photo.place.address.iso_country_code
|
||||
|
||||
@@ -87,19 +87,19 @@ class PLRevGeoLocationInfo:
|
||||
self.postalAddress = postalAddress
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in [
|
||||
"addressString",
|
||||
"countryCode",
|
||||
"isHome",
|
||||
"compoundNames",
|
||||
"compoundSecondaryNames",
|
||||
"version",
|
||||
"geoServiceProvider",
|
||||
"postalAddress",
|
||||
]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field)
|
||||
for field in [
|
||||
"addressString",
|
||||
"countryCode",
|
||||
"isHome",
|
||||
"compoundNames",
|
||||
"compoundSecondaryNames",
|
||||
"version",
|
||||
"geoServiceProvider",
|
||||
"postalAddress",
|
||||
]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
@@ -151,21 +151,17 @@ class PLRevGeoMapItem:
|
||||
self.finalPlaceInfos = finalPlaceInfos
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in ["sortedPlaceInfos", "finalPlaceInfos"]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field)
|
||||
for field in ["sortedPlaceInfos", "finalPlaceInfos"]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
sortedPlaceInfos = []
|
||||
finalPlaceInfos = []
|
||||
for place in self.sortedPlaceInfos:
|
||||
sortedPlaceInfos.append(str(place))
|
||||
for place in self.finalPlaceInfos:
|
||||
finalPlaceInfos.append(str(place))
|
||||
sortedPlaceInfos = [str(place) for place in self.sortedPlaceInfos]
|
||||
finalPlaceInfos = [str(place) for place in self.finalPlaceInfos]
|
||||
return (
|
||||
f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}"
|
||||
)
|
||||
@@ -192,10 +188,10 @@ class PLRevGeoMapItemAdditionalPlaceInfo:
|
||||
self.dominantOrderType = dominantOrderType
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in ["area", "name", "placeType", "dominantOrderType"]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field)
|
||||
for field in ["area", "name", "placeType", "dominantOrderType"]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
@@ -245,19 +241,19 @@ class CNPostalAddress:
|
||||
self._subLocality = _subLocality
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in [
|
||||
"_ISOCountryCode",
|
||||
"_city",
|
||||
"_country",
|
||||
"_postalCode",
|
||||
"_state",
|
||||
"_street",
|
||||
"_subAdministrativeArea",
|
||||
"_subLocality",
|
||||
]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field)
|
||||
for field in [
|
||||
"_ISOCountryCode",
|
||||
"_city",
|
||||
"_country",
|
||||
"_postalCode",
|
||||
"_state",
|
||||
"_street",
|
||||
"_subAdministrativeArea",
|
||||
"_subLocality",
|
||||
]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
@@ -490,16 +486,14 @@ class PlaceInfo4(PlaceInfo):
|
||||
"names": self.names,
|
||||
"country_code": self.country_code,
|
||||
}
|
||||
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
return strval
|
||||
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
|
||||
def as_dict(self):
|
||||
info = {
|
||||
return {
|
||||
"name": self.name,
|
||||
"names": self.names._asdict(),
|
||||
"country_code": self.country_code,
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
class PlaceInfo5(PlaceInfo):
|
||||
@@ -541,7 +535,7 @@ class PlaceInfo5(PlaceInfo):
|
||||
@property
|
||||
def address(self):
|
||||
addr = self._plrevgeoloc.postalAddress
|
||||
address = PostalAddress(
|
||||
return PostalAddress(
|
||||
street=addr._street,
|
||||
sub_locality=addr._subLocality,
|
||||
city=addr._city,
|
||||
@@ -551,7 +545,6 @@ class PlaceInfo5(PlaceInfo):
|
||||
country=addr._country,
|
||||
iso_country_code=addr._ISOCountryCode,
|
||||
)
|
||||
return address
|
||||
|
||||
def _process_place_info(self):
|
||||
""" 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),
|
||||
}
|
||||
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
return strval
|
||||
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
|
||||
def as_dict(self):
|
||||
info = {
|
||||
return {
|
||||
"name": self.name,
|
||||
"names": self.names._asdict(),
|
||||
"country_code": self.country_code,
|
||||
@@ -642,4 +634,3 @@ class PlaceInfo5(PlaceInfo):
|
||||
"address_str": self.address_str,
|
||||
"address": self.address._asdict(),
|
||||
}
|
||||
return info
|
||||
|
||||
@@ -261,10 +261,9 @@ def get_preferred_uti_extension(uti):
|
||||
|
||||
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
|
||||
|
||||
ext = CoreServices.UTTypeCopyPreferredTagWithClass(
|
||||
return CoreServices.UTTypeCopyPreferredTagWithClass(
|
||||
uti, CoreServices.kUTTagClassFilenameExtension
|
||||
)
|
||||
return ext
|
||||
|
||||
|
||||
def findfiles(pattern, path_):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>4021</integer>
|
||||
<integer>703</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,24 +3,24 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BackgroundHighlightCollection</key>
|
||||
<date>2020-05-30T01:45:51Z</date>
|
||||
<date>2020-06-06T14:26:31Z</date>
|
||||
<key>BackgroundHighlightEnrichment</key>
|
||||
<date>2020-05-30T01:45:51Z</date>
|
||||
<date>2020-06-06T14:26:29Z</date>
|
||||
<key>BackgroundJobAssetRevGeocode</key>
|
||||
<date>2020-05-30T04:01:24Z</date>
|
||||
<date>2020-06-06T14:26:31Z</date>
|
||||
<key>BackgroundJobSearch</key>
|
||||
<date>2020-05-30T01:45:51Z</date>
|
||||
<date>2020-06-06T14:26:31Z</date>
|
||||
<key>BackgroundPeopleSuggestion</key>
|
||||
<date>2020-05-30T01:45:51Z</date>
|
||||
<date>2020-06-06T14:26:29Z</date>
|
||||
<key>BackgroundUserBehaviorProcessor</key>
|
||||
<date>2020-05-29T04:31:38Z</date>
|
||||
<date>2020-06-06T14:26:31Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||
<date>2020-05-30T02:16:06Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-05-29T04:31:37Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-05-30T04:01:24Z</date>
|
||||
<date>2020-06-06T14:26:33Z</date>
|
||||
<key>SiriPortraitDonation</key>
|
||||
<date>2020-05-29T04:31:38Z</date>
|
||||
<date>2020-06-06T14:26:31Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 290 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/test-images/IMG_1693.tif
Normal file
BIN
tests/test-images/IMG_1693.tif
Normal file
Binary file not shown.
@@ -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_LIBRARY_PATH = "/Test-10.15.5.photoslibrary"
|
||||
|
||||
PHOTOS_DB_LEN = 13
|
||||
|
||||
KEYWORDS = [
|
||||
"Kids",
|
||||
"wedding",
|
||||
@@ -22,10 +24,11 @@ KEYWORDS = [
|
||||
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
|
||||
ALBUMS = [
|
||||
"Pumpkin Farm",
|
||||
"Test Album",
|
||||
"Test Album", # there are 2 albums named "Test Album" for testing duplicate album names
|
||||
"AlbumInFolder",
|
||||
"Raw"
|
||||
] # Note: there are 2 albums named "Test Album" for testing duplicate album names
|
||||
"Raw",
|
||||
"I have a deleted twin", # there's an empty album with same name that has been deleted
|
||||
]
|
||||
KEYWORDS_DICT = {
|
||||
"Kids": 4,
|
||||
"wedding": 2,
|
||||
@@ -43,6 +46,7 @@ ALBUM_DICT = {
|
||||
"Test Album": 2,
|
||||
"AlbumInFolder": 2,
|
||||
"Raw": 4,
|
||||
"I have a deleted twin": 1,
|
||||
} # Note: there are 2 albums named "Test Album" for testing duplicate album names
|
||||
|
||||
UUID_DICT = {
|
||||
@@ -58,8 +62,15 @@ UUID_DICT = {
|
||||
"external_edit": "DC99FBDD-7A52-4100-A5BB-344131646C30",
|
||||
"no_external_edit": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51",
|
||||
"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():
|
||||
# test named argument
|
||||
@@ -109,14 +120,14 @@ def test_init4():
|
||||
def test_init5(mocker):
|
||||
# test failed get_last_library_path
|
||||
import osxphotos
|
||||
|
||||
|
||||
def bad_library():
|
||||
return None
|
||||
|
||||
# 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
|
||||
mocker.patch("osxphotos.photosdb.photosdb.get_last_library_path", new=bad_library)
|
||||
|
||||
|
||||
with pytest.raises(Exception):
|
||||
assert osxphotos.PhotosDB()
|
||||
|
||||
@@ -126,7 +137,7 @@ def test_db_len():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
# assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS
|
||||
assert len(photosdb) == 12
|
||||
assert len(photosdb) == PHOTOS_DB_LEN
|
||||
|
||||
|
||||
def test_db_version():
|
||||
@@ -229,7 +240,7 @@ def test_missing():
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
|
||||
assert len(photos) == 1
|
||||
p = photos[0]
|
||||
assert p.path == None
|
||||
assert p.path is None
|
||||
assert p.ismissing == True
|
||||
|
||||
|
||||
@@ -378,7 +389,7 @@ def test_count():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos()
|
||||
assert len(photos) == 12
|
||||
assert len(photos) == PHOTOS_DB_LEN
|
||||
|
||||
|
||||
def test_keyword_2():
|
||||
@@ -402,6 +413,16 @@ def test_keyword_not_in_album():
|
||||
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():
|
||||
import osxphotos
|
||||
|
||||
@@ -730,6 +751,31 @@ def test_export_13():
|
||||
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():
|
||||
import osxphotos
|
||||
|
||||
@@ -781,7 +827,7 @@ def test_from_to_date():
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB)
|
||||
|
||||
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))
|
||||
assert len(photos) == 6
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
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_13 = "tests/Test-Places-High-Sierra-10.13.6.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"
|
||||
|
||||
CLI_OUTPUT_NO_SUBCOMMAND = [
|
||||
@@ -28,6 +30,7 @@ CLI_OUTPUT_NO_SUBCOMMAND = [
|
||||
" help Print help; for help on commands: help <command>.",
|
||||
" info Print out descriptive info of the Photos library database.",
|
||||
" 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.",
|
||||
" persons Print out persons (faces) found in the Photos library.",
|
||||
" places Print out places found in the Photos library.",
|
||||
@@ -47,6 +50,10 @@ CLI_EXPORT_FILENAMES = [
|
||||
"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_FILENAMES_EDITED_SUFFIX = [
|
||||
@@ -132,6 +139,41 @@ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES3 = [
|
||||
"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_FILENAME = "Pumkins2.jpg"
|
||||
@@ -169,6 +211,62 @@ CLI_EXIFTOOL = {
|
||||
"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
|
||||
try:
|
||||
exiftool = get_exiftool_path()
|
||||
@@ -183,9 +281,10 @@ def test_osxphotos():
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
output = result.output
|
||||
|
||||
assert result.exit_code == 0
|
||||
for line in CLI_OUTPUT_NO_SUBCOMMAND:
|
||||
assert line in output
|
||||
assert line.strip() in output
|
||||
|
||||
|
||||
def test_osxphotos_help_1():
|
||||
@@ -198,7 +297,7 @@ def test_osxphotos_help_1():
|
||||
output = result.output
|
||||
assert result.exit_code == 0
|
||||
for line in CLI_OUTPUT_NO_SUBCOMMAND:
|
||||
assert line in output
|
||||
assert line.strip() in output
|
||||
|
||||
|
||||
def test_osxphotos_help_2():
|
||||
@@ -208,7 +307,6 @@ def test_osxphotos_help_2():
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["help", "persons"])
|
||||
output = result.output
|
||||
assert result.exit_code == 0
|
||||
assert "Print out persons (faces) found in the Photos library." in result.output
|
||||
|
||||
@@ -220,7 +318,6 @@ def test_osxphotos_help_3():
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["help", "foo"])
|
||||
output = result.output
|
||||
assert result.exit_code == 0
|
||||
assert "Invalid command: foo" in result.output
|
||||
|
||||
@@ -475,14 +572,387 @@ def test_query_date():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
import logging
|
||||
|
||||
logging.warning(result.output)
|
||||
|
||||
json_got = json.loads(result.output)
|
||||
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():
|
||||
import glob
|
||||
import os
|
||||
@@ -706,7 +1176,7 @@ def test_export_directory_template_2():
|
||||
|
||||
|
||||
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 os
|
||||
import os.path
|
||||
@@ -728,7 +1198,7 @@ def test_export_directory_template_3():
|
||||
],
|
||||
)
|
||||
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():
|
||||
@@ -825,6 +1295,141 @@ def test_export_directory_template_locale():
|
||||
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():
|
||||
import json
|
||||
import os
|
||||
@@ -1084,8 +1689,6 @@ def test_export_sidecar_keyword_template():
|
||||
"EXIF:ModifyDate": "2020:04:11 12:34:16"}]"""
|
||||
)[0]
|
||||
|
||||
import logging
|
||||
|
||||
json_file = open("Pumkins2.json", "r")
|
||||
json_got = json.load(json_file)[0]
|
||||
json_file.close()
|
||||
@@ -1450,3 +2053,79 @@ def test_export_directory_template_1_dry_run():
|
||||
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1:
|
||||
assert f"Exported {filepath}" in result.output
|
||||
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
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
""" test datetime_formatter.DateTimeFormatter """
|
||||
import pytest
|
||||
|
||||
def test_datetime_formatter():
|
||||
|
||||
def test_datetime_formatter_1():
|
||||
"""Test DateTimeFormatter """
|
||||
import datetime
|
||||
import locale
|
||||
from osxphotos.datetime_formatter import DateTimeFormatter
|
||||
|
||||
|
||||
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)
|
||||
|
||||
assert dtf.date == "2020-05-23"
|
||||
@@ -19,3 +21,54 @@ def test_datetime_formatter():
|
||||
assert dtf.mm == "05"
|
||||
assert dtf.dd == "23"
|
||||
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
97
tests/test_score_info.py
Normal 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
|
||||
@@ -32,6 +32,9 @@ TEMPLATE_VALUES = {
|
||||
"{created.dd}": "04",
|
||||
"{created.dow}": "Tuesday",
|
||||
"{created.doy}": "035",
|
||||
"{created.hour}": "19",
|
||||
"{created.min}": "07",
|
||||
"{created.sec}": "38",
|
||||
"{modified.date}": "2020-03-21",
|
||||
"{modified.year}": "2020",
|
||||
"{modified.yy}": "20",
|
||||
@@ -40,6 +43,9 @@ TEMPLATE_VALUES = {
|
||||
"{modified.mon}": "Mar",
|
||||
"{modified.dd}": "21",
|
||||
"{modified.doy}": "081",
|
||||
"{modified.hour}": "01",
|
||||
"{modified.min}": "33",
|
||||
"{modified.sec}": "08",
|
||||
"{place.name}": "Washington, District of Columbia, United States",
|
||||
"{place.country_code}": "US",
|
||||
"{place.name.country}": "United States",
|
||||
@@ -106,7 +112,7 @@ def test_lookup():
|
||||
|
||||
for subst in TEMPLATE_SUBSTITUTIONS:
|
||||
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
|
||||
|
||||
|
||||
@@ -115,7 +121,10 @@ def test_lookup_multi():
|
||||
import os
|
||||
import re
|
||||
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)
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||
@@ -123,10 +132,11 @@ def test_lookup_multi():
|
||||
|
||||
for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED:
|
||||
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 len(lookup) >= 1
|
||||
|
||||
|
||||
def test_subst():
|
||||
""" Test that substitutions are correct """
|
||||
import locale
|
||||
@@ -432,3 +442,19 @@ def test_subst_multi_folder_albums_3():
|
||||
rendered, unknown = photo.render_template(template)
|
||||
assert sorted(rendered) == sorted(expected)
|
||||
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] == "_"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
""" Builds the template table in markdown format for README.md """
|
||||
|
||||
from osxphotos.photoinfo.template import (
|
||||
from osxphotos.phototemplate import (
|
||||
TEMPLATE_SUBSTITUTIONS,
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user