Compare commits

..

19 Commits

Author SHA1 Message Date
Rhet Turnbull
65674f57bc added --keyword-template 2020-05-01 22:05:46 -07:00
Rhet Turnbull
7af1ccd4ed Fixed bug related to issue #119 2020-04-30 21:38:24 -07:00
Rhet Turnbull
1b6f661e6b test library updates 2020-04-30 13:02:11 -07:00
Rhet Turnbull
a57da2346b Bug fix for albums in Photos <= 4 to address issue #116 2020-04-28 18:20:26 -07:00
Rhet Turnbull
3fe03cd127 version bump for pypi 2020-04-28 07:54:22 -07:00
Rhet Turnbull
5cc98c338b Update README.md 2020-04-28 07:48:54 -07:00
Rhet Turnbull
1c9d4f282b Update README.md 2020-04-28 07:44:00 -07:00
Rhet Turnbull
1ceda15134 Fixed implementation of use_albums_as_keywords and use_persons_as_keywords, closes #115 2020-04-28 07:41:37 -07:00
Rhet Turnbull
a80071111f Updated README.md 2020-04-28 07:10:48 -07:00
Rhet Turnbull
072a8d795e Updated CHANGELOG.md 2020-04-27 23:16:31 -07:00
Rhet Turnbull
b35b071634 Added --album-keyword and --person-keyword to CLI, closes #61 2020-04-27 23:08:59 -07:00
Rhet Turnbull
56a000609f Updated tests/README.md 2020-04-26 16:31:24 -07:00
Rhet Turnbull
54d5d4b7ba Updated test libraries 2020-04-26 16:04:03 -07:00
Rhet Turnbull
38137a1351 Updated CHANGELOG.md 2020-04-26 16:03:26 -07:00
Rhet Turnbull
4b29a2e05f Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-04-26 15:57:51 -07:00
Rhet Turnbull
9be0f849b7 Updated test to avoid issue with GitHub workflow 2020-04-26 15:57:43 -07:00
Rhet Turnbull
ccb5f252d1 Update pythonpackage.yml to remove older pythons 2020-04-26 15:39:37 -07:00
Rhet Turnbull
d8a64c9573 Fixed locale bug in templates, closes #113 2020-04-26 15:20:28 -07:00
Rhet Turnbull
81d4e392c3 Updated CHANGELOG.md 2020-04-20 22:22:08 -07:00
82 changed files with 1750 additions and 547 deletions

View File

@@ -9,7 +9,7 @@ jobs:
strategy: strategy:
max-parallel: 4 max-parallel: 4
matrix: matrix:
python-version: [3.6, 3.7, 3.8] python-version: [3.8]
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1

View File

@@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.28.7](https://github.com/RhetTbull/osxphotos/compare/v0.28.6...v0.28.7)
> 28 April 2020
- Added --album-keyword and --person-keyword to CLI, closes #61 [`#61`](https://github.com/RhetTbull/osxphotos/issues/61)
- Updated test libraries [`54d5d4b`](https://github.com/RhetTbull/osxphotos/commit/54d5d4b7ba99204f58e723231309ab6e306be28c)
- Updated CHANGELOG.md [`38137a1`](https://github.com/RhetTbull/osxphotos/commit/38137a1351cdb7ab72393ea03828933dac0b76b0)
- Updated tests/README.md [`56a0006`](https://github.com/RhetTbull/osxphotos/commit/56a000609f2f08d0f8800fec49cada2980c3bb9d)
#### [v0.28.6](https://github.com/RhetTbull/osxphotos/compare/v0.28.5...v0.28.6)
> 26 April 2020
- Fixed locale bug in templates, closes #113 [`#113`](https://github.com/RhetTbull/osxphotos/issues/113)
- Updated CHANGELOG.md [`81d4e39`](https://github.com/RhetTbull/osxphotos/commit/81d4e392c39f0fe6f967a447c7d0c970bf224032)
- Updated test to avoid issue with GitHub workflow [`9be0f84`](https://github.com/RhetTbull/osxphotos/commit/9be0f849b73061d053d30274ff3295b79c88f0b6)
- Update pythonpackage.yml to remove older pythons [`ccb5f25`](https://github.com/RhetTbull/osxphotos/commit/ccb5f252d14e9335ae04a2e338a6d527b80c9a93)
#### [v0.28.5](https://github.com/RhetTbull/osxphotos/compare/0.28.2...v0.28.5)
> 21 April 2020
- added __len__ to PhotosDB, closes #44 [`#44`](https://github.com/RhetTbull/osxphotos/issues/44)
- Updated use of _PHOTOS_4_VERSION, closes #106 [`#106`](https://github.com/RhetTbull/osxphotos/issues/106)
- Updated tests and test library with RAW images [`9b9b54e`](https://github.com/RhetTbull/osxphotos/commit/9b9b54e590e43ae49fb3ae41d493a1f8faec4181)
- Updated setup.py to resolve issue with bpylist2 on python &lt; 3.8 [`8e4b88a`](https://github.com/RhetTbull/osxphotos/commit/8e4b88ad1fc18438f941e045bfc8aeac878914f9)
- Added cli.py for use with pyinstaller [`cf28cb6`](https://github.com/RhetTbull/osxphotos/commit/cf28cb6452de17f2ef8d80435386e8d5a1aabd34)
- added raw_is_original handling [`a337e79`](https://github.com/RhetTbull/osxphotos/commit/a337e79e13802b4824c2f088ce9db1c027d6f3c5)
- Updated CHANGELOG.md [`22f1e8f`](https://github.com/RhetTbull/osxphotos/commit/22f1e8f2a6478e0576f6bff53e348aad8680ae69)
#### [0.28.2](https://github.com/RhetTbull/osxphotos/compare/v0.28.1...0.28.2) #### [0.28.2](https://github.com/RhetTbull/osxphotos/compare/v0.28.1...0.28.2)
> 18 April 2020 > 18 April 2020
@@ -285,7 +315,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.15.0...v0.15.1) #### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.15.0...v0.15.1)
> 19 April 2020 > 28 April 2020
#### [v0.15.0](https://github.com/RhetTbull/osxphotos/compare/v0.14.21...v0.15.0) #### [v0.15.0](https://github.com/RhetTbull/osxphotos/compare/v0.14.21...v0.15.0)

100
README.md
View File

@@ -16,7 +16,7 @@
+ [AlbumInfo](#albuminfo) + [AlbumInfo](#albuminfo)
+ [FolderInfo](#folderinfo) + [FolderInfo](#folderinfo)
+ [PlaceInfo](#placeinfo) + [PlaceInfo](#placeinfo)
+ [Template Functions](#template-functions) + [Template Substitutions](#template-substitutions)
+ [Utility Functions](#utility-functions) + [Utility Functions](#utility-functions)
* [Examples](#examples) * [Examples](#examples)
* [Related Projects](#related-projects) * [Related Projects](#related-projects)
@@ -33,9 +33,11 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
## Supported operating systems ## Supported operating systems
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 / Photos 5.0. Requires python >= 3.6 though if you use `pip` to install, you must use python >= 3.8. See notes [below](#Installation-instructions) 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.
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12 Requires python >= 3.6 though if you use `pip` to install, you must use python >= 3.8. See notes [below](#Installation-instructions). I highly recommend running this with python >= 3.8 as I'll eventually drop support for 3.6 and 3.7.
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12.
## Installation instructions ## Installation instructions
@@ -48,7 +50,7 @@ If you're using python 3.6 or 3.7, you'll need to do this first to get around an
pip install -r requirements.txt pip install -r requirements.txt
You can also install directly from [pypi](https://pypi.org/) but you must use python >= 3.8 to avoid an error with bpylist2. The package will work fine with python 3.6 or 3.7 but I know of no way to get `pip` to install the right dependencies. You can also install directly from [pypi](https://pypi.org/) but you must use python >= 3.8 to avoid an error with bpylist2. The package currently works fine with python 3.6 or 3.7 but I know of no way to get `pip` to install the right dependencies.
pip install osxphotos pip install osxphotos
@@ -99,6 +101,7 @@ Example: `osxphotos help export`
``` ```
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
Usage: __main__.py export [OPTIONS] [PHOTOS_LIBRARY]... DEST
Export photos from the Photos database. Export path DEST is required. Export photos from the Photos database. Export path DEST is required.
Optionally, query the Photos database using 1 or more search options; if Optionally, query the Photos database using 1 or more search options; if
@@ -217,6 +220,22 @@ Options:
photos if the RAW photo does not have an photos if the RAW photo does not have an
associated jpeg image (e.g. the RAW file was associated jpeg image (e.g. the RAW file was
imported to Photos without a jpeg preview). imported to Photos without a jpeg preview).
--person-keyword Use person in image as keyword/tag when
exporting metadata.
--album-keyword Use album name as keyword/tag when exporting
metadata.
--keyword-template TEMPLATE For use with --exiftool, --sidecar; specify
a template string to use as keyword in the
form '{name,DEFAULT}' This is the same
format as --directory. For example, if you
wanted to add the full path to the folder
and album photo is contained in as a keyword
when exporting you could specify --keyword-
template "{folder_album}" You may specify
more than one template, for example
--keyword-template "{folder_album}"
--keyword-template "{created.year}" See
Templating System below.
--current-name Use photo's current filename instead of --current-name Use photo's current filename instead of
original filename for export. Note: original filename for export. Note:
Starting with Photos 5, all photos are Starting with Photos 5, all photos are
@@ -264,7 +283,7 @@ Options:
**Templating System** **Templating System**
With the --directory option, you may specify a template for the export With the --directory option you may specify a template for the export
directory. This directory will be appended to the export path specified in directory. This directory will be appended to the export path specified in
the export DEST argument to export. For example, if template is the export DEST argument to export. For example, if template is
'{created.year}/{created.month}', and export desitnation DEST is '{created.year}/{created.month}', and export desitnation DEST is
@@ -272,6 +291,11 @@ the export DEST argument to export. For example, if template is
be '/Users/maria/Pictures/export/2020/March' if the photo was created in March be '/Users/maria/Pictures/export/2020/March' if the photo was created in March
2020. 2020.
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
new keyword in format 'folder/subfolder/album' to preserve the folder/album
structure, you can use --keyword-template "{folder_album}"
In the template, valid template substitutions will be replaced by the In the template, valid template substitutions will be replaced by the
corresponding value from the table below. Invalid substitutions will result corresponding value from the table below. Invalid substitutions will result
in a an error and the script will abort. in a an error and the script will abort.
@@ -296,7 +320,7 @@ I plan to eventually extend the templating system to the exported filename so
you can specify the filename using a template. you can specify the filename using a template.
Substitution Description Substitution Description
{name} Filename of the photo {name} Current filename of the photo
{original_name} Photo's original filename when imported to {original_name} Photo's original filename when imported to
Photos Photos
{title} Title of the photo {title} Title of the photo
@@ -814,6 +838,7 @@ For example, in my library, Photos says I have 19,386 photos and 474 movies. Ho
>>> >>>
``` ```
### PhotoInfo ### PhotoInfo
PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library. PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library.
@@ -958,7 +983,8 @@ Returns True if photo is a panorama, otherwise False.
#### `json()` #### `json()`
Returns a JSON representation of all photo info Returns a JSON representation of all photo info
#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False)` #### `export()`
`export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
Export photo from the Photos library to another destination on disk. Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised). - dest: must be valid destination path as str (or exception raised).
@@ -973,6 +999,8 @@ Export photo from the Photos library to another destination on disk.
- timeout: (int, default=120) timeout in seconds used with use_photos_export - timeout: (int, default=120) timeout in seconds used with use_photos_export
- exiftool: (boolean, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path - exiftool: (boolean, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path
- no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes - no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
- use_albums_as_keywords: (boolean, default = False); if True, will use album names as keywords when exporting metadata with exiftool or sidecar
- use_persons_as_keywords: (boolean, default = False); if True, will use person names as keywords when exporting metadata with exiftool or sidecar
Returns: list of paths to exported files. More than one file could be exported, for example if live_photo=True, both the original imaage and the associated .mov file will be exported Returns: list of paths to exported files. More than one file could be exported, for example if live_photo=True, both the original imaage and the associated .mov file will be exported
@@ -994,6 +1022,26 @@ If overwrite=False and increment=False, export will fail if destination file alr
**Implementation Note**: Because the usual python file copy methods don't preserve all the metadata available on MacOS, export uses `/usr/bin/ditto` to do the copy for export. ditto preserves most metadata such as extended attributes, permissions, ACLs, etc. **Implementation Note**: Because the usual python file copy methods don't preserve all the metadata available on MacOS, export uses `/usr/bin/ditto` to do the copy for export. ditto preserves most metadata such as extended attributes, permissions, ACLs, etc.
#### <a name="rendertemplate">`render_template()`</a>
`render_template(template, none_str = "_", path_sep = None)`
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
- `template`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
- `path_sep`: optional character to use as path separator, default is os.path.sep
Returns a tuple of (rendered, unmatched) where rendered is a list of rendered strings with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. if template contained "{foo}", unmatched would be ["foo"].
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `(["2020/{foo}"],["foo"])`
If you want to include "{" or "}" in the output, use "{{" or "}}"
e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `(["2020/{foo}"],[])`
Some substitutions, notably `album`, `keyword`, and `person` could return multiple values, hence a new string will be return for each possible substitution (hence why a list of rendered strings is returned). For example, a photo in 2 albums: 'Vacation' and 'Family' would result in the following rendered values if template was "{created.year}/{album}" and created.year == 2020: `["2020/Vacation","2020/Family"]`
See [Template Substitutions](#template-substitutions) for additional details.
### AlbumInfo ### AlbumInfo
PhotosDB.album_info and PhotoInfo.album_info return a list of AlbumInfo objects. Each AlbumInfo object represents a single album in the Photos library. PhotosDB.album_info and PhotoInfo.album_info return a list of AlbumInfo objects. Each AlbumInfo object represents a single album in the Photos library.
@@ -1139,26 +1187,9 @@ PostalAddress(street='3700 Wailea Alanui Dr', sub_locality=None, city='Kihei', s
'96753' '96753'
``` ```
### Template Functions ### Template Substitutions
There is a simple template system used by the command line client to specify the output directory using a template. The following are available in `osxphotos.template`.
#### `render_filepath_template(template, photo, none_str="_")`
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
- `template`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
- `photo`: a [PhotoInfo](#photoinfo) object
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
Returns a tuple of (rendered, unmatched) where rendered is a list of rendered strings with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. if template contained "{foo}", unmatched would be ["foo"].
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `(["2020/{foo}"],["foo"])`
If you want to include "{" or "}" in the output, use "{{" or "}}"
e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `(["2020/{foo}"],[])`
Some substitutions, notably `album`, `keyword`, and `person` could return multiple values, hence a new string will be return for each possible substitution (hence why a list of rendered strings is returned). For example, a photo in 2 albums: 'Vacation' and 'Family' would result in the following rendered values if template was "{created.year}/{album}" and created.year == 2020: `["2020/Vacation","2020/Family"]`
The following substitutions are availabe for use with `PhotoInfo.render_template()`
| Substitution | Description | | Substitution | Description |
|--------------|-------------| |--------------|-------------|
@@ -1198,21 +1229,6 @@ Some substitutions, notably `album`, `keyword`, and `person` could return multip
|{person}|Person(s) / face(s) in a photo| |{person}|Person(s) / face(s) in a photo|
#### `DateTimeFormatter(dt)`
Class that provides easy access to formatted datetime values.
- `dt`: a datetime.datetime object
Returnes `DateTimeFormater` class.
Has the following properties:
- `date`: Date in ISO format without timezone, e.g. "2020-03-04"
- `year`: 4-digit year
- `yy`: 2-digit year
- `month`: month name in user's locale
- `mon`: month abbreviation in user's locale
- `mm`: 2-digit month
- `doy`: 3-digit day of year (e.g. Julian day)
### Utility Functions ### Utility Functions
The following functions are located in osxphotos.utils The following functions are located in osxphotos.utils
@@ -1318,7 +1334,7 @@ Testing against "real world" Photos libraries would be especially helpful. If y
My goal is make osxphotos as reliable and comprehensive as possible. The test suite currently has over 400 tests--but there are still some [bugs](https://github.com/RhetTbull/osxphotos/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or incomplete features lurking. If you find bugs please open an [issue](https://github.com/RhetTbull/osxphotos/issues). Notable issues include: My goal is make osxphotos as reliable and comprehensive as possible. The test suite currently has over 400 tests--but there are still some [bugs](https://github.com/RhetTbull/osxphotos/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or incomplete features lurking. If you find bugs please open an [issue](https://github.com/RhetTbull/osxphotos/issues). Notable issues include:
- RAW images imported to Photos with an associated jpeg preview are not handled correctly by osxphotos. osxphotos query and export will operate on the jpeg preview instead of the RAW image as will `PhotoInfo.path`. If the user selects "Use RAW as original" in Photos, the RAW image will be exported or operated on but the jpeg will be ignored. See [Issue #101](https://github.com/RhetTbull/osxphotos/issues/101) Note: Alpha version of fix for this bug is implemented in the current version of osxphotos. - RAW images imported to Photos with an associated jpeg preview are not handled correctly by osxphotos. osxphotos query and export will operate on the jpeg preview instead of the RAW image as will `PhotoInfo.path`. If the user selects "Use RAW as original" in Photos, the RAW image will be exported or operated on but the jpeg will be ignored. See [Issue #101](https://github.com/RhetTbull/osxphotos/issues/101) Note: Beta version of fix for this bug is implemented in the current version of osxphotos.
- The `--download-missing` option for `osxphotos export` does not work correctly with burst images. It will download the primary image but not the other burst images. See [Issue #75](https://github.com/RhetTbull/osxphotos/issues/75) - The `--download-missing` option for `osxphotos export` does not work correctly with burst images. It will download the primary image but not the other burst images. See [Issue #75](https://github.com/RhetTbull/osxphotos/issues/75)
## Implementation Notes ## Implementation Notes

View File

@@ -21,11 +21,7 @@ import osxphotos
from ._constants import _EXIF_TOOL_URL, _PHOTOS_4_VERSION, _UNKNOWN_PLACE from ._constants import _EXIF_TOOL_URL, _PHOTOS_4_VERSION, _UNKNOWN_PLACE
from ._version import __version__ from ._version import __version__
from .exiftool import get_exiftool_path from .exiftool import get_exiftool_path
from .template import ( from .template import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
render_filepath_template,
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
)
from .utils import _copy_file, create_path_by_date from .utils import _copy_file, create_path_by_date
@@ -83,7 +79,7 @@ class ExportCommand(click.Command):
formatter.write_text("**Templating System**") formatter.write_text("**Templating System**")
formatter.write("\n") formatter.write("\n")
formatter.write_text( formatter.write_text(
"With the --directory option, you may specify a template for the " "With the --directory option you may specify a template for the "
+ "export directory. This directory will be appended to the export path specified " + "export directory. This directory will be appended to the export path specified "
+ "in the export DEST argument to export. For example, if template is " + "in the export DEST argument to export. For example, if template is "
+ "'{created.year}/{created.month}', and export desitnation DEST is " + "'{created.year}/{created.month}', and export desitnation DEST is "
@@ -92,6 +88,13 @@ class ExportCommand(click.Command):
+ "if the photo was created in March 2020. " + "if the photo was created in March 2020. "
) )
formatter.write("\n") formatter.write("\n")
formatter.write_text(
"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 new keyword in format 'folder/subfolder/album' to "
+ 'preserve the folder/album structure, you can use --keyword-template "{folder_album}"'
)
formatter.write("\n")
formatter.write_text( formatter.write_text(
"In the template, valid template substitutions will be replaced by " "In the template, valid template substitutions will be replaced by "
+ "the corresponding value from the table below. Invalid substitutions will result in a " + "the corresponding value from the table below. Invalid substitutions will result in a "
@@ -893,6 +896,30 @@ def query(
"Note: this does not skip RAW photos if the RAW photo does not have an associated jpeg image " "Note: this does not skip RAW photos if the RAW photo does not have an associated jpeg image "
"(e.g. the RAW file was imported to Photos without a jpeg preview).", "(e.g. the RAW file was imported to Photos without a jpeg preview).",
) )
@click.option(
"--person-keyword",
is_flag=True,
help="Use person in image as keyword/tag when exporting metadata.",
)
@click.option(
"--album-keyword",
is_flag=True,
help="Use album name as keyword/tag when exporting metadata.",
)
@click.option(
"--keyword-template",
metavar="TEMPLATE",
multiple=True,
default=None,
help="For use with --exiftool, --sidecar; specify a template string to use as "
"keyword in the form '{name,DEFAULT}' "
"This is the same format as --directory. For example, if you wanted to add "
"the full path to the folder and album photo is contained in as a keyword when exporting "
'you could specify --keyword-template "{folder_album}" '
'You may specify more than one template, for example --keyword-template "{folder_album}" '
'--keyword-template "{created.year}" '
"See Templating System below.",
)
@click.option( @click.option(
"--current-name", "--current-name",
is_flag=True, is_flag=True,
@@ -983,6 +1010,9 @@ def export(
skip_bursts, skip_bursts,
skip_live, skip_live,
skip_raw, skip_raw,
person_keyword,
album_keyword,
keyword_template,
current_name, current_name,
sidecar, sidecar,
only_photos, only_photos,
@@ -1174,6 +1204,9 @@ def export(
directory, directory,
no_extended_attributes, no_extended_attributes,
export_raw, export_raw,
album_keyword,
person_keyword,
keyword_template,
) )
else: else:
for p in photos: for p in photos:
@@ -1192,6 +1225,9 @@ def export(
directory, directory,
no_extended_attributes, no_extended_attributes,
export_raw, export_raw,
album_keyword,
person_keyword,
keyword_template,
) )
if export_paths: if export_paths:
click.echo(f"Exported {p.filename} to {export_paths}") click.echo(f"Exported {p.filename} to {export_paths}")
@@ -1578,6 +1614,9 @@ def export_photo(
directory, directory,
no_extended_attributes, no_extended_attributes,
export_raw, export_raw,
album_keyword,
person_keyword,
keyword_template,
): ):
""" Helper function for export that does the actual export """ Helper function for export that does the actual export
photo: PhotoInfo object photo: PhotoInfo object
@@ -1594,6 +1633,9 @@ def export_photo(
directory: template used to determine output directory directory: template used to determine output directory
no_extended_attributes: boolean; if True, exports photo without preserving extended attributes no_extended_attributes: boolean; if True, exports photo without preserving extended attributes
export_raw: boolean; if True exports RAW image associate with the photo export_raw: boolean; if True exports RAW image associate with the photo
album_keyword: boolean; if True, exports album names as keywords in metadata
person_keyword: boolean; if True, exports person names as keywords in metadata
keyword_template: list of strings; if provided use rendered template strings as keywords
returns list of path(s) of exported photo or None if photo was missing returns list of path(s) of exported photo or None if photo was missing
""" """
@@ -1634,7 +1676,7 @@ def export_photo(
dest_paths = [dest_path] dest_paths = [dest_path]
elif directory: elif directory:
# got a directory template, render it and check results are valid # got a directory template, render it and check results are valid
dirnames, unmatched = render_filepath_template(directory, photo) dirnames, unmatched = photo.render_template(directory)
if unmatched: if unmatched:
raise click.BadOptionUsage( raise click.BadOptionUsage(
"directory", "directory",
@@ -1677,6 +1719,9 @@ def export_photo(
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
exiftool=exiftool, exiftool=exiftool,
no_xattr=no_extended_attributes, no_xattr=no_extended_attributes,
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
)[0] )[0]
photo_paths.append(photo_path) photo_paths.append(photo_path)
@@ -1712,6 +1757,9 @@ def export_photo(
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
exiftool=exiftool, exiftool=exiftool,
no_xattr=no_extended_attributes, no_xattr=no_extended_attributes,
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
) )
return photo_paths return photo_paths

View File

@@ -51,3 +51,12 @@ _PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass _PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums" _PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder" _PHOTOS_4_ROOT_FOLDER = "LibraryFolder"
# EXIF related constants
# max keyword length for IPTC:Keyword, reference
# https://www.iptc.org/std/photometadata/documentation/userguide/
_MAX_IPTC_KEYWORD_LEN = 64
# Sentinel value for detecting if a template in keyword_template doesn't match
# If anyone has a keyword matching this, then too bad...
_OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.28.5" __version__ = "0.28.13"

View File

@@ -0,0 +1,52 @@
""" Simple formatting of datetime.datetime objects """
import datetime
class DateTimeFormatter:
""" provides property access to formatted datetime.datetime strftime values """
def __init__(self, dt: datetime.datetime):
self.dt = dt
@property
def date(self):
""" ISO date in form 2020-03-22 """
date = self.dt.date().isoformat()
return date
@property
def year(self):
""" 4 digit year """
year = f"{self.dt.year}"
return year
@property
def yy(self):
""" 2 digit year """
yy = f"{self.dt.strftime('%y')}"
return yy
@property
def mm(self):
""" 2 digit month """
mm = f"{self.dt.strftime('%m')}"
return mm
@property
def month(self):
""" Month as locale's full name """
month = f"{self.dt.strftime('%B')}"
return month
@property
def mon(self):
""" Month as locale's abbreviated name """
mon = f"{self.dt.strftime('%b')}"
return mon
@property
def doy(self):
""" Julian day of year starting from 001 """
doy = f"{self.dt.strftime('%j')}"
return doy

View File

@@ -7,6 +7,7 @@ PhotosDB.photos() returns a list of PhotoInfo objects
import glob import glob
import json import json
import logging import logging
import os
import os.path import os.path
import pathlib import pathlib
import re import re
@@ -19,16 +20,25 @@ import yaml
from mako.template import Template from mako.template import Template
from ._constants import ( from ._constants import (
_MAX_IPTC_KEYWORD_LEN,
_MOVIE_TYPE, _MOVIE_TYPE,
_OSXPHOTOS_NONE_SENTINEL,
_PHOTO_TYPE, _PHOTO_TYPE,
_PHOTOS_4_VERSION, _PHOTOS_4_VERSION,
_PHOTOS_5_SHARED_PHOTO_PATH, _PHOTOS_5_SHARED_PHOTO_PATH,
_TEMPLATE_DIR, _TEMPLATE_DIR,
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME, _XMP_TEMPLATE_NAME,
) )
from .albuminfo import AlbumInfo
from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifTool from .exiftool import ExifTool
from .placeinfo import PlaceInfo4, PlaceInfo5 from .placeinfo import PlaceInfo4, PlaceInfo5
from .albuminfo import AlbumInfo from .template import (
MULTI_VALUE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
)
from .utils import ( from .utils import (
_copy_file, _copy_file,
_export_photo_uuid_applescript, _export_photo_uuid_applescript,
@@ -304,9 +314,7 @@ class PhotoInfo:
# Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library # Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library
# that are missing do not always trigger is_missing = True as happens # that are missing do not always trigger is_missing = True as happens
# in earlier version so it's possible for this check to fail, if so, return None # in earlier version so it's possible for this check to fail, if so, return None
logging.debug( logging.debug(f"Error getting path to RAW file: {filepath}/{glob_str}")
f"Error getting path to RAW file: {filepath}/{glob_str}"
)
photopath = None photopath = None
else: else:
photopath = os.path.join(filepath, raw_file[0]) photopath = os.path.join(filepath, raw_file[0])
@@ -635,6 +643,9 @@ class PhotoInfo:
timeout=120, timeout=120,
exiftool=False, exiftool=False,
no_xattr=False, no_xattr=False,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
): ):
""" export photo """ export photo
dest: must be valid destination path (or exception raised) dest: must be valid destination path (or exception raised)
@@ -660,7 +671,13 @@ class PhotoInfo:
timeout: (int, default=120) timeout in seconds used with use_photos_export timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
returns list of full paths to the exported files """ returns list of full paths to the exported files
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
"""
# list of all files exported during this call to export # list of all files exported during this call to export
exported_files = [] exported_files = []
@@ -864,7 +881,11 @@ class PhotoInfo:
if sidecar_json: if sidecar_json:
logging.debug("writing exiftool_json_sidecar") logging.debug("writing exiftool_json_sidecar")
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.json") sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.json")
sidecar_str = self._exiftool_json_sidecar() sidecar_str = self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
)
try: try:
self._write_sidecar(sidecar_filename, sidecar_str) self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e: except Exception as e:
@@ -874,7 +895,11 @@ class PhotoInfo:
if sidecar_xmp: if sidecar_xmp:
logging.debug("writing xmp_sidecar") logging.debug("writing xmp_sidecar")
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.xmp") sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.xmp")
sidecar_str = self._xmp_sidecar() sidecar_str = self._xmp_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
)
try: try:
self._write_sidecar(sidecar_filename, sidecar_str) self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e: except Exception as e:
@@ -884,17 +909,367 @@ class PhotoInfo:
# if exiftool, write the metadata # if exiftool, write the metadata
if exiftool and exported_files: if exiftool and exported_files:
for exported_file in exported_files: for exported_file in exported_files:
self._write_exif_data(exported_file) self._write_exif_data(
exported_file,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
)
return exported_files return exported_files
def _write_exif_data(self, filepath): def render_template(self, template, none_str="_", path_sep=None):
""" render a filename or directory template
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 """
if path_sep is None:
path_sep = os.path.sep
elif path_sep is not None and len(path_sep) != 1:
raise ValueError(f"path_sep must be single character: {path_sep}")
# the rendering happens in two phases:
# phase 1: handle all the single-value template substitutions
# results in a single string with all the template fields replaced
# phase 2: loop through all the multi-value template substitutions
# could result in multiple strings
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
# there would be 6 possible renderings (2 albums x 3 persons)
# 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\-. ]+))?)(?=\}(?!\}))\}"
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
def make_subst_function(self, none_str, get_func=self.get_template_value):
""" returns: substitution function for use in re.sub
photo: a PhotoInfo object
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
# closure to capture photo, none_str in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 4:
try:
val = get_func(matchobj.group(1))
except KeyError:
return matchobj.group(0)
if val is None:
return (
matchobj.group(3)
if matchobj.group(3) is not None
else none_str
)
else:
return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
return subst
subst_func = make_subst_function(self, none_str)
# do the replacements
rendered = re.sub(regex, subst_func, template)
# do multi-valued placements
# start with the single string from phase 1 above then loop through all
# multi-valued fields and all values for each of those fields
# rendered_strings will be updated as each field is processed
# for example: if two albums, two keywords, and one person and template is:
# "{created.year}/{album}/{keyword}/{person}"
# rendered strings would do the following:
# start (created.year filled in phase 1)
# ['2011/{album}/{keyword}/{person}']
# after processing albums:
# ['2011/Album1/{keyword}/{person}',
# '2011/Album2/{keyword}/{person}',]
# after processing keywords:
# ['2011/Album1/keyword1/{person}',
# '2011/Album1/keyword2/{person}',
# '2011/Album2/keyword1/{person}',
# '2011/Album2/keyword2/{person}',]
# after processing person:
# ['2011/Album1/keyword1/person1',
# '2011/Album1/keyword2/person1',
# '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',]
rendered_strings = set([rendered])
for field in MULTI_VALUE_SUBSTITUTIONS:
if field == "album":
values = self.albums
elif field == "keyword":
values = self.keywords
elif field == "person":
values = self.persons
# remove any _UNKNOWN_PERSON values
values = [val for val in values if val != _UNKNOWN_PERSON]
elif field == "folder_album":
values = []
# photos must be in an album to be in a folder
for album in self.album_info:
if album.folder_names:
# album in folder
folder = path_sep.join(album.folder_names)
folder += path_sep + album.title
values.append(folder)
else:
# album not in folder
values.append(album.title)
else:
raise ValueError(f"Unhandleded template value: {field}")
# If no values, insert None so code below will substite none_str for None
values = values or [None]
# Build a regex that matches only the field being processed
re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}"
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, set() to avoid duplicates
new_strings = set()
for str_template in rendered_strings:
for val in values:
def get_template_value_multi(lookup_value):
""" 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 """
if lookup_value == field:
return val
else:
raise KeyError(f"Unexpected value: {lookup_value}")
subst = make_subst_function(
self, none_str, get_func=get_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
new_strings.add(new_string)
# update rendered_strings for the next field to process
rendered_strings = new_strings
# find any {fields} that weren't replaced
unmatched = []
for rendered_str in rendered_strings:
unmatched.extend(
[
no_match[0]
for no_match in re.findall(regex, rendered_str)
if no_match[0] not in unmatched
]
)
# fix any escaped curly braces
rendered_strings = [
rendered_str.replace("{{", "{").replace("}}", "}")
for rendered_str in rendered_strings
]
return rendered_strings, unmatched
def get_template_value(self, lookup):
""" lookup template value (single-value template substitutions) for use in make_subst_function
lookup: value to find a match for
returns: either the matching template value (which may be None)
raises: KeyError if no rule exists for lookup """
# must be a valid keyword
if lookup == "name":
return pathlib.Path(self.filename).stem
if lookup == "original_name":
return pathlib.Path(self.original_filename).stem
if lookup == "title":
return self.title
if lookup == "descr":
return self.description
if lookup == "created.date":
return DateTimeFormatter(self.date).date
if lookup == "created.year":
return DateTimeFormatter(self.date).year
if lookup == "created.yy":
return DateTimeFormatter(self.date).yy
if lookup == "created.mm":
return DateTimeFormatter(self.date).mm
if lookup == "created.month":
return DateTimeFormatter(self.date).month
if lookup == "created.mon":
return DateTimeFormatter(self.date).mon
if lookup == "created.doy":
return DateTimeFormatter(self.date).doy
if lookup == "modified.date":
return (
DateTimeFormatter(self.date_modified).date
if self.date_modified
else None
)
if lookup == "modified.year":
return (
DateTimeFormatter(self.date_modified).year
if self.date_modified
else None
)
if lookup == "modified.yy":
return (
DateTimeFormatter(self.date_modified).yy if self.date_modified else None
)
if lookup == "modified.mm":
return (
DateTimeFormatter(self.date_modified).mm if self.date_modified else None
)
if lookup == "modified.month":
return (
DateTimeFormatter(self.date_modified).month
if self.date_modified
else None
)
if lookup == "modified.mon":
return (
DateTimeFormatter(self.date_modified).mon
if self.date_modified
else None
)
if lookup == "modified.doy":
return (
DateTimeFormatter(self.date_modified).doy
if self.date_modified
else None
)
if lookup == "place.name":
return self.place.name if self.place else None
if lookup == "place.country_code":
return self.place.country_code if self.place else None
if lookup == "place.name.country":
return (
self.place.names.country[0]
if self.place and self.place.names.country
else None
)
if lookup == "place.name.state_province":
return (
self.place.names.state_province[0]
if self.place and self.place.names.state_province
else None
)
if lookup == "place.name.city":
return (
self.place.names.city[0]
if self.place and self.place.names.city
else None
)
if lookup == "place.name.area_of_interest":
return (
self.place.names.area_of_interest[0]
if self.place and self.place.names.area_of_interest
else None
)
if lookup == "place.address":
return (
self.place.address_str
if self.place and self.place.address_str
else None
)
if lookup == "place.address.street":
return (
self.place.address.street
if self.place and self.place.address.street
else None
)
if lookup == "place.address.city":
return (
self.place.address.city
if self.place and self.place.address.city
else None
)
if lookup == "place.address.state_province":
return (
self.place.address.state_province
if self.place and self.place.address.state_province
else None
)
if lookup == "place.address.postal_code":
return (
self.place.address.postal_code
if self.place and self.place.address.postal_code
else None
)
if lookup == "place.address.country":
return (
self.place.address.country
if self.place and self.place.address.country
else None
)
if lookup == "place.address.country_code":
return (
self.place.address.iso_country_code
if self.place and self.place.address.iso_country_code
else None
)
# if here, didn't get a match
raise KeyError(f"No rule for processing {lookup}")
def _write_exif_data(
self,
filepath,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
):
""" write exif data to image file at filepath """ write exif data to image file at filepath
filepath: full path to the image file """ filepath: full path to the image file """
if not os.path.exists(filepath): if not os.path.exists(filepath):
raise FileNotFoundError(f"Could not find file {filepath}") raise FileNotFoundError(f"Could not find file {filepath}")
exiftool = ExifTool(filepath) exiftool = ExifTool(filepath)
exif_info = json.loads(self._exiftool_json_sidecar())[0] exif_info = json.loads(
self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
)
)[0]
for exiftag, val in exif_info.items(): for exiftag, val in exif_info.items():
if type(val) == list: if type(val) == list:
# more than one, set first value the add additional values # more than one, set first value the add additional values
@@ -905,16 +1280,24 @@ class PhotoInfo:
else: else:
exiftool.setvalue(exiftag, val) exiftool.setvalue(exiftag, val)
def _exiftool_json_sidecar(self): def _exiftool_json_sidecar(
self,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
):
""" return json string of EXIF details in exiftool sidecar format """ return json string of EXIF details in exiftool sidecar format
Does not include all the EXIF fields as those are likely already in the image Does not include all the EXIF fields as those are likely already in the image
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
Exports the following: Exports the following:
FileName FileName
ImageDescription ImageDescription
Description Description
Title Title
TagsList TagsList
Keywords Keywords (may include album name, person name, or template)
Subject Subject
PersonInImage PersonInImage
GPSLatitude, GPSLongitude GPSLatitude, GPSLongitude
@@ -934,18 +1317,64 @@ class PhotoInfo:
if self.title: if self.title:
exif["XMP:Title"] = self.title exif["XMP:Title"] = self.title
keyword_list = []
if self.keywords: if self.keywords:
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = list(self.keywords) keyword_list.extend(self.keywords)
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
exif["XMP:Subject"] = list(self.keywords)
person_list = []
if self.persons: if self.persons:
exif["XMP:PersonInImage"] = self.persons # filter out _UNKNOWN_PERSON
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
if use_persons_as_keywords and person_list:
keyword_list.extend(person_list)
if use_albums_as_keywords and self.albums:
keyword_list.extend(self.albums)
if keyword_template:
rendered_keywords = []
for template_str in keyword_template:
rendered, unmatched = self.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
if unmatched:
logging.warning(
f"Unmatched template substitution for template: {template_str} {unmatched}"
)
rendered_keywords.extend(rendered)
# filter out any template values that didn't match by looking for sentinel
rendered_keywords = [
keyword
for keyword in rendered_keywords
if _OSXPHOTOS_NONE_SENTINEL not in keyword
]
# check to see if any keywords too long
long_keywords = [
long_str
for long_str in rendered_keywords
if len(long_str) > _MAX_IPTC_KEYWORD_LEN
]
if long_keywords:
logging.warning(
f"Some keywords exceed max IPTC Keyword length of {_MAX_IPTC_KEYWORD_LEN}: {long_keywords}"
)
logging.debug(f"rendered_keywords: {rendered_keywords}")
keyword_list.extend(rendered_keywords)
if keyword_list:
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = keyword_list
if person_list:
exif["XMP:PersonInImage"] = person_list
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP" # Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
if "XMP:Subject" in exif: # only use Photos' keywords for subject
exif["XMP:Subject"].extend(self.persons) exif["XMP:Subject"] = list(self.keywords) + person_list
else:
exif["XMP:Subject"] = self.persons
# if self.favorite(): # if self.favorite():
# exif["Rating"] = 5 # exif["Rating"] = 5
@@ -979,14 +1408,86 @@ class PhotoInfo:
json_str = json.dumps([exif]) json_str = json.dumps([exif])
return json_str return json_str
def _xmp_sidecar(self): def _xmp_sidecar(
""" returns string for XMP sidecar """ self,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
):
""" returns string for XMP sidecar
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords """
# TODO: add additional fields to XMP file? # TODO: add additional fields to XMP file?
xmp_template = Template( xmp_template = Template(
filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME) filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME)
) )
xmp_str = xmp_template.render(photo=self)
keyword_list = []
if self.keywords:
keyword_list.extend(self.keywords)
# TODO: keyword handling in this and _exiftool_json_sidecar is
# good candidate for pulling out in a function
person_list = []
if self.persons:
# filter out _UNKNOWN_PERSON
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
if use_persons_as_keywords and person_list:
keyword_list.extend(person_list)
if use_albums_as_keywords and self.albums:
keyword_list.extend(self.albums)
if keyword_template:
rendered_keywords = []
for template_str in keyword_template:
rendered, unmatched = self.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
if unmatched:
logging.warning(
f"Unmatched template substitution for template: {template_str} {unmatched}"
)
rendered_keywords.extend(rendered)
# filter out any template values that didn't match by looking for sentinel
rendered_keywords = [
keyword
for keyword in rendered_keywords
if _OSXPHOTOS_NONE_SENTINEL not in keyword
]
# check to see if any keywords too long
long_keywords = [
long_str
for long_str in rendered_keywords
if len(long_str) > _MAX_IPTC_KEYWORD_LEN
]
if long_keywords:
logging.warning(
f"Some keywords exceed max IPTC Keyword length of {_MAX_IPTC_KEYWORD_LEN}: {long_keywords}"
)
logging.debug(f"rendered_keywords: {rendered_keywords}")
keyword_list.extend(rendered_keywords)
subject_list = []
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
subject_list = list(self.keywords) + person_list
xmp_str = xmp_template.render(
photo=self,
keywords=keyword_list,
persons=person_list,
subjects=subject_list,
)
# remove extra lines that mako inserts from template # remove extra lines that mako inserts from template
xmp_str = "\n".join( xmp_str = "\n".join(
[line for line in xmp_str.split("\n") if line.strip() != ""] [line for line in xmp_str.split("\n") if line.strip() != ""]

View File

@@ -19,17 +19,18 @@ from ._constants import (
_MOVIE_TYPE, _MOVIE_TYPE,
_PHOTO_TYPE, _PHOTO_TYPE,
_PHOTOS_3_VERSION, _PHOTOS_3_VERSION,
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_TOP_LEVEL_ALBUM,
_PHOTOS_4_VERSION, _PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_FOLDER_KIND,
_PHOTOS_5_ROOT_FOLDER_KIND,
_PHOTOS_5_SHARED_ALBUM_KIND,
_PHOTOS_5_VERSION, _PHOTOS_5_VERSION,
_TESTED_DB_VERSIONS, _TESTED_DB_VERSIONS,
_TESTED_OS_VERSIONS, _TESTED_OS_VERSIONS,
_UNKNOWN_PERSON, _UNKNOWN_PERSON,
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_TOP_LEVEL_ALBUM,
_PHOTOS_5_ROOT_FOLDER_KIND,
_PHOTOS_5_FOLDER_KIND,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_SHARED_ALBUM_KIND,
) )
from ._version import __version__ from ._version import __version__
from .albuminfo import AlbumInfo, FolderInfo from .albuminfo import AlbumInfo, FolderInfo
@@ -77,6 +78,12 @@ class PhotosDB:
# set up the data structures used to store all the Photo database info # set up the data structures used to store all the Photo database info
# if True, will treat persons as keywords when exporting metadata
self.use_persons_as_keywords = False
# if True, will treat albums as keywords when exporting metadata
self.use_albums_as_keywords = False
# Path to the Photos library database file # Path to the Photos library database file
# photos.db in the photos library database/ directory # photos.db in the photos library database/ directory
self._dbfile = None self._dbfile = None
@@ -655,8 +662,11 @@ class PhotosDB:
# build folder hierarchy # build folder hierarchy
for album, details in self._dbalbum_details.items(): for album, details in self._dbalbum_details.items():
parent_folder = details["folderUuid"] parent_folder = details["folderUuid"]
if parent_folder != _PHOTOS_4_TOP_LEVEL_ALBUM: if details[
# logging.warning(f"album = {details['title']}, parent = {parent_folder}") "albumSubclass"
] == _PHOTOS_4_ALBUM_KIND and parent_folder not in [
_PHOTOS_4_TOP_LEVEL_ALBUM
]:
folder_hierarchy = self._build_album_folder_hierarchy_4(parent_folder) folder_hierarchy = self._build_album_folder_hierarchy_4(parent_folder)
self._dbalbum_folders[album] = folder_hierarchy self._dbalbum_folders[album] = folder_hierarchy
else: else:
@@ -1214,17 +1224,24 @@ class PhotosDB:
def _build_album_folder_hierarchy_4(self, uuid, folders=None): def _build_album_folder_hierarchy_4(self, uuid, folders=None):
""" recursively build folder/album hierarchy """ recursively build folder/album hierarchy
uuid: uuid of the album/folder being processed uuid: parent uuid of the album being processed
folders: dict holding the folder hierarchy """ (parent uuid is a folder in RKFolders)
folders: dict holding the folder hierarchy
NOTE: This implementation is different than _build_album_folder_hierarchy_5
which takes the uuid of the album being processed. Here uuid is the parent uuid
of the parent folder album because in Photos <=4, folders are in RKFolders and
albums in RKAlbums. In Photos 5, folders are just special albums
with kind = _PHOTOS_5_FOLDER_KIND """
parent_uuid = self._dbfolder_details[uuid]["parentFolderUuid"] parent_uuid = self._dbfolder_details[uuid]["parentFolderUuid"]
# logging.warning(f"uuid = {uuid}, parent = {parent_uuid}, folders = {folders}")
if parent_uuid is None: if parent_uuid is None:
return folders return folders
if parent_uuid == _PHOTOS_4_TOP_LEVEL_ALBUM: if parent_uuid == _PHOTOS_4_TOP_LEVEL_ALBUM:
if not folders:
# this is a top-level folder with no sub-folders
folders = {uuid: None}
# at top of hierarchy, we're done # at top of hierarchy, we're done
return folders return folders
@@ -1607,8 +1624,8 @@ class PhotosDB:
info["momentID"] = row[26] info["momentID"] = row[26]
# original resource choice (e.g. RAW or jpeg) # original resource choice (e.g. RAW or jpeg)
# for images part of a RAW/jpeg pair, # for images part of a RAW/jpeg pair,
# ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE # ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE
# = 0 if jpeg is selected as "original" in Photos (the default) # = 0 if jpeg is selected as "original" in Photos (the default)
# = 1 if RAW is selected as "original" in Photos # = 1 if RAW is selected as "original" in Photos
info["original_resource_choice"] = row[27] info["original_resource_choice"] = row[27]

View File

@@ -1,4 +1,4 @@
""" Custom template system for osxphotos """ """ Custom template system for osxphotos (implemented in PhotoInfo.render_template) """
# Rolled my own template system because: # Rolled my own template system because:
# 1. Needed to handle multiple values (e.g. album, keyword) # 1. Needed to handle multiple values (e.g. album, keyword)
@@ -9,18 +9,14 @@
# #
# This code isn't elegant but it seems to work well. PRs gladly accepted. # This code isn't elegant but it seems to work well. PRs gladly accepted.
import datetime import locale
import os
import pathlib
import re
from typing import Tuple, List # pylint: disable=syntax-error
from .photoinfo import PhotoInfo # ensure locale set to user's locale
from ._constants import _UNKNOWN_PERSON locale.setlocale(locale.LC_ALL, "")
# Permitted substitutions (each of these returns a single value or None) # Permitted substitutions (each of these returns a single value or None)
TEMPLATE_SUBSTITUTIONS = { TEMPLATE_SUBSTITUTIONS = {
"{name}": "Filename of the photo", "{name}": "Current filename of the photo",
"{original_name}": "Photo's original filename when imported to Photos", "{original_name}": "Photo's original filename when imported to Photos",
"{title}": "Title of the photo", "{title}": "Title of the photo",
"{descr}": "Description of the photo", "{descr}": "Description of the photo",
@@ -66,374 +62,3 @@ MULTI_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "") field.replace("{", "").replace("}", "")
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.keys() for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.keys()
] ]
def get_template_value(lookup, photo):
""" lookup template value (single-value template substitutions) for use in make_subst_function
lookup: value to find a match for
photo: PhotoInfo object whose data will be used for value substitutions
returns: either the matching template value (which may be None)
raises: KeyError if no rule exists for lookup """
# must be a valid keyword
if lookup == "name":
return pathlib.Path(photo.filename).stem
if lookup == "original_name":
return pathlib.Path(photo.original_filename).stem
if lookup == "title":
return photo.title
if lookup == "descr":
return photo.description
if lookup == "created.date":
return DateTimeFormatter(photo.date).date
if lookup == "created.year":
return DateTimeFormatter(photo.date).year
if lookup == "created.yy":
return DateTimeFormatter(photo.date).yy
if lookup == "created.mm":
return DateTimeFormatter(photo.date).mm
if lookup == "created.month":
return DateTimeFormatter(photo.date).month
if lookup == "created.mon":
return DateTimeFormatter(photo.date).mon
if lookup == "created.doy":
return DateTimeFormatter(photo.date).doy
if lookup == "modified.date":
return (
DateTimeFormatter(photo.date_modified).date if photo.date_modified else None
)
if lookup == "modified.year":
return (
DateTimeFormatter(photo.date_modified).year if photo.date_modified else None
)
if lookup == "modified.yy":
return (
DateTimeFormatter(photo.date_modified).yy if photo.date_modified else None
)
if lookup == "modified.mm":
return (
DateTimeFormatter(photo.date_modified).mm if photo.date_modified else None
)
if lookup == "modified.month":
return (
DateTimeFormatter(photo.date_modified).month
if photo.date_modified
else None
)
if lookup == "modified.mon":
return (
DateTimeFormatter(photo.date_modified).mon if photo.date_modified else None
)
if lookup == "modified.doy":
return (
DateTimeFormatter(photo.date_modified).doy if photo.date_modified else None
)
if lookup == "place.name":
return photo.place.name if photo.place else None
if lookup == "place.country_code":
return photo.place.country_code if photo.place else None
if lookup == "place.name.country":
return (
photo.place.names.country[0]
if photo.place and photo.place.names.country
else None
)
if lookup == "place.name.state_province":
return (
photo.place.names.state_province[0]
if photo.place and photo.place.names.state_province
else None
)
if lookup == "place.name.city":
return (
photo.place.names.city[0]
if photo.place and photo.place.names.city
else None
)
if lookup == "place.name.area_of_interest":
return (
photo.place.names.area_of_interest[0]
if photo.place and photo.place.names.area_of_interest
else None
)
if lookup == "place.address":
return (
photo.place.address_str if photo.place and photo.place.address_str else None
)
if lookup == "place.address.street":
return (
photo.place.address.street
if photo.place and photo.place.address.street
else None
)
if lookup == "place.address.city":
return (
photo.place.address.city
if photo.place and photo.place.address.city
else None
)
if lookup == "place.address.state_province":
return (
photo.place.address.state_province
if photo.place and photo.place.address.state_province
else None
)
if lookup == "place.address.postal_code":
return (
photo.place.address.postal_code
if photo.place and photo.place.address.postal_code
else None
)
if lookup == "place.address.country":
return (
photo.place.address.country
if photo.place and photo.place.address.country
else None
)
if lookup == "place.address.country_code":
return (
photo.place.address.iso_country_code
if photo.place and photo.place.address.iso_country_code
else None
)
# if here, didn't get a match
raise KeyError(f"No rule for processing {lookup}")
def render_filepath_template(template, photo, none_str="_"):
""" render a filename or directory template
template: str template
photo: PhotoInfo object
none_str: str to use default for None values, default is '_' """
# the rendering happens in two phases:
# phase 1: handle all the single-value template substitutions
# results in a single string with all the template fields replaced
# phase 2: loop through all the multi-value template substitutions
# could result in multiple strings
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
# there would be 6 possible renderings (2 albums x 3 persons)
# 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\-. ]+))?)(?=\}(?!\}))\}"
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
if type(photo) is not PhotoInfo:
raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}")
def make_subst_function(photo, none_str, get_func=get_template_value):
""" returns: substitution function for use in re.sub
photo: a PhotoInfo object
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
# closure to capture photo, none_str in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 4:
try:
val = get_func(matchobj.group(1), photo)
except KeyError:
return matchobj.group(0)
if val is None:
return (
matchobj.group(3) if matchobj.group(3) is not None else none_str
)
else:
return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
return subst
subst_func = make_subst_function(photo, none_str)
# do the replacements
rendered = re.sub(regex, subst_func, template)
# do multi-valued placements
# start with the single string from phase 1 above then loop through all
# multi-valued fields and all values for each of those fields
# rendered_strings will be updated as each field is processed
# for example: if two albums, two keywords, and one person and template is:
# "{created.year}/{album}/{keyword}/{person}"
# rendered strings would do the following:
# start (created.year filled in phase 1)
# ['2011/{album}/{keyword}/{person}']
# after processing albums:
# ['2011/Album1/{keyword}/{person}',
# '2011/Album2/{keyword}/{person}',]
# after processing keywords:
# ['2011/Album1/keyword1/{person}',
# '2011/Album1/keyword2/{person}',
# '2011/Album2/keyword1/{person}',
# '2011/Album2/keyword2/{person}',]
# after processing person:
# ['2011/Album1/keyword1/person1',
# '2011/Album1/keyword2/person1',
# '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',]
rendered_strings = set([rendered])
for field in MULTI_VALUE_SUBSTITUTIONS:
if field == "album":
values = photo.albums
elif field == "keyword":
values = photo.keywords
elif field == "person":
values = photo.persons
# remove any _UNKNOWN_PERSON values
values = [val for val in values if val != _UNKNOWN_PERSON]
elif field == "folder_album":
values = []
# photos must be in an album to be in a folder
for album in photo.album_info:
if album.folder_names:
# album in folder
folder = os.path.sep.join(album.folder_names)
folder += os.path.sep + album.title
values.append(folder)
else:
# album not in folder
values.append(album.title)
else:
raise ValueError(f"Unhandleded template value: {field}")
# If no values, insert None so code below will substite none_str for None
values = values or [None]
# Build a regex that matches only the field being processed
re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}"
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, set() to avoid duplicates
new_strings = set()
for str_template in rendered_strings:
for val in values:
def get_template_value_multi(lookup_value, photo):
""" 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 """
if lookup_value == field:
return val
else:
raise KeyError(f"Unexpected value: {lookup_value}")
subst = make_subst_function(
photo, none_str, get_func=get_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
new_strings.add(new_string)
# update rendered_strings for the next field to process
rendered_strings = new_strings
# find any {fields} that weren't replaced
unmatched = []
for rendered_str in rendered_strings:
unmatched.extend(
[
no_match[0]
for no_match in re.findall(regex, rendered_str)
if no_match[0] not in unmatched
]
)
# fix any escaped curly braces
rendered_strings = [
rendered_str.replace("{{", "{").replace("}}", "}")
for rendered_str in rendered_strings
]
return rendered_strings, unmatched
class DateTimeFormatter:
""" provides property access to formatted datetime.datetime strftime values """
def __init__(self, dt: datetime.datetime):
self.dt = dt
@property
def date(self):
""" ISO date in form 2020-03-22 """
date = self.dt.date().isoformat()
return date
@property
def year(self):
""" 4 digit year """
year = f"{self.dt.year}"
return year
@property
def yy(self):
""" 2 digit year """
yy = f"{self.dt.strftime('%y')}"
return yy
@property
def mm(self):
""" 2 digit month """
mm = f"{self.dt.strftime('%m')}"
return mm
@property
def month(self):
""" Month as locale's full name """
month = f"{self.dt.strftime('%B')}"
return month
@property
def mon(self):
""" Month as locale's abbreviated name """
mon = f"{self.dt.strftime('%b')}"
return mon
@property
def doy(self):
""" Julian day of year starting from 001 """
doy = f"{self.dt.strftime('%j')}"
return doy

View File

@@ -79,16 +79,16 @@
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"> xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
${dc_description(photo.description)} ${dc_description(photo.description)}
${dc_title(photo.title)} ${dc_title(photo.title)}
${dc_subject(photo.keywords + photo.persons)} ${dc_subject(subjects)}
${dc_datecreated(photo.date)} ${dc_datecreated(photo.date)}
</rdf:Description> </rdf:Description>
<rdf:Description rdf:about='' <rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'> xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
${iptc_personinimage(photo.persons)} ${iptc_personinimage(persons)}
</rdf:Description> </rdf:Description>
<rdf:Description rdf:about='' <rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'> xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
${dk_tagslist(photo.keywords)} ${dk_tagslist(keywords)}
</rdf:Description> </rdf:Description>
<rdf:Description rdf:about='' <rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'> xmlns:xmp='http://ns.adobe.com/xap/1.0/'>

View File

@@ -9,6 +9,13 @@ To run the tests, do the following from the main source folder:
Running the tests this way allows the library to be tested without installing it. Running the tests this way allows the library to be tested without installing it.
## Skipped Tests ##
A few tests will look for certain environment variables to determine if they should run.
Some of the export tests rely on photos in my local library and will look for `OSXPHOTOS_TEST_EXPORT=1` to determine if they should run.
One test for locale does not run on GitHub's automated workflow and will look for `OSXPHOTOS_TEST_LOCALE=1` to determine if it should be run. If you want to run this test, set the environment variable.
## Attribution ## ## Attribution ##
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com). All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/). These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com). All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).

View File

@@ -8,8 +8,11 @@
<array/> <array/>
<key>ExpandedSidebarItemIdentifiers</key> <key>ExpandedSidebarItemIdentifiers</key>
<array> <array>
<string>obfeGcvoT1auxoh2Tu86OQ</string>
<string>TopLevelAlbums</string> <string>TopLevelAlbums</string>
<string>TopLevelSlideshows</string> <string>TopLevelSlideshows</string>
<string>MBS8+gBrQCWQxmcav+C8HQ</string>
<string>cHwwVoUiQ8a2nZNXgVsnCA</string>
</array> </array>
<key>IPXWorkspaceControllerZoomLevelsKey</key> <key>IPXWorkspaceControllerZoomLevelsKey</key>
<dict> <dict>
@@ -23,11 +26,11 @@
<key>key</key> <key>key</key>
<integer>1</integer> <integer>1</integer>
<key>lastKnownDisplayName</key> <key>lastKnownDisplayName</key>
<string>September 28, 2018</string> <string>Pumpkin Farm (1)</string>
<key>type</key> <key>type</key>
<string>album</string> <string>album</string>
<key>uuid</key> <key>uuid</key>
<string>+Ep8CrNRRhea9eVA618FMg</string> <string>AU8Gp8bwRlOvngZFgwXBdg</string>
</dict> </dict>
<key>lastKnownItemCounts</key> <key>lastKnownItemCounts</key>
<dict> <dict>

View File

@@ -3,8 +3,8 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key> <key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-07-28T01:23:52Z</date> <date>2020-04-30T14:09:38Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key> <key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-07-28T01:23:52Z</date> <date>2020-05-01T04:27:48Z</date>
</dict> </dict>
</plist> </plist>

View File

@@ -2,7 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>ProcessedInQuiescentState</key>
<true/>
<key>SuggestedMeIdentifier</key> <key>SuggestedMeIdentifier</key>
<string></string> <string></string>
<key>Version</key>
<integer>3</integer>
</dict> </dict>
</plist> </plist>

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key> <key>LithiumMessageTracer</key>
<dict> <dict>
<key>LastReportedDate</key> <key>LastReportedDate</key>
<date>2019-07-27T12:01:15Z</date> <date>2020-05-01T03:34:50Z</date>
</dict> </dict>
</dict> </dict>
</plist> </plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key> <key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer> <integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key> <key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2019-07-26T20:15:18Z</date> <date>2020-04-30T12:51:41Z</date>
</dict> </dict>
</plist> </plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>LastHistoryRowId</key> <key>LastHistoryRowId</key>
<integer>615</integer> <integer>670</integer>
<key>LibraryBuildTag</key> <key>LibraryBuildTag</key>
<string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string> <string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string>
<key>LibrarySchemaVersion</key> <key>LibrarySchemaVersion</key>

View File

@@ -9,7 +9,7 @@
<key>HistoricalMarker</key> <key>HistoricalMarker</key>
<dict> <dict>
<key>LastHistoryRowId</key> <key>LastHistoryRowId</key>
<integer>615</integer> <integer>670</integer>
<key>LibraryBuildTag</key> <key>LibraryBuildTag</key>
<string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string> <string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string>
<key>LibrarySchemaVersion</key> <key>LibrarySchemaVersion</key>
@@ -24,7 +24,7 @@
<key>SnapshotCompletedDate</key> <key>SnapshotCompletedDate</key>
<date>2019-07-26T20:15:17Z</date> <date>2019-07-26T20:15:17Z</date>
<key>SnapshotLastValidated</key> <key>SnapshotLastValidated</key>
<date>2019-07-27T12:01:15Z</date> <date>2020-05-01T03:34:50Z</date>
<key>SnapshotTables</key> <key>SnapshotTables</key>
<dict/> <dict/>
</dict> </dict>

View File

@@ -3,8 +3,8 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key> <key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-04-18T18:01:02Z</date> <date>2020-04-25T23:54:43Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key> <key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-04-18T17:22:55Z</date> <date>2020-04-26T06:26:10Z</date>
</dict> </dict>
</plist> </plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key> <key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer> <integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key> <key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-04-17T17:49:52Z</date> <date>2020-04-25T23:54:29Z</date>
</dict> </dict>
</plist> </plist>

View File

@@ -24,7 +24,7 @@
<key>SnapshotCompletedDate</key> <key>SnapshotCompletedDate</key>
<date>2019-07-27T13:16:43Z</date> <date>2019-07-27T13:16:43Z</date>
<key>SnapshotLastValidated</key> <key>SnapshotLastValidated</key>
<date>2020-04-17T17:51:16Z</date> <date>2020-04-25T23:56:35Z</date>
<key>SnapshotTables</key> <key>SnapshotTables</key>
<dict/> <dict/>
</dict> </dict>

View File

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

View File

@@ -3,24 +3,24 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>BackgroundHighlightCollection</key> <key>BackgroundHighlightCollection</key>
<date>2020-04-17T14:33:32Z</date> <date>2020-05-01T23:03:12Z</date>
<key>BackgroundHighlightEnrichment</key> <key>BackgroundHighlightEnrichment</key>
<date>2020-04-17T14:33:32Z</date> <date>2020-05-01T23:03:11Z</date>
<key>BackgroundJobAssetRevGeocode</key> <key>BackgroundJobAssetRevGeocode</key>
<date>2020-04-17T14:33:33Z</date> <date>2020-05-02T01:35:19Z</date>
<key>BackgroundJobSearch</key> <key>BackgroundJobSearch</key>
<date>2020-04-17T14:33:33Z</date> <date>2020-05-01T23:03:12Z</date>
<key>BackgroundPeopleSuggestion</key> <key>BackgroundPeopleSuggestion</key>
<date>2020-04-17T14:33:31Z</date> <date>2020-05-01T23:03:11Z</date>
<key>BackgroundUserBehaviorProcessor</key> <key>BackgroundUserBehaviorProcessor</key>
<date>2020-04-17T07:32:04Z</date> <date>2020-05-01T23:03:13Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key> <key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2020-04-17T14:33:37Z</date> <date>2020-05-02T01:35:36Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key> <key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-04-17T07:32:00Z</date> <date>2020-05-01T23:03:11Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key> <key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-04-17T14:33:34Z</date> <date>2020-05-02T01:35:19Z</date>
<key>SiriPortraitDonation</key> <key>SiriPortraitDonation</key>
<date>2020-04-17T07:32:04Z</date> <date>2020-05-01T23:03:13Z</date>
</dict> </dict>
</plist> </plist>

View File

@@ -3,8 +3,8 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>FaceIDModelLastGenerationKey</key> <key>FaceIDModelLastGenerationKey</key>
<date>2020-04-17T07:32:07Z</date> <date>2020-05-01T23:03:14Z</date>
<key>LastContactClassificationKey</key> <key>LastContactClassificationKey</key>
<date>2020-04-17T07:32:12Z</date> <date>2020-05-01T23:03:18Z</date>
</dict> </dict>
</plist> </plist>

View File

@@ -0,0 +1,229 @@
import pytest
from osxphotos._constants import _UNKNOWN_PERSON
PHOTOS_DB = "./tests/Test-10.13.6.photoslibrary/database/photos.db"
TOP_LEVEL_FOLDERS = ["Folder1", "TestFolder"]
TOP_LEVEL_CHILDREN = ["SubFolder1", "SubFolder2"]
FOLDER_ALBUM_DICT = {
"Folder1": [],
"SubFolder1": [],
"SubFolder2": ["AlbumInFolder"],
"TestFolder": ["TestAlbum"],
}
ALBUM_NAMES = ["Pumpkin Farm", "AlbumInFolder", "TestAlbum"]
ALBUM_PARENT_DICT = {
"Pumpkin Farm": None,
"AlbumInFolder": "SubFolder2",
"TestAlbum": "TestFolder",
}
ALBUM_FOLDER_NAMES_DICT = {
"Pumpkin Farm": [],
"AlbumInFolder": ["Folder1", "SubFolder2"],
"TestAlbum": ["TestFolder"],
}
ALBUM_LEN_DICT = {"Pumpkin Farm": 3, "AlbumInFolder": 1, "TestAlbum": 1}
ALBUM_PHOTO_UUID_DICT = {
"Pumpkin Farm": [
"vAZGdUK1QdGfWPgC+KsJag",
"NlY8CklESxGpaKsTVHB3HQ",
"RWmFYiDjSyKjeK8Pfna0Eg",
],
"AlbumInFolder": ["RWmFYiDjSyKjeK8Pfna0Eg"],
"TestAlbum": ["NlY8CklESxGpaKsTVHB3HQ"],
}
######### Test FolderInfo ##########
def test_folders_1():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
folders = photosdb.folders
# top level folders
folders = photosdb.folder_info
assert len(folders) == len(TOP_LEVEL_FOLDERS)
# check folder names
folder_names = [f.title for f in folders]
assert sorted(folder_names) == sorted(TOP_LEVEL_FOLDERS)
def test_folder_names():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# check folder names
folder_names = photosdb.folders
assert folder_names == TOP_LEVEL_FOLDERS
assert sorted(folder_names) == sorted(TOP_LEVEL_FOLDERS)
def test_folders_len():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# top level folders
folders = photosdb.folder_info
assert len(folders[0]) == len(TOP_LEVEL_CHILDREN)
def test_folders_children():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# top level folders
folders = photosdb.folder_info
# children of top level folder
children = folders[0].subfolders
children_names = [f.title for f in children]
assert sorted(children_names) == sorted(TOP_LEVEL_CHILDREN)
for child in folders[0].subfolders:
# check valid children FolderInfo
assert child.parent
assert child.parent.uuid == folders[0].uuid
# check folder names
folder_names = [f.title for f in folders]
assert sorted(folder_names) == sorted(TOP_LEVEL_FOLDERS)
def test_folders_parent():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# top level folders
folders = photosdb.folder_info
# parent of top level folder should be none
for folder in folders:
assert folder.parent is None
for child in folder.subfolders:
# children's parent uuid should match folder uuid
assert child.parent
assert child.parent.uuid == folder.uuid
def test_folders_albums():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# top level folders
folders = photosdb.folder_info
for folder in folders:
name = folder.title
albums = [a.title for a in folder.album_info]
assert sorted(albums) == sorted(FOLDER_ALBUM_DICT[name])
for child in folder.subfolders:
name = child.title
albums = [a.title for a in child.album_info]
assert sorted(albums) == sorted(FOLDER_ALBUM_DICT[name])
########## Test AlbumInfo ##########
def test_albums_1():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.album_info
assert len(albums) == len(ALBUM_NAMES)
# check names
album_names = [a.title for a in albums]
assert sorted(album_names) == sorted(ALBUM_NAMES)
def test_albums_parent():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.album_info
for album in albums:
parent = album.parent.title if album.parent else None
assert parent == ALBUM_PARENT_DICT[album.title]
def test_albums_folder_names():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.album_info
for album in albums:
folder_names = album.folder_names
assert folder_names == ALBUM_FOLDER_NAMES_DICT[album.title]
def test_albums_folders():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.album_info
for album in albums:
folders = album.folder_list
folder_names = [f.title for f in folders]
assert folder_names == ALBUM_FOLDER_NAMES_DICT[album.title]
def test_albums_len():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.album_info
for album in albums:
assert len(album) == ALBUM_LEN_DICT[album.title]
def test_albums_photos():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.album_info
for album in albums:
photos = album.photos
assert len(photos) == ALBUM_LEN_DICT[album.title]
assert len(photos) == len(album)
for photo in photos:
assert photo.uuid in ALBUM_PHOTO_UUID_DICT[album.title]
def test_photoinfo_albums():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=ALBUM_PHOTO_UUID_DICT["Pumpkin Farm"])
albums = photos[0].albums
assert "Pumpkin Farm" in albums

View File

@@ -1,3 +1,4 @@
import os
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
@@ -71,6 +72,12 @@ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1 = [
"2018/September/Pumkins1.jpg", "2018/September/Pumkins1.jpg",
] ]
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_LOCALE = [
"2019/September/IMG_9975.JPEG",
"2020/Februar/IMG_1064.JPEG",
"2016/März/IMG_3984.JPEG",
]
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1 = [ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_ALBUM1 = [
"Multi Keyword/wedding.jpg", "Multi Keyword/wedding.jpg",
"_/Tulips.jpg", "_/Tulips.jpg",
@@ -325,6 +332,7 @@ def test_export_sidecar():
"-V", "-V",
], ],
) )
assert result.exit_code == 0
files = glob.glob("*.*") files = glob.glob("*.*")
assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES) assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES)
@@ -595,6 +603,49 @@ def test_export_directory_template_album_2():
assert os.path.isfile(os.path.join(workdir, filepath)) assert os.path.isfile(os.path.join(workdir, filepath))
@pytest.mark.skipif(
"OSXPHOTOS_TEST_LOCALE" not in os.environ,
reason="Skip if running in Github actions",
)
def test_export_directory_template_locale():
# test export using directory template in user locale non-US
import os
import glob
import locale
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# set locale environment
os.environ["LANG"] = "de_DE.UTF-8"
os.environ["LC_COLLATE"] = "de_DE.UTF-8"
os.environ["LC_CTYPE"] = "de_DE.UTF-8"
os.environ["LC_MESSAGES"] = "de_DE.UTF-8"
os.environ["LC_MONETARY"] = "de_DE.UTF-8"
os.environ["LC_NUMERIC"] = "de_DE.UTF-8"
os.environ["LC_TIME"] = "de_DE.UTF-8"
locale.setlocale(locale.LC_ALL, "")
result = runner.invoke(
export,
[
os.path.join(cwd, PLACES_PHOTOS_DB),
".",
"-V",
"--directory",
"{created.year}/{created.month}",
],
)
assert result.exit_code == 0
workdir = os.getcwd()
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES_LOCALE:
assert os.path.isfile(os.path.join(workdir, filepath))
def test_places(): def test_places():
import json import json
import os import os
@@ -805,3 +856,70 @@ def test_no_folder_1_14():
json_got = json.loads(result.output) json_got = json.loads(result.output)
assert len(json_got) == 1 # single element assert len(json_got) == 1 # single element
assert json_got[0]["uuid"] == "15uNd7%8RguTEgNPKHfTWw" assert json_got[0]["uuid"] == "15uNd7%8RguTEgNPKHfTWw"
def test_export_sidecar_keyword_template():
import json
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import cli
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
"--keyword-template",
"{folder_album}",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
],
)
assert result.exit_code == 0
files = glob.glob("*.*")
assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES)
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Girl holding pumpkin",
"XMP:Description": "Girl holding pumpkin",
"XMP:Title": "I found one!",
"XMP:TagsList": ["Kids", "Multi Keyword", "Test Album", "Pumpkin Farm"],
"IPTC:Keywords": ["Kids", "Multi Keyword", "Test Album", "Pumpkin Farm"],
"XMP:PersonInImage": ["Katie"],
"XMP:Subject": ["Kids", "Katie"],
"EXIF:DateTimeOriginal": "2018:09:28 16:07:07",
"EXIF:OffsetTimeOriginal": "-04:00",
"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()
# some gymnastics to account for different sort order in different pythons
for k, v in json_got.items():
if type(v) in (list, tuple):
assert sorted(json_expected[k]) == sorted(v)
else:
assert json_expected[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v

View File

@@ -468,6 +468,85 @@ def test_exiftool_json_sidecar():
json_got = json.loads(json_got)[0] json_got = json.loads(json_got)[0]
# some gymnastics to account for different sort order in different pythons # some gymnastics to account for different sort order in different pythons
for k, v in json_got.items():
if type(v) in (list, tuple):
assert sorted(json_expected[k]) == sorted(v)
else:
assert json_expected[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
def test_exiftool_json_sidecar_use_persons_keyword():
import osxphotos
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Girls with pumpkins",
"XMP:Description": "Girls with pumpkins",
"XMP:Title": "Can we carry this?",
"XMP:TagsList": ["Kids", "Suzy", "Katie"],
"IPTC:Keywords": ["Kids", "Suzy", "Katie"],
"XMP:PersonInImage": ["Suzy", "Katie"],
"XMP:Subject": ["Kids", "Suzy", "Katie"],
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
"EXIF:OffsetTimeOriginal": "-04:00",
"EXIF:ModifyDate": "2019:11:24 13:09:17"}]
"""
)[0]
json_got = photos[0]._exiftool_json_sidecar(use_persons_as_keywords=True)
json_got = json.loads(json_got)[0]
# some gymnastics to account for different sort order in different pythons
for k, v in json_got.items():
if type(v) in (list, tuple):
assert sorted(json_expected[k]) == sorted(v)
else:
assert json_expected[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
def test_exiftool_json_sidecar_use_albums_keyword():
import osxphotos
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Girls with pumpkins",
"XMP:Description": "Girls with pumpkins",
"XMP:Title": "Can we carry this?",
"XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"],
"IPTC:Keywords": ["Kids", "Pumpkin Farm", "Test Album"],
"XMP:PersonInImage": ["Suzy", "Katie"],
"XMP:Subject": ["Kids", "Suzy", "Katie"],
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
"EXIF:OffsetTimeOriginal": "-04:00",
"EXIF:ModifyDate": "2019:11:24 13:09:17"}]
"""
)[0]
json_got = photos[0]._exiftool_json_sidecar(use_albums_as_keywords=True)
json_got = json.loads(json_got)[0]
# some gymnastics to account for different sort order in different pythons # some gymnastics to account for different sort order in different pythons
for k, v in json_got.items(): for k, v in json_got.items():
if type(v) in (list, tuple): if type(v) in (list, tuple):
@@ -530,12 +609,140 @@ def test_xmp_sidecar():
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate> <xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description> </rdf:Description>
</rdf:RDF> </rdf:RDF>
</x:xmpmeta>""" </x:xmpmeta>"""
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")] xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
xmp_got = photos[0]._xmp_sidecar() xmp_got = photos[0]._xmp_sidecar()
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")] xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines): for line_expected, line_got in zip(
sorted(xmp_expected_lines), sorted(xmp_got_lines)
):
assert line_expected == line_got
def test_xmp_sidecar_use_persons_keyword():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
xmp_got = photos[0]._xmp_sidecar(use_persons_as_keywords=True)
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
for line_expected, line_got in zip(
sorted(xmp_expected_lines), sorted(xmp_got_lines)
):
assert line_expected == line_got
def test_xmp_sidecar_use_albums_keyword():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Pumpkin Farm</rdf:li>
<rdf:li>Test Album</rdf:li>
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
xmp_got = photos[0]._xmp_sidecar(use_albums_as_keywords=True)
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
for line_expected, line_got in zip(
sorted(xmp_expected_lines), sorted(xmp_got_lines)
):
assert line_expected == line_got assert line_expected == line_got

View File

@@ -155,4 +155,3 @@ def test_export_edited_no_edit(photosdb):
with pytest.raises(Exception) as e: with pytest.raises(Exception) as e:
assert photos[0].export(dest, use_photos_export=True, edited=True) assert photos[0].export(dest, use_photos_export=True, edited=True)
assert e.type == ValueError assert e.type == ValueError

View File

@@ -0,0 +1,219 @@
import pytest
from osxphotos._constants import _UNKNOWN_PERSON
PHOTOS_DB = "./tests/Test-10.15.4.photoslibrary/database/photos.db"
TOP_LEVEL_FOLDERS = ["Folder1"]
TOP_LEVEL_CHILDREN = ["SubFolder1", "SubFolder2"]
FOLDER_ALBUM_DICT = {"Folder1": [], "SubFolder1": [], "SubFolder2": ["AlbumInFolder"]}
ALBUM_NAMES = ["Pumpkin Farm", "AlbumInFolder", "Test Album", "Test Album"]
ALBUM_PARENT_DICT = {
"Pumpkin Farm": None,
"AlbumInFolder": "SubFolder2",
"Test Album": None,
}
ALBUM_FOLDER_NAMES_DICT = {
"Pumpkin Farm": [],
"AlbumInFolder": ["Folder1", "SubFolder2"],
"Test Album": [],
}
ALBUM_LEN_DICT = {"Pumpkin Farm": 3, "AlbumInFolder": 2, "Test Album": 1}
ALBUM_PHOTO_UUID_DICT = {
"Pumpkin Farm": [
"F12384F6-CD17-4151-ACBA-AE0E3688539E",
"D79B8D77-BFFC-460B-9312-034F2877D35B",
"1EB2B765-0765-43BA-A90C-0D0580E6172C",
],
"Test Album": [
"F12384F6-CD17-4151-ACBA-AE0E3688539E",
"D79B8D77-BFFC-460B-9312-034F2877D35B",
],
"AlbumInFolder": [
"3DD2C897-F19E-4CA6-8C22-B027D5A71907",
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51",
],
}
UUID_DICT = {
"two_albums": "F12384F6-CD17-4151-ACBA-AE0E3688539E",
"in_album": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51",
"xmp": "F12384F6-CD17-4151-ACBA-AE0E3688539E",
}
def test_exiftool_json_sidecar_keyword_template_long(caplog):
import osxphotos
from osxphotos._constants import _MAX_IPTC_KEYWORD_LEN
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["in_album"]])
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:11:24 13:09:17"}]
"""
)[0]
long_str = "x" * (_MAX_IPTC_KEYWORD_LEN + 1)
json_got = photos[0]._exiftool_json_sidecar(keyword_template=[long_str])
json_got = json.loads(json_got)[0]
assert "Some keywords exceed max IPTC Keyword length" in caplog.text
# some gymnastics to account for different sort order in different pythons
for k, v in json_got.items():
if type(v) in (list, tuple):
assert sorted(json_expected[k]) == sorted(v)
else:
assert json_expected[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
def test_exiftool_json_sidecar_keyword_template():
import osxphotos
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["in_album"]])
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder"],
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:11:24 13:09:17"}]
"""
)[0]
json_got = photos[0]._exiftool_json_sidecar(keyword_template=["{folder_album}"])
json_got = json.loads(json_got)[0]
# some gymnastics to account for different sort order in different pythons
for k, v in json_got.items():
if type(v) in (list, tuple):
assert sorted(json_expected[k]) == sorted(v)
else:
assert json_expected[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
# some gymnastics to account for different sort order in different pythons
for k, v in json_got.items():
if type(v) in (list, tuple):
assert sorted(json_expected[k]) == sorted(v)
else:
assert json_expected[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
def test_xmp_sidecar_keyword_template():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Pumpkin Farm</rdf:li>
<rdf:li>Test Album</rdf:li>
<rdf:li>2018</rdf:li>
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
xmp_got = photos[0]._xmp_sidecar(
keyword_template=["{created.year}", "{folder_album}"]
)
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
for line_expected, line_got in zip(
sorted(xmp_expected_lines), sorted(xmp_got_lines)
):
assert line_expected == line_got

View File

@@ -454,7 +454,7 @@ def test_xmp_sidecar():
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate> <xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description> </rdf:Description>
</rdf:RDF> </rdf:RDF>
</x:xmpmeta>""" </x:xmpmeta>"""
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")] xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
@@ -464,3 +464,68 @@ def test_xmp_sidecar():
for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines): for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines):
assert line_expected == line_got assert line_expected == line_got
def test_xmp_sidecar_keyword_template():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Test Album</rdf:li>
<rdf:li>Pumpkin Farm</rdf:li>
<rdf:li>2018</rdf:li>
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
xmp_got = photos[0]._xmp_sidecar(
keyword_template=["{folder_album}", "{created.year}"]
)
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
for line_expected, line_got in zip(
sorted(xmp_expected_lines), sorted(xmp_got_lines)
):
assert line_expected == line_got

View File

@@ -15,7 +15,7 @@ KEYWORDS = [
"United Kingdom", "United Kingdom",
] ]
PERSONS = ["Katie", "Suzy", "Maria"] PERSONS = ["Katie", "Suzy", "Maria"]
ALBUMS = ["Pumpkin Farm"] ALBUMS = ["Pumpkin Farm", "AlbumInFolder", "TestAlbum"]
KEYWORDS_DICT = { KEYWORDS_DICT = {
"Kids": 4, "Kids": 4,
"wedding": 2, "wedding": 2,
@@ -28,7 +28,7 @@ KEYWORDS_DICT = {
"United Kingdom": 1, "United Kingdom": 1,
} }
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1} PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1}
ALBUM_DICT = {"Pumpkin Farm": 3} ALBUM_DICT = {"Pumpkin Farm": 3, "TestAlbum": 1, "AlbumInFolder": 1}
def test_init(): def test_init():
@@ -123,7 +123,7 @@ def test_attributes():
) )
assert p.description == "Girl holding pumpkin" assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!" assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm"] assert sorted(p.albums) == ["AlbumInFolder", "Pumpkin Farm"]
assert p.persons == ["Katie"] assert p.persons == ["Katie"]
assert p.path.endswith( assert p.path.endswith(
"/tests/Test-10.13.6.photoslibrary/Masters/2019/07/26/20190726-203227/Pumkins2.jpg" "/tests/Test-10.13.6.photoslibrary/Masters/2019/07/26/20190726-203227/Pumkins2.jpg"

View File

@@ -53,22 +53,53 @@ TEMPLATE_VALUES = {
} }
TEMPLATE_VALUES_DEU = {
"{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
"{original_name}": "IMG_1064",
"{title}": "Glen Ord",
"{descr}": "Jack Rose Dining Saloon",
"{created.date}": "2020-02-04",
"{created.year}": "2020",
"{created.yy}": "20",
"{created.mm}": "02",
"{created.month}": "Februar",
"{created.mon}": "Feb",
"{created.doy}": "035",
"{modified.date}": "2020-03-21",
"{modified.year}": "2020",
"{modified.yy}": "20",
"{modified.mm}": "03",
"{modified.month}": "März",
"{modified.mon}": "Mär",
"{modified.doy}": "081",
"{place.name}": "Washington, District of Columbia, United States",
"{place.country_code}": "US",
"{place.name.country}": "United States",
"{place.name.state_province}": "District of Columbia",
"{place.name.city}": "Washington",
"{place.name.area_of_interest}": "_",
"{place.address}": "2038 18th St NW, Washington, DC 20009, United States",
"{place.address.street}": "2038 18th St NW",
"{place.address.city}": "Washington",
"{place.address.state_province}": "DC",
"{place.address.postal_code}": "20009",
"{place.address.country}": "United States",
"{place.address.country_code}": "US",
}
def test_lookup(): def test_lookup():
""" Test that a lookup is returned for every possible value """ """ Test that a lookup is returned for every possible value """
import re import re
import osxphotos import osxphotos
from osxphotos.template import ( from osxphotos.template import TEMPLATE_SUBSTITUTIONS
get_template_value,
render_filepath_template,
TEMPLATE_SUBSTITUTIONS,
)
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
for subst in TEMPLATE_SUBSTITUTIONS: for subst in TEMPLATE_SUBSTITUTIONS:
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1) lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
lookup = get_template_value(lookup_str, photo) lookup = photo.get_template_value(lookup_str)
assert lookup or lookup is None assert lookup or lookup is None
@@ -76,29 +107,67 @@ def test_subst():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
import locale import locale
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
locale.setlocale(locale.LC_ALL, "en_US") locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
for template in TEMPLATE_VALUES: for template in TEMPLATE_VALUES:
rendered, _ = render_filepath_template(template, photo) rendered, _ = photo.render_template(template)
assert rendered[0] == TEMPLATE_VALUES[template] assert rendered[0] == TEMPLATE_VALUES[template]
def test_subst_locale_1():
""" Test that substitutions are correct in user locale"""
import locale
import osxphotos
# osxphotos.template sets local on load so set the environment first
# set locale to DE
locale.setlocale(locale.LC_ALL, "de_DE.UTF-8")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
for template in TEMPLATE_VALUES_DEU:
rendered, _ = photo.render_template(template)
assert rendered[0] == TEMPLATE_VALUES_DEU[template]
def test_subst_locale_2():
""" Test that substitutions are correct in user locale"""
import locale
import os
import osxphotos
# osxphotos.template sets local on load so set the environment first
os.environ["LANG"] = "de_DE.UTF-8"
os.environ["LC_COLLATE"] = "de_DE.UTF-8"
os.environ["LC_CTYPE"] = "de_DE.UTF-8"
os.environ["LC_MESSAGES"] = "de_DE.UTF-8"
os.environ["LC_MONETARY"] = "de_DE.UTF-8"
os.environ["LC_NUMERIC"] = "de_DE.UTF-8"
os.environ["LC_TIME"] = "de_DE.UTF-8"
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
for template in TEMPLATE_VALUES_DEU:
rendered, _ = photo.render_template(template)
assert rendered[0] == TEMPLATE_VALUES_DEU[template]
def test_subst_default_val(): def test_subst_default_val():
""" Test substitution with default value specified """ """ Test substitution with default value specified """
import locale import locale
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
locale.setlocale(locale.LC_ALL, "en_US") locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{place.name.area_of_interest,UNKNOWN}" template = "{place.name.area_of_interest,UNKNOWN}"
rendered, _ = render_filepath_template(template, photo) rendered, _ = photo.render_template(template)
assert rendered[0] == "UNKNOWN" assert rendered[0] == "UNKNOWN"
@@ -106,14 +175,13 @@ def test_subst_default_val_2():
""" Test substitution with ',' but no default value """ """ Test substitution with ',' but no default value """
import locale import locale
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
locale.setlocale(locale.LC_ALL, "en_US") locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{place.name.area_of_interest,}" template = "{place.name.area_of_interest,}"
rendered, _ = render_filepath_template(template, photo) rendered, _ = photo.render_template(template)
assert rendered[0] == "_" assert rendered[0] == "_"
@@ -121,32 +189,30 @@ def test_subst_unknown_val():
""" Test substitution with unknown value specified """ """ Test substitution with unknown value specified """
import locale import locale
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
locale.setlocale(locale.LC_ALL, "en_US") locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{created.year}/{foo}" template = "{created.year}/{foo}"
rendered, unknown = render_filepath_template(template, photo) rendered, unknown = photo.render_template(template)
assert rendered[0] == "2020/{foo}" assert rendered[0] == "2020/{foo}"
assert unknown == ["foo"] assert unknown == ["foo"]
template = "{place.name.area_of_interest,}" template = "{place.name.area_of_interest,}"
rendered, _ = render_filepath_template(template, photo) rendered, _ = photo.render_template(template)
assert rendered[0] == "_" assert rendered[0] == "_"
def test_subst_double_brace(): def test_subst_double_brace():
""" Test substitution with double brace {{ which should be ignored """ """ Test substitution with double brace {{ which should be ignored """
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{created.year}/{{foo}}" template = "{created.year}/{{foo}}"
rendered, unknown = render_filepath_template(template, photo) rendered, unknown = photo.render_template(template)
assert rendered[0] == "2020/{foo}" assert rendered[0] == "2020/{foo}"
assert not unknown assert not unknown
@@ -155,14 +221,13 @@ def test_subst_unknown_val_with_default():
""" Test substitution with unknown value specified """ """ Test substitution with unknown value specified """
import locale import locale
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
locale.setlocale(locale.LC_ALL, "en_US") locale.setlocale(locale.LC_ALL, "en_US")
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{created.year}/{foo,bar}" template = "{created.year}/{foo,bar}"
rendered, unknown = render_filepath_template(template, photo) rendered, unknown = photo.render_template(template)
assert rendered[0] == "2020/{foo,bar}" assert rendered[0] == "2020/{foo,bar}"
assert unknown == ["foo"] assert unknown == ["foo"]
@@ -171,14 +236,13 @@ def test_subst_multi_1_1_2():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
# one album, one keyword, two persons # one album, one keyword, two persons
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
template = "{created.year}/{album}/{keyword}/{person}" template = "{created.year}/{album}/{keyword}/{person}"
expected = ["2018/Pumpkin Farm/Kids/Katie", "2018/Pumpkin Farm/Kids/Suzy"] expected = ["2018/Pumpkin Farm/Kids/Katie", "2018/Pumpkin Farm/Kids/Suzy"]
rendered, _ = render_filepath_template(template, photo) rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
@@ -186,7 +250,6 @@ def test_subst_multi_2_1_1():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
# 2 albums, 1 keyword, 1 person # 2 albums, 1 keyword, 1 person
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
# one album, one keyword, two persons # one album, one keyword, two persons
@@ -198,7 +261,7 @@ def test_subst_multi_2_1_1():
"2018/Test Album/Kids/Katie", "2018/Test Album/Kids/Katie",
"2018/Multi Keyword/Kids/Katie", "2018/Multi Keyword/Kids/Katie",
] ]
rendered, _ = render_filepath_template(template, photo) rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
@@ -206,7 +269,6 @@ def test_subst_multi_2_1_1_single():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
# 2 albums, 1 keyword, 1 person but only do keywords # 2 albums, 1 keyword, 1 person but only do keywords
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
# one album, one keyword, two persons # one album, one keyword, two persons
@@ -214,7 +276,7 @@ def test_subst_multi_2_1_1_single():
template = "{keyword}" template = "{keyword}"
expected = ["Kids"] expected = ["Kids"]
rendered, _ = render_filepath_template(template, photo) rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
@@ -222,7 +284,6 @@ def test_subst_multi_0_2_0():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons # 0 albums, 2 keywords, 0 persons
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
# one album, one keyword, two persons # one album, one keyword, two persons
@@ -230,7 +291,7 @@ def test_subst_multi_0_2_0():
template = "{created.year}/{album}/{keyword}/{person}" template = "{created.year}/{album}/{keyword}/{person}"
expected = ["2019/_/wedding/_", "2019/_/flowers/_"] expected = ["2019/_/wedding/_", "2019/_/flowers/_"]
rendered, _ = render_filepath_template(template, photo) rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
@@ -238,7 +299,6 @@ def test_subst_multi_0_2_0_single():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons, but only do albums # 0 albums, 2 keywords, 0 persons, but only do albums
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
# one album, one keyword, two persons # one album, one keyword, two persons
@@ -246,7 +306,7 @@ def test_subst_multi_0_2_0_single():
template = "{created.year}/{album}" template = "{created.year}/{album}"
expected = ["2019/_"] expected = ["2019/_"]
rendered, _ = render_filepath_template(template, photo) rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
@@ -254,7 +314,6 @@ def test_subst_multi_0_2_0_default_val():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons, default vals provided # 0 albums, 2 keywords, 0 persons, default vals provided
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
# one album, one keyword, two persons # one album, one keyword, two persons
@@ -262,7 +321,7 @@ def test_subst_multi_0_2_0_default_val():
template = "{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person,NOPERSON}" template = "{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person,NOPERSON}"
expected = ["2019/NOALBUM/wedding/NOPERSON", "2019/NOALBUM/flowers/NOPERSON"] expected = ["2019/NOALBUM/wedding/NOPERSON", "2019/NOALBUM/flowers/NOPERSON"]
rendered, _ = render_filepath_template(template, photo) rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
@@ -270,7 +329,6 @@ def test_subst_multi_0_2_0_default_val_unknown_val():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template # 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
# one album, one keyword, two persons # one album, one keyword, two persons
@@ -283,7 +341,7 @@ def test_subst_multi_0_2_0_default_val_unknown_val():
"2019/NOALBUM/wedding/_/{foo}/{baz}", "2019/NOALBUM/wedding/_/{foo}/{baz}",
"2019/NOALBUM/flowers/_/{foo}/{baz}", "2019/NOALBUM/flowers/_/{foo}/{baz}",
] ]
rendered, unknown = render_filepath_template(template, photo) rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
assert unknown == ["foo"] assert unknown == ["foo"]
@@ -292,7 +350,6 @@ def test_subst_multi_0_2_0_default_val_unknown_val_2():
""" Test that substitutions are correct """ """ Test that substitutions are correct """
# 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template # 0 albums, 2 keywords, 0 persons, default vals provided, unknown val in template
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
# one album, one keyword, two persons # one album, one keyword, two persons
@@ -303,7 +360,7 @@ def test_subst_multi_0_2_0_default_val_unknown_val_2():
"2019/NOALBUM/wedding/_/{foo,bar}/{baz,bar}", "2019/NOALBUM/wedding/_/{foo,bar}/{baz,bar}",
"2019/NOALBUM/flowers/_/{foo,bar}/{baz,bar}", "2019/NOALBUM/flowers/_/{foo,bar}/{baz,bar}",
] ]
rendered, unknown = render_filepath_template(template, photo) rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
assert unknown == ["foo"] assert unknown == ["foo"]
@@ -311,7 +368,6 @@ def test_subst_multi_0_2_0_default_val_unknown_val_2():
def test_subst_multi_folder_albums_1(): def test_subst_multi_folder_albums_1():
""" Test substitutions for folder_album are correct """ """ Test substitutions for folder_album are correct """
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4)
@@ -319,7 +375,7 @@ def test_subst_multi_folder_albums_1():
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_1"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["folder_album_1"]])[0]
template = "{folder_album}" template = "{folder_album}"
expected = ["Folder1/SubFolder2/AlbumInFolder"] expected = ["Folder1/SubFolder2/AlbumInFolder"]
rendered, unknown = render_filepath_template(template, photo) rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
assert unknown == [] assert unknown == []
@@ -327,7 +383,6 @@ def test_subst_multi_folder_albums_1():
def test_subst_multi_folder_albums_2(): def test_subst_multi_folder_albums_2():
""" Test substitutions for folder_album are correct """ """ Test substitutions for folder_album are correct """
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4)
@@ -335,15 +390,14 @@ def test_subst_multi_folder_albums_2():
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_no_folder"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["folder_album_no_folder"]])[0]
template = "{folder_album}" template = "{folder_album}"
expected = ["Pumpkin Farm", "Test Album"] expected = ["Pumpkin Farm", "Test Album"]
rendered, unknown = render_filepath_template(template, photo) rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
assert unknown == [] assert unknown == []
def test_subst_multi_folder_albums_3(caplog): def test_subst_multi_folder_albums_3():
""" Test substitutions for folder_album on < Photos 5 """ """ Test substitutions for folder_album on < Photos 5 """
import osxphotos import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_14_6) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_14_6)
@@ -351,6 +405,6 @@ def test_subst_multi_folder_albums_3(caplog):
photo = photosdb.photos(uuid=[UUID_DICT["mojave_album_1"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
template = "{folder_album}" template = "{folder_album}"
expected = ["Folder1/SubFolder2/AlbumInFolder", "Pumpkin Farm", "Test Album (1)"] expected = ["Folder1/SubFolder2/AlbumInFolder", "Pumpkin Farm", "Test Album (1)"]
rendered, unknown = render_filepath_template(template, photo) rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
assert unknown == [] assert unknown == []