Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc98c338b | ||
|
|
1c9d4f282b | ||
|
|
1ceda15134 | ||
|
|
a80071111f | ||
|
|
072a8d795e | ||
|
|
b35b071634 | ||
|
|
56a000609f | ||
|
|
54d5d4b7ba | ||
|
|
38137a1351 | ||
|
|
4b29a2e05f | ||
|
|
9be0f849b7 | ||
|
|
ccb5f252d1 | ||
|
|
d8a64c9573 | ||
|
|
81d4e392c3 | ||
|
|
85d2baac10 | ||
|
|
8a768e62ce | ||
|
|
1c8eb764f5 | ||
|
|
8e4b88ad1f | ||
|
|
3f80f786a3 | ||
|
|
a337e79e13 | ||
|
|
ec68feec49 | ||
|
|
9b9b54e590 | ||
|
|
22f1e8f2a6 | ||
|
|
1867c1d747 | ||
|
|
87eb84fddd | ||
|
|
15a3736b74 | ||
|
|
cf28cb6452 | ||
|
|
f20fadcef7 |
2
.github/workflows/pythonpackage.yml
vendored
@@ -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
|
||||||
|
|||||||
46
CHANGELOG.md
@@ -4,6 +4,46 @@ 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 < 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)
|
||||||
|
|
||||||
|
> 18 April 2020
|
||||||
|
|
||||||
|
- Added folder support for Photos <= 4, closes #93 [`#93`](https://github.com/RhetTbull/osxphotos/issues/93)
|
||||||
|
- cleaned up SQL statements in _process_database4 [`6f28171`](https://github.com/RhetTbull/osxphotos/commit/6f281711e2001a63ffad076d7b9835272d5d09da)
|
||||||
|
- Updated CHANGELOG.md [`1fa9583`](https://github.com/RhetTbull/osxphotos/commit/1fa9583ea689d54d2613a064f1ade25bcdfbf043)
|
||||||
|
- Fixed suffix check on export to be case insensitive [`4b30b3b`](https://github.com/RhetTbull/osxphotos/commit/4b30b3b4260e2c7409e18825e5b626efe646db16)
|
||||||
|
- test library update [`3bac106`](https://github.com/RhetTbull/osxphotos/commit/3bac106eb7a180e9e39643a89087d92bf2a437d0)
|
||||||
|
|
||||||
#### [v0.28.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.4...v0.28.1)
|
#### [v0.28.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.4...v0.28.1)
|
||||||
|
|
||||||
> 18 April 2020
|
> 18 April 2020
|
||||||
@@ -273,7 +313,11 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|||||||
- Moved PhotosDB attributes to properties instead of methods [`d95acdf`](https://github.com/RhetTbull/osxphotos/commit/d95acdf9f8764a1720bcba71a6dad29bf668eaf9)
|
- Moved PhotosDB attributes to properties instead of methods [`d95acdf`](https://github.com/RhetTbull/osxphotos/commit/d95acdf9f8764a1720bcba71a6dad29bf668eaf9)
|
||||||
- changed interface for export, prepped for exiftool_json_sidecar [`1fe8859`](https://github.com/RhetTbull/osxphotos/commit/1fe885962e8a9a420e776bdd3dc640ca143224b2)
|
- changed interface for export, prepped for exiftool_json_sidecar [`1fe8859`](https://github.com/RhetTbull/osxphotos/commit/1fe885962e8a9a420e776bdd3dc640ca143224b2)
|
||||||
|
|
||||||
#### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.14.21...v0.15.1)
|
#### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.15.0...v0.15.1)
|
||||||
|
|
||||||
|
> 28 April 2020
|
||||||
|
|
||||||
|
#### [v0.15.0](https://github.com/RhetTbull/osxphotos/compare/v0.14.21...v0.15.0)
|
||||||
|
|
||||||
> 14 December 2019
|
> 14 December 2019
|
||||||
|
|
||||||
|
|||||||
26
README.md
@@ -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
|
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
|
||||||
@@ -44,6 +46,14 @@ osxmetadata uses setuptools, thus simply run:
|
|||||||
|
|
||||||
python3 setup.py install
|
python3 setup.py install
|
||||||
|
|
||||||
|
If you're using python 3.6 or 3.7, you'll need to do this first to get around an issue with bpylist2:
|
||||||
|
|
||||||
|
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 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
|
||||||
|
|
||||||
## Command Line Usage
|
## Command Line Usage
|
||||||
|
|
||||||
This package will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
|
This package will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
|
||||||
@@ -209,6 +219,10 @@ 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.
|
||||||
--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
|
||||||
@@ -276,7 +290,7 @@ rendered name, use double braces, e.g. '{{' or '}}', thus using
|
|||||||
You may specify an optional default value to use if the substitution does not
|
You may specify an optional default value to use if the substitution does not
|
||||||
contain a value (e.g. the value is null) by specifying the default value after
|
contain a value (e.g. the value is null) by specifying the default value after
|
||||||
a ',' in the template string: for example, if template is
|
a ',' in the template string: for example, if template is
|
||||||
'{created.year}/{place.address,'NO_ADDRESS'}' but there was no address
|
'{created.year}/{place.address,NO_ADDRESS}' but there was no address
|
||||||
associated with the photo, the resulting output would be:
|
associated with the photo, the resulting output would be:
|
||||||
'2020/NO_ADDRESS/photoname.jpg'. If specified, the default value may not
|
'2020/NO_ADDRESS/photoname.jpg'. If specified, the default value may not
|
||||||
contain a brace symbol ('{' or '}').
|
contain a brace symbol ('{' or '}').
|
||||||
@@ -806,6 +820,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.
|
||||||
|
|
||||||
@@ -950,7 +965,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).
|
||||||
@@ -965,6 +981,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
|
||||||
|
|
||||||
|
|||||||
19
cli.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
""" stand alone command line script for use with pyinstaller
|
||||||
|
|
||||||
|
To build this into an executable:
|
||||||
|
- install pyinstaller:
|
||||||
|
python3 -m pip install pyinstaller
|
||||||
|
- then use build_cli_exe.sh to run pyinstaller or execute the following command:
|
||||||
|
pyinstaller --onefile --hidden-import="pkg_resources.py2_warn" --name osxphotos --add-data osxphotos/templates/xmp_sidecar.mako:osxphotos/templates cli.py
|
||||||
|
|
||||||
|
Resulting executable will be in "dist/osxphotos"
|
||||||
|
|
||||||
|
Note: This is *not* the cli that "python3 -m pip install osxphotos" or "python setup.py install" would install;
|
||||||
|
it's merely a wrapper around __main__.py to allow pyinstaller to work
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from osxphotos.__main__ import cli
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
8
make_cli_exe.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# This will build an stand-alone executable called 'osxphotos' in your ./dist directory
|
||||||
|
# using pyinstaller
|
||||||
|
# If you need to install pyinstaller:
|
||||||
|
# python3 -m pip install --upgrade pyinstaller
|
||||||
|
|
||||||
|
pyinstaller --onefile --hidden-import="pkg_resources.py2_warn" --name osxphotos --add-data osxphotos/templates/xmp_sidecar.mako:osxphotos/templates cli.py
|
||||||
@@ -18,7 +18,7 @@ from pathvalidate import (
|
|||||||
|
|
||||||
import osxphotos
|
import osxphotos
|
||||||
|
|
||||||
from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_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 (
|
||||||
@@ -109,7 +109,7 @@ class ExportCommand(click.Command):
|
|||||||
"You may specify an optional default value to use if the substitution does not contain a value "
|
"You may specify an optional default value to use if the substitution does not contain a value "
|
||||||
+ "(e.g. the value is null) "
|
+ "(e.g. the value is null) "
|
||||||
+ "by specifying the default value after a ',' in the template string: "
|
+ "by specifying the default value after a ',' in the template string: "
|
||||||
+ "for example, if template is '{created.year}/{place.address,'NO_ADDRESS'}' "
|
+ "for example, if template is '{created.year}/{place.address,NO_ADDRESS}' "
|
||||||
+ "but there was no address associated with the photo, the resulting output would be: "
|
+ "but there was no address associated with the photo, the resulting output would be: "
|
||||||
+ "'2020/NO_ADDRESS/photoname.jpg'. "
|
+ "'2020/NO_ADDRESS/photoname.jpg'. "
|
||||||
+ "If specified, the default value may not contain a brace symbol ('{' or '}')."
|
+ "If specified, the default value may not contain a brace symbol ('{' or '}')."
|
||||||
@@ -428,7 +428,7 @@ def albums(ctx, cli_obj, db, json_, photos_library):
|
|||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||||
albums = {"albums": photosdb.albums_as_dict}
|
albums = {"albums": photosdb.albums_as_dict}
|
||||||
if photosdb.db_version >= _PHOTOS_5_VERSION:
|
if photosdb.db_version > _PHOTOS_4_VERSION:
|
||||||
albums["shared albums"] = photosdb.albums_shared_as_dict
|
albums["shared albums"] = photosdb.albums_shared_as_dict
|
||||||
|
|
||||||
if json_ or cli_obj.json:
|
if json_ or cli_obj.json:
|
||||||
@@ -493,7 +493,7 @@ def info(ctx, cli_obj, db, json_, photos_library):
|
|||||||
not_shared_movies = [p for p in movies if not p.shared]
|
not_shared_movies = [p for p in movies if not p.shared]
|
||||||
info["movie_count"] = len(not_shared_movies)
|
info["movie_count"] = len(not_shared_movies)
|
||||||
|
|
||||||
if pdb.db_version >= _PHOTOS_5_VERSION:
|
if pdb.db_version > _PHOTOS_4_VERSION:
|
||||||
shared_photos = [p for p in photos if p.shared]
|
shared_photos = [p for p in photos if p.shared]
|
||||||
info["shared_photo_count"] = len(shared_photos)
|
info["shared_photo_count"] = len(shared_photos)
|
||||||
|
|
||||||
@@ -508,7 +508,7 @@ def info(ctx, cli_obj, db, json_, photos_library):
|
|||||||
info["albums_count"] = len(albums)
|
info["albums_count"] = len(albums)
|
||||||
info["albums"] = albums
|
info["albums"] = albums
|
||||||
|
|
||||||
if pdb.db_version >= _PHOTOS_5_VERSION:
|
if pdb.db_version > _PHOTOS_4_VERSION:
|
||||||
albums_shared = pdb.albums_shared_as_dict
|
albums_shared = pdb.albums_shared_as_dict
|
||||||
info["shared_albums_count"] = len(albums_shared)
|
info["shared_albums_count"] = len(albums_shared)
|
||||||
info["shared_albums"] = albums_shared
|
info["shared_albums"] = albums_shared
|
||||||
@@ -893,6 +893,16 @@ 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(
|
@click.option(
|
||||||
"--current-name",
|
"--current-name",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
@@ -983,6 +993,8 @@ def export(
|
|||||||
skip_bursts,
|
skip_bursts,
|
||||||
skip_live,
|
skip_live,
|
||||||
skip_raw,
|
skip_raw,
|
||||||
|
person_keyword,
|
||||||
|
album_keyword,
|
||||||
current_name,
|
current_name,
|
||||||
sidecar,
|
sidecar,
|
||||||
only_photos,
|
only_photos,
|
||||||
@@ -1174,6 +1186,8 @@ def export(
|
|||||||
directory,
|
directory,
|
||||||
no_extended_attributes,
|
no_extended_attributes,
|
||||||
export_raw,
|
export_raw,
|
||||||
|
album_keyword,
|
||||||
|
person_keyword,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
for p in photos:
|
for p in photos:
|
||||||
@@ -1192,6 +1206,8 @@ def export(
|
|||||||
directory,
|
directory,
|
||||||
no_extended_attributes,
|
no_extended_attributes,
|
||||||
export_raw,
|
export_raw,
|
||||||
|
album_keyword,
|
||||||
|
person_keyword,
|
||||||
)
|
)
|
||||||
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 +1594,8 @@ def export_photo(
|
|||||||
directory,
|
directory,
|
||||||
no_extended_attributes,
|
no_extended_attributes,
|
||||||
export_raw,
|
export_raw,
|
||||||
|
album_keyword,
|
||||||
|
person_keyword,
|
||||||
):
|
):
|
||||||
""" 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 +1612,8 @@ 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
|
||||||
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -1677,6 +1697,8 @@ 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,
|
||||||
)[0]
|
)[0]
|
||||||
photo_paths.append(photo_path)
|
photo_paths.append(photo_path)
|
||||||
|
|
||||||
@@ -1712,6 +1734,8 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
return photo_paths
|
return photo_paths
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.28.2"
|
__version__ = "0.28.8"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from ._constants import (
|
|||||||
_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 .exiftool import ExifTool
|
from .exiftool import ExifTool
|
||||||
@@ -53,7 +54,12 @@ class PhotoInfo:
|
|||||||
@property
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
""" filename of the picture """
|
""" filename of the picture """
|
||||||
return self._info["filename"]
|
if self.has_raw and self.raw_original:
|
||||||
|
# return name of the RAW file
|
||||||
|
# TODO: not yet implemented
|
||||||
|
return self._info["filename"]
|
||||||
|
else:
|
||||||
|
return self._info["filename"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def original_filename(self):
|
def original_filename(self):
|
||||||
@@ -296,9 +302,10 @@ class PhotoInfo:
|
|||||||
glob_str = f"{filestem}*.{raw_ext}"
|
glob_str = f"{filestem}*.{raw_ext}"
|
||||||
raw_file = findfiles(glob_str, filepath)
|
raw_file = findfiles(glob_str, filepath)
|
||||||
if len(raw_file) != 1:
|
if len(raw_file) != 1:
|
||||||
logging.warning(
|
# Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library
|
||||||
f"Error getting path to RAW file: {filepath}/{glob_str}"
|
# 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
|
||||||
|
logging.debug(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])
|
||||||
@@ -610,7 +617,7 @@ class PhotoInfo:
|
|||||||
""" returns True if associated RAW image and the RAW image is selected in Photos
|
""" returns True if associated RAW image and the RAW image is selected in Photos
|
||||||
via "Use RAW as Original "
|
via "Use RAW as Original "
|
||||||
otherwise returns False """
|
otherwise returns False """
|
||||||
return True if self._info["original_resource_choice"] == 1 else False
|
return self._info["raw_is_original"]
|
||||||
|
|
||||||
def export(
|
def export(
|
||||||
self,
|
self,
|
||||||
@@ -627,6 +634,8 @@ 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,
|
||||||
):
|
):
|
||||||
""" export photo
|
""" export photo
|
||||||
dest: must be valid destination path (or exception raised)
|
dest: must be valid destination path (or exception raised)
|
||||||
@@ -652,7 +661,12 @@ 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
|
||||||
|
"""
|
||||||
|
|
||||||
# list of all files exported during this call to export
|
# list of all files exported during this call to export
|
||||||
exported_files = []
|
exported_files = []
|
||||||
@@ -856,7 +870,10 @@ 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,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -866,7 +883,10 @@ 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,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -876,17 +896,28 @@ 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,
|
||||||
|
)
|
||||||
|
|
||||||
return exported_files
|
return exported_files
|
||||||
|
|
||||||
def _write_exif_data(self, filepath):
|
def _write_exif_data(
|
||||||
|
self, filepath, use_albums_as_keywords=False, use_persons_as_keywords=False
|
||||||
|
):
|
||||||
""" 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,
|
||||||
|
)
|
||||||
|
)[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
|
||||||
@@ -897,7 +928,9 @@ 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
|
||||||
|
):
|
||||||
""" 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
|
||||||
Exports the following:
|
Exports the following:
|
||||||
@@ -926,18 +959,30 @@ 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_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:
|
exif["XMP:Subject"] = list(self.keywords) + person_list
|
||||||
exif["XMP:Subject"].extend(self.persons)
|
|
||||||
else:
|
|
||||||
exif["XMP:Subject"] = self.persons
|
|
||||||
|
|
||||||
# if self.favorite():
|
# if self.favorite():
|
||||||
# exif["Rating"] = 5
|
# exif["Rating"] = 5
|
||||||
@@ -971,14 +1016,41 @@ 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(self, use_albums_as_keywords=False, use_persons_as_keywords=False):
|
||||||
""" returns string for XMP sidecar """
|
""" returns string for XMP sidecar """
|
||||||
# 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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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() != ""]
|
||||||
|
|||||||
@@ -77,6 +77,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
|
||||||
@@ -240,7 +246,7 @@ class PhotosDB:
|
|||||||
self._db_version = self._get_db_version()
|
self._db_version = self._get_db_version()
|
||||||
|
|
||||||
# If Photos >= 5, actual data isn't in photos.db but in Photos.sqlite
|
# If Photos >= 5, actual data isn't in photos.db but in Photos.sqlite
|
||||||
if int(self._db_version) >= int(_PHOTOS_5_VERSION):
|
if int(self._db_version) > int(_PHOTOS_4_VERSION):
|
||||||
dbpath = pathlib.Path(self._dbfile).parent
|
dbpath = pathlib.Path(self._dbfile).parent
|
||||||
dbfile = dbpath / "Photos.sqlite"
|
dbfile = dbpath / "Photos.sqlite"
|
||||||
if not _check_file_exists(dbfile):
|
if not _check_file_exists(dbfile):
|
||||||
@@ -259,7 +265,7 @@ class PhotosDB:
|
|||||||
library_path = os.path.dirname(os.path.abspath(dbfile))
|
library_path = os.path.dirname(os.path.abspath(dbfile))
|
||||||
(library_path, _) = os.path.split(library_path) # drop /database from path
|
(library_path, _) = os.path.split(library_path) # drop /database from path
|
||||||
self._library_path = library_path
|
self._library_path = library_path
|
||||||
if int(self._db_version) < int(_PHOTOS_5_VERSION):
|
if int(self._db_version) <= int(_PHOTOS_4_VERSION):
|
||||||
masters_path = os.path.join(library_path, "Masters")
|
masters_path = os.path.join(library_path, "Masters")
|
||||||
self._masters_path = masters_path
|
self._masters_path = masters_path
|
||||||
else:
|
else:
|
||||||
@@ -528,7 +534,6 @@ class PhotosDB:
|
|||||||
""" process the Photos database to extract info
|
""" process the Photos database to extract info
|
||||||
works on Photos version <= 4.0 """
|
works on Photos version <= 4.0 """
|
||||||
|
|
||||||
# TODO: Update strings to remove + (not needed)
|
|
||||||
# Epoch is Jan 1, 2001
|
# Epoch is Jan 1, 2001
|
||||||
td = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
|
td = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
|
||||||
|
|
||||||
@@ -888,6 +893,7 @@ class PhotosDB:
|
|||||||
# TODO: NOT YET USED -- PLACEHOLDER for RAW processing (currently only in _process_database5)
|
# TODO: NOT YET USED -- PLACEHOLDER for RAW processing (currently only in _process_database5)
|
||||||
# original resource choice (e.g. RAW or jpeg)
|
# original resource choice (e.g. RAW or jpeg)
|
||||||
self._dbphotos[uuid]["original_resource_choice"] = None
|
self._dbphotos[uuid]["original_resource_choice"] = None
|
||||||
|
self._dbphotos[uuid]["raw_is_original"] = None
|
||||||
|
|
||||||
# associated RAW image info
|
# associated RAW image info
|
||||||
self._dbphotos[uuid]["has_raw"] = True if row[25] == 7 else False
|
self._dbphotos[uuid]["has_raw"] = True if row[25] == 7 else False
|
||||||
@@ -903,17 +909,17 @@ class PhotosDB:
|
|||||||
# get additional details from RKMaster, needed for RAW processing
|
# get additional details from RKMaster, needed for RAW processing
|
||||||
c.execute(
|
c.execute(
|
||||||
""" SELECT
|
""" SELECT
|
||||||
RKMaster.uuid,
|
RKMaster.uuid,
|
||||||
RKMaster.volumeId,
|
RKMaster.volumeId,
|
||||||
RKMaster.imagePath,
|
RKMaster.imagePath,
|
||||||
RKMaster.isMissing,
|
RKMaster.isMissing,
|
||||||
RKMaster.originalFileName,
|
RKMaster.originalFileName,
|
||||||
RKMaster.UTI,
|
RKMaster.UTI,
|
||||||
RKMaster.modelID,
|
RKMaster.modelID,
|
||||||
RKMaster.fileSize,
|
RKMaster.fileSize,
|
||||||
RKMaster.isTrulyRaw,
|
RKMaster.isTrulyRaw,
|
||||||
RKMaster.alternateMasterUuid
|
RKMaster.alternateMasterUuid
|
||||||
FROM RKMaster
|
FROM RKMaster
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1607,7 +1613,12 @@ 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,
|
||||||
|
# ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE
|
||||||
|
# = 0 if jpeg is selected as "original" in Photos (the default)
|
||||||
|
# = 1 if RAW is selected as "original" in Photos
|
||||||
info["original_resource_choice"] = row[27]
|
info["original_resource_choice"] = row[27]
|
||||||
|
info["raw_is_original"] = True if row[27] == 1 else False
|
||||||
|
|
||||||
# associated RAW image info
|
# associated RAW image info
|
||||||
# will be filled in later
|
# will be filled in later
|
||||||
@@ -1878,7 +1889,7 @@ class PhotosDB:
|
|||||||
# folder with no parent (e.g. shared iCloud folders)
|
# folder with no parent (e.g. shared iCloud folders)
|
||||||
return folders
|
return folders
|
||||||
|
|
||||||
if self._db_version >= _PHOTOS_5_VERSION and parent == self._folder_root_pk:
|
if self._db_version > _PHOTOS_4_VERSION and parent == self._folder_root_pk:
|
||||||
# at the top of the folder hierarchy, we're done
|
# at the top of the folder hierarchy, we're done
|
||||||
return folders
|
return folders
|
||||||
|
|
||||||
@@ -2156,3 +2167,7 @@ class PhotosDB:
|
|||||||
return self.__dict__ == other.__dict__
|
return self.__dict__ == other.__dict__
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
""" returns number of photos in the database """
|
||||||
|
return len(self._dbphotos)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
# 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 datetime
|
||||||
|
import locale
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
@@ -18,6 +19,9 @@ from typing import Tuple, List # pylint: disable=syntax-error
|
|||||||
from .photoinfo import PhotoInfo
|
from .photoinfo import PhotoInfo
|
||||||
from ._constants import _UNKNOWN_PERSON
|
from ._constants import _UNKNOWN_PERSON
|
||||||
|
|
||||||
|
# ensure locale set to user's locale
|
||||||
|
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}": "Filename of the photo",
|
||||||
|
|||||||
@@ -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/'>
|
||||||
|
|||||||
32
setup.py
@@ -3,7 +3,7 @@
|
|||||||
#
|
#
|
||||||
# setup.py script for osxphotos
|
# setup.py script for osxphotos
|
||||||
#
|
#
|
||||||
# Copyright (c) 2019 Rhet Turnbull, rturnbull+git@gmail.com
|
# Copyright (c) 2019, 2020 Rhet Turnbull, rturnbull+git@gmail.com
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# Permission is hereby granted, free of charge, to any person
|
# Permission is hereby granted, free of charge, to any person
|
||||||
@@ -27,23 +27,43 @@
|
|||||||
# SOFTWARE.
|
# SOFTWARE.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
from setuptools import find_packages, setup
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
this_directory = os.path.abspath(os.path.dirname(__file__))
|
# python version as 2-digit float (e.g. 3.6)
|
||||||
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
|
py_ver = float(".".join(platform.python_version_tuple()[:2]))
|
||||||
long_description = f.read()
|
|
||||||
|
|
||||||
|
# holds config info read from disk
|
||||||
about = {}
|
about = {}
|
||||||
|
this_directory = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
# get version info from _version
|
||||||
with open(
|
with open(
|
||||||
os.path.join(this_directory, "osxphotos", "_version.py"), mode="r", encoding="utf-8"
|
os.path.join(this_directory, "osxphotos", "_version.py"), mode="r", encoding="utf-8"
|
||||||
) as f:
|
) as f:
|
||||||
exec(f.read(), about)
|
exec(f.read(), about)
|
||||||
|
|
||||||
|
# read README.md into long_description
|
||||||
|
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
|
||||||
|
about["long_description"] = f.read()
|
||||||
|
|
||||||
|
# ugly hack to install custom version of bpylist2 needed for Python < 3.8
|
||||||
|
# the stock version of bylist2==2.0.3 causes an error related to
|
||||||
|
# "pkg_resources.ContextualVersionConflict: (pycodestyle 2.3.1..."
|
||||||
|
# PEP 508 no help here as URL-based lookups not allowed in PyPI packages
|
||||||
|
# if you know a better way, PRs welcome!
|
||||||
|
# once I go to 3.8+ required, this won't be necessary as bpylist2 3.0+ solves this issue
|
||||||
|
if py_ver < 3.8:
|
||||||
|
os.system(
|
||||||
|
"python3 -m pip install git+git://github.com/RhetTbull/bpylist2.git#egg=bpylist2"
|
||||||
|
)
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="osxphotos",
|
name="osxphotos",
|
||||||
version=about["__version__"],
|
version=about["__version__"],
|
||||||
description="Manipulate (read-only) Apple's Photos app library on Mac OS X",
|
description="Manipulate (read-only) Apple's Photos app library on Mac OS X",
|
||||||
long_description=long_description,
|
long_description=about["long_description"],
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
author="Rhet Turnbull",
|
author="Rhet Turnbull",
|
||||||
author_email="rturnbull+git@gmail.com",
|
author_email="rturnbull+git@gmail.com",
|
||||||
@@ -58,7 +78,7 @@ setup(
|
|||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Operating System :: MacOS :: MacOS X",
|
"Operating System :: MacOS :: MacOS X",
|
||||||
"Programming Language :: Python :: 3.6",
|
"Programming Language :: Python :: 3.8",
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
],
|
],
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
|||||||
@@ -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/).
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>2500</integer>
|
||||||
<key>processname</key>
|
<key>processname</key>
|
||||||
<string>photolibraryd</string>
|
<string>photolibraryd</string>
|
||||||
<key>uid</key>
|
<key>uid</key>
|
||||||
|
|||||||
@@ -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-04-26T23:00:16Z</date>
|
||||||
<key>BackgroundHighlightEnrichment</key>
|
<key>BackgroundHighlightEnrichment</key>
|
||||||
<date>2020-04-17T14:33:32Z</date>
|
<date>2020-04-26T23:00:15Z</date>
|
||||||
<key>BackgroundJobAssetRevGeocode</key>
|
<key>BackgroundJobAssetRevGeocode</key>
|
||||||
<date>2020-04-17T14:33:33Z</date>
|
<date>2020-04-26T23:00:16Z</date>
|
||||||
<key>BackgroundJobSearch</key>
|
<key>BackgroundJobSearch</key>
|
||||||
<date>2020-04-17T14:33:33Z</date>
|
<date>2020-04-26T23:00:16Z</date>
|
||||||
<key>BackgroundPeopleSuggestion</key>
|
<key>BackgroundPeopleSuggestion</key>
|
||||||
<date>2020-04-17T14:33:31Z</date>
|
<date>2020-04-26T23:00:14Z</date>
|
||||||
<key>BackgroundUserBehaviorProcessor</key>
|
<key>BackgroundUserBehaviorProcessor</key>
|
||||||
<date>2020-04-17T07:32:04Z</date>
|
<date>2020-04-26T18:44:36Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||||
<date>2020-04-17T14:33:37Z</date>
|
<date>2020-04-26T23:00:17Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||||
<date>2020-04-17T07:32:00Z</date>
|
<date>2020-04-26T18:44:35Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||||
<date>2020-04-17T14:33:34Z</date>
|
<date>2020-04-26T21:06:44Z</date>
|
||||||
<key>SiriPortraitDonation</key>
|
<key>SiriPortraitDonation</key>
|
||||||
<date>2020-04-17T07:32:04Z</date>
|
<date>2020-04-26T18:44:36Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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-04-26T18:44:37Z</date>
|
||||||
<key>LastContactClassificationKey</key>
|
<key>LastContactClassificationKey</key>
|
||||||
<date>2020-04-17T07:32:12Z</date>
|
<date>2020-04-26T18:44:46Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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>3212</integer>
|
<integer>434</integer>
|
||||||
<key>processname</key>
|
<key>processname</key>
|
||||||
<string>photolibraryd</string>
|
<string>photolibraryd</string>
|
||||||
<key>uid</key>
|
<key>uid</key>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
@@ -3,24 +3,24 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>BackgroundHighlightCollection</key>
|
<key>BackgroundHighlightCollection</key>
|
||||||
<date>2020-04-11T20:00:25Z</date>
|
<date>2020-04-19T15:27:34Z</date>
|
||||||
<key>BackgroundHighlightEnrichment</key>
|
<key>BackgroundHighlightEnrichment</key>
|
||||||
<date>2020-04-11T20:00:25Z</date>
|
<date>2020-04-19T15:27:34Z</date>
|
||||||
<key>BackgroundJobAssetRevGeocode</key>
|
<key>BackgroundJobAssetRevGeocode</key>
|
||||||
<date>2020-04-11T20:00:25Z</date>
|
<date>2020-04-19T15:27:35Z</date>
|
||||||
<key>BackgroundJobSearch</key>
|
<key>BackgroundJobSearch</key>
|
||||||
<date>2020-04-11T20:00:25Z</date>
|
<date>2020-04-19T15:27:35Z</date>
|
||||||
<key>BackgroundPeopleSuggestion</key>
|
<key>BackgroundPeopleSuggestion</key>
|
||||||
<date>2020-04-11T20:00:24Z</date>
|
<date>2020-04-19T15:27:34Z</date>
|
||||||
<key>BackgroundUserBehaviorProcessor</key>
|
<key>BackgroundUserBehaviorProcessor</key>
|
||||||
<date>2020-04-11T20:00:25Z</date>
|
<date>2020-04-19T15:27:35Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||||
<date>2020-04-11T20:10:27Z</date>
|
<date>2020-04-11T20:10:27Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||||
<date>2020-04-11T20:00:24Z</date>
|
<date>2020-04-11T20:00:24Z</date>
|
||||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||||
<date>2020-04-11T20:00:25Z</date>
|
<date>2020-04-19T15:27:35Z</date>
|
||||||
<key>SiriPortraitDonation</key>
|
<key>SiriPortraitDonation</key>
|
||||||
<date>2020-04-11T20:00:25Z</date>
|
<date>2020-04-19T15:27:35Z</date>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -118,6 +118,13 @@ def test_init5():
|
|||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
assert osxphotos.PhotosDB()
|
assert osxphotos.PhotosDB()
|
||||||
|
|
||||||
|
def test_db_len():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
# assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS
|
||||||
|
assert len(photosdb) == 12
|
||||||
|
|
||||||
|
|
||||||
def test_db_version():
|
def test_db_version():
|
||||||
import osxphotos
|
import osxphotos
|
||||||
@@ -368,7 +375,7 @@ def test_count():
|
|||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
photos = photosdb.photos()
|
photos = photosdb.photos()
|
||||||
assert len(photos) == 8
|
assert len(photos) == 12
|
||||||
|
|
||||||
|
|
||||||
def test_keyword_2():
|
def test_keyword_2():
|
||||||
@@ -771,7 +778,7 @@ def test_from_to_date():
|
|||||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB)
|
photosdb = osxphotos.PhotosDB(PHOTOS_DB)
|
||||||
|
|
||||||
photos = photosdb.photos(from_date=dt.datetime(2018, 10, 28))
|
photos = photosdb.photos(from_date=dt.datetime(2018, 10, 28))
|
||||||
assert len(photos) == 2
|
assert len(photos) ==6
|
||||||
|
|
||||||
photos = photosdb.photos(to_date=dt.datetime(2018, 10, 28))
|
photos = photosdb.photos(to_date=dt.datetime(2018, 10, 28))
|
||||||
assert len(photos) == 6
|
assert len(photos) == 6
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
@@ -46,16 +47,22 @@ CLI_EXPORT_FILENAMES = [
|
|||||||
|
|
||||||
CLI_EXPORT_FILENAMES_CURRENT = [
|
CLI_EXPORT_FILENAMES_CURRENT = [
|
||||||
"1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg",
|
"1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg",
|
||||||
|
"3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg",
|
||||||
|
"4D521201-92AC-43E5-8F7C-59BC41C37A96.cr2",
|
||||||
|
"4D521201-92AC-43E5-8F7C-59BC41C37A96.jpeg",
|
||||||
|
"6191423D-8DB8-4D4C-92BE-9BBBA308AAC4.jpeg",
|
||||||
|
"A92D9C26-3A50-4197-9388-CB5F7DB9FA91.cr2",
|
||||||
|
"A92D9C26-3A50-4197-9388-CB5F7DB9FA91.jpeg",
|
||||||
|
"D05A5FE3-15FB-49A1-A15D-AB3DA6F8B068.dng",
|
||||||
|
"D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg",
|
||||||
"DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg",
|
"DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg",
|
||||||
"DC99FBDD-7A52-4100-A5BB-344131646C30_edited.jpeg",
|
"DC99FBDD-7A52-4100-A5BB-344131646C30_edited.jpeg",
|
||||||
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg",
|
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg",
|
||||||
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51_edited.jpeg",
|
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51_edited.jpeg",
|
||||||
"D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg",
|
|
||||||
"F12384F6-CD17-4151-ACBA-AE0E3688539E.jpeg",
|
"F12384F6-CD17-4151-ACBA-AE0E3688539E.jpeg",
|
||||||
"6191423D-8DB8-4D4C-92BE-9BBBA308AAC4.jpeg",
|
|
||||||
"3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1 = [
|
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1 = [
|
||||||
"2019/April/wedding.jpg",
|
"2019/April/wedding.jpg",
|
||||||
"2019/July/Tulips.jpg",
|
"2019/July/Tulips.jpg",
|
||||||
@@ -65,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",
|
||||||
@@ -589,6 +602,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
|
||||||
|
|||||||
@@ -482,6 +482,88 @@ def test_exiftool_json_sidecar():
|
|||||||
assert json_got[k] == v
|
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
|
||||||
|
# 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():
|
||||||
|
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_xmp_sidecar():
|
def test_xmp_sidecar():
|
||||||
import osxphotos
|
import osxphotos
|
||||||
|
|
||||||
@@ -539,3 +621,125 @@ 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_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(xmp_expected_lines, 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(xmp_expected_lines, xmp_got_lines):
|
||||||
|
assert line_expected == line_got
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ def test_db_version():
|
|||||||
assert photosdb.db_version in osxphotos._constants._TESTED_DB_VERSIONS
|
assert photosdb.db_version in osxphotos._constants._TESTED_DB_VERSIONS
|
||||||
assert photosdb.db_version == "4025"
|
assert photosdb.db_version == "4025"
|
||||||
|
|
||||||
|
def test_db_len():
|
||||||
|
import osxphotos
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||||
|
# assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS
|
||||||
|
assert len(photosdb) == 7
|
||||||
|
|
||||||
|
|
||||||
def test_os_version():
|
def test_os_version():
|
||||||
import osxphotos
|
import osxphotos
|
||||||
|
|||||||
@@ -53,6 +53,41 @@ 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
|
||||||
@@ -87,6 +122,49 @@ def test_subst():
|
|||||||
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")
|
||||||
|
from osxphotos.template import render_filepath_template
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
|
for template in TEMPLATE_VALUES_DEU:
|
||||||
|
rendered, _ = render_filepath_template(template, photo)
|
||||||
|
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"
|
||||||
|
|
||||||
|
from osxphotos.template import render_filepath_template
|
||||||
|
|
||||||
|
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||||
|
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||||
|
|
||||||
|
for template in TEMPLATE_VALUES_DEU:
|
||||||
|
rendered, _ = render_filepath_template(template, photo)
|
||||||
|
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
|
||||||
|
|||||||