Compare commits

...

23 Commits

Author SHA1 Message Date
Rhet Turnbull
3bac106eb7 test library update 2020-04-18 12:24:03 -07:00
Rhet Turnbull
47d1c82c03 Added folder support for Photos <= 4, closes #93 2020-04-18 12:21:08 -07:00
Rhet Turnbull
6f281711e2 cleaned up SQL statements in _process_database4 2020-04-18 08:05:43 -07:00
Rhet Turnbull
4b30b3b426 Fixed suffix check on export to be case insensitive 2020-04-18 07:59:04 -07:00
Rhet Turnbull
1fa9583ea6 Updated CHANGELOG.md 2020-04-17 23:33:17 -07:00
Rhet Turnbull
235e1fb1a6 Updated README.md 2020-04-17 23:23:57 -07:00
Rhet Turnbull
36c2821a0f replaced CLI option --original-name with --current-name 2020-04-17 23:20:23 -07:00
Rhet Turnbull
ed425724a0 Changed default CLI behavior to export all photos 2020-04-17 22:52:11 -07:00
Rhet Turnbull
55daa31c71 Update README.md 2020-04-17 12:46:56 -07:00
Rhet Turnbull
b6ac9e1ea3 Updated test library for Sierra 2020-04-17 11:52:55 -07:00
Rhet Turnbull
9d151478d6 Initial support for RAW photos in Photos 4 to address issue #101 2020-04-17 10:44:15 -07:00
Rhet Turnbull
7d55844390 Added --export-raw to CLI export 2020-04-16 23:10:33 -07:00
Rhet Turnbull
f398e9116f Added --has-raw to CLI query and export 2020-04-16 16:46:29 -07:00
Rhet Turnbull
4fe8190b57 Added raw details to PhotoInfo json() and __str__() 2020-04-16 16:11:47 -07:00
Rhet Turnbull
7e42ebb240 Initial work on suppport for associated RAW images 2020-04-16 11:53:48 -07:00
Rhet Turnbull
edae116baa Test library update 2020-04-16 11:53:05 -07:00
Rhet Turnbull
d542cda17d Small fix to database version logic to look into issue #102 2020-04-15 14:09:47 -07:00
Rhet Turnbull
99b5b54c6d Update README.md 2020-04-15 11:38:57 -07:00
Rhet Turnbull
379feddcda Updated README.md to add Known Bugs section 2020-04-15 11:37:38 -07:00
Rhet Turnbull
24285f5dd2 Update README.md 2020-04-13 00:53:50 -07:00
Rhet Turnbull
3cb3ebb300 Updated CHANGELOG.md 2020-04-13 00:46:08 -07:00
Rhet Turnbull
16037f10fa Update README.md 2020-04-12 23:50:04 -07:00
Rhet Turnbull
ebd21491ac Updated examples to work with latest version 2020-04-12 20:57:37 -07:00
86 changed files with 985 additions and 874 deletions

View File

@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.28.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.4...v0.28.1)
> 18 April 2020
- Initial work on suppport for associated RAW images [`7e42ebb`](https://github.com/RhetTbull/osxphotos/commit/7e42ebb2402d45cd5d20bdd55bddddaa9db4679f)
- Initial support for RAW photos in Photos 4 to address issue #101 [`9d15147`](https://github.com/RhetTbull/osxphotos/commit/9d151478d610291b8d482aafae3d445dfd391fca)
- replaced CLI option --original-name with --current-name [`36c2821`](https://github.com/RhetTbull/osxphotos/commit/36c2821a0fa62eaaa54cf1edc2d9c6da98155354)
#### [v0.27.4](https://github.com/RhetTbull/osxphotos/compare/v0.27.3...v0.27.4)
> 12 April 2020
- Added {folder_album} to template and --folder to CLI [`b7c7b9f`](https://github.com/RhetTbull/osxphotos/commit/b7c7b9f0664e69c743bdd8a228ad2936cf6b7600)
- Test library update [`21e7020`](https://github.com/RhetTbull/osxphotos/commit/21e7020fec406b0f3926d7adc8a1451bfe77e75a)
- Updated CHANGELOG.md [`952741d`](https://github.com/RhetTbull/osxphotos/commit/952741d488d2fbbaf8a0c1d3781ad7c4205c068f)
#### [v0.27.3](https://github.com/RhetTbull/osxphotos/compare/v0.27.1...v0.27.3)
> 12 April 2020

View File

@@ -21,6 +21,7 @@
* [Examples](#examples)
* [Related Projects](#related-projects)
* [Contributing](#contributing)
* [Known Bugs](#known-bugs)
* [Implementation Notes](#implementation-notes)
* [Dependencies](#dependencies)
* [Acknowledgements](#acknowledgements)
@@ -95,7 +96,10 @@ Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
Optionally, query the Photos database using 1 or more search options; if
more than one option is provided, they are treated as "AND" (e.g. search
for photos matching all options). If no query options are provided, all
photos will be exported.
photos will be exported. By default, all versions of all photos will be
exported including edited versions, live photo movies, burst photos, and
associated RAW images. See --skip-edited, --skip-live, --skip-bursts, and
--skip-raw options to modify this behavior.
Options:
--db <Photos database path> Specify Photos database path. Path to Photos
@@ -107,6 +111,7 @@ Options:
order: 1. last opened library, 2. system
library, 3. ~/Pictures/Photos
Library.photoslibrary
-V, --verbose Print verbose output.
--keyword KEYWORD Search for photos with keyword KEYWORD. If
more than one keyword, treated as "OR", e.g.
find photos match any keyword
@@ -171,6 +176,8 @@ Options:
--not-selfie Search for photos that are not selfies.
--panorama Search for panorama photos.
--not-panorama Search for photos that are not panoramas.
--has-raw Search for photos with both a jpeg and RAW
version
--only-movies Search only for movies (default searches
both images and movies).
--only-photos Search only for photos/images (default
@@ -183,7 +190,6 @@ Options:
Search by end item date, e.g.
2000-01-12T12:00:00 or 2000-12-31 (ISO 8601
w/o TZ).
-V, --verbose Print verbose output.
--overwrite Overwrite existing files. Default behavior
is to add (1), (2), etc to filename if file
already exists. Use this with caution as it
@@ -192,19 +198,23 @@ Options:
--export-by-date Automatically create output folders to
organize photos by date created (e.g.
DEST/2019/12/20/photoname.jpg).
--export-edited Also export edited version of photo if an
edited version exists. Edited photo will be
named in form of "photoname_edited.ext"
--export-bursts If a photo is a burst photo export all
associated burst images in the library. Not
currently compatible with --download-
misssing; see note on --download-missing.
--export-live If a photo is a live photo export the
associated live video component. Live video
will have same name as photo but with .mov
extension.
--original-name Use photo's original filename instead of
current filename for export.
--skip-edited Do not export edited version of photo if an
edited version exists.
--skip-bursts Do not export all associated burst images in
the library if a photo is a burst photo.
--skip-live Do not export the associated live video
component of a live photo.
--skip-raw Do not export associated RAW images of a
RAW/jpeg pair. 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).
--current-name Use photo's current filename instead of
original filename for export. Note:
Starting with Photos 5, all photos are
renamed upon import. By default, photos are
exported with the the original name they had
before import.
--sidecar FORMAT Create sidecar for each photo exported;
valid FORMAT values: xmp, json; --sidecar
json: create JSON sidecar useable by
@@ -224,9 +234,9 @@ Options:
exist on disk. This will be slow and will
require internet connection. This obviously
only works if the Photos library is synched
to iCloud. Note: --download-missing is not
currently compatabile with --export-bursts;
only the primary photo will be exported--
to iCloud. Note: --download-missing does
not currently export all burst images; only
the primary photo will be exported--
associated burst images will be skipped.
--exiftool Use exiftool to write metadata directly to
exported photos. To use this option,
@@ -353,13 +363,13 @@ Substitution Description
{person} Person(s) / face(s) in a photo
```
Example: export all photos to ~/Desktop/export, including edited versions and live photo movies, group in folders by date created
Example: export all photos to ~/Desktop/export group in folders by date created
`osxphotos export --export-edited --export-live --export-by-date ~/Pictures/Photos\ Library.photoslibrary ~/Desktop/export`
`osxphotos export --export-by-date ~/Pictures/Photos\ Library.photoslibrary ~/Desktop/export`
**Note**: Photos library/database path can also be specified using --db option:
`osxphotos export --export-edited --export-live --export-by-date --db ~/Pictures/Photos\ Library.photoslibrary ~/Desktop/export`
`osxphotos export --export-by-date --db ~/Pictures/Photos\ Library.photoslibrary ~/Desktop/export`
Example: find all photos with keyword "Kids" and output results to json file named results.json:
@@ -1015,7 +1025,7 @@ Returns a [FolderInfo](#FolderInfo) object representing the albums parent folder
### FolderInfo
PhotosDB.folders returns a list of FolderInfo objects representing the top level folders in the library. Each FolderInfo object represents a single folder in the Photos library.
PhotosDB.folder_info returns a list of FolderInfo objects representing the top level folders in the library. Each FolderInfo object represents a single folder in the Photos library.
#### `uuid`
Returns the universally unique identifier (uuid) of the folder. This is how Photos keeps track of individual objects within the database.
@@ -1037,7 +1047,7 @@ Returns a [FolderInfo](#FolderInfo) object representing the folder's parent fold
```python
>>> import osxphotos
>>> photosdb = osxphotos.PhotosDB()
>>> photosdb.subfolders
>>> photosdb.folder_info
[<osxphotos.albuminfo.FolderInfo object at 0x10fcc0160>]
>>> photosdb.folder_info[0].title
'Folder1'
@@ -1296,13 +1306,20 @@ If you have an interesting example that shows usage of this package, submit an i
Testing against "real world" Photos libraries would be especially helpful. If you discover issues in testing against your Photos libraries, please open an issue. I've done extensive testing against my own Photos library but that's a since data point and I'm certain there are issues lurking in various edge cases I haven't discovered yet.
## Known Bugs
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.
- 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
This package works by creating a copy of the sqlite3 database that photos uses to store data about the photos library. the class photosdb then queries this database to extract information about the photos such as persons (faces identified in the photos), albums, keywords, etc. If your library is large, the database can be hundreds of MB in size and the copy read then can take many 10s of seconds to complete. Once copied, the entire database is processed and an in-memory data structure is created meaning all subsequent accesses of the PhotosDB object occur much more quickly.
This package works by creating a copy of the sqlite3 database that photos uses to store data about the photos library. The class PhotosDB then queries this database to extract information about the photos such as persons (faces identified in the photos), albums, keywords, etc. If your library is large, the database can be hundreds of MB in size and the copy read then can take many 10s of seconds to complete. Once copied, the entire database is processed and an in-memory data structure is created meaning all subsequent accesses of the PhotosDB object occur much more quickly.
If apple changes the database format this will likely break.
Apple does provide a framework ([PhotoKit](https://developer.apple.com/documentation/photokit?language=objc)) for querying the user's Photos library and I attempted to create the funcationality in this package using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
Apple does provide a framework ([PhotoKit](https://developer.apple.com/documentation/photokit?language=objc)) for querying the user's Photos library and I attempted to create the funcationality in this package using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons) and Apple's System Integrity Protection (SIP) made the interface unreliable. If you'd like to experiment with the PhotoKit interface, here's some sample [code](https://gist.github.com/RhetTbull/41cc85e5bdeb30f761147ce32fba5c94). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
## Dependencies
- [PyObjC](https://pythonhosted.org/pyobjc/)

View File

@@ -14,7 +14,7 @@ def main():
print(photosdb.keywords)
print(photosdb.persons)
print(photosdb.album_names)
print(photosdb.albums)
print(photosdb.keywords_as_dict)
print(photosdb.persons_as_dict)

View File

@@ -345,6 +345,11 @@ def query_options(f):
is_flag=True,
help="Search for photos that are not panoramas.",
),
o(
"--has-raw",
is_flag=True,
help="Search for photos with both a jpeg and RAW version",
),
o(
"--only-movies",
is_flag=True,
@@ -724,6 +729,7 @@ def query(
not_selfie,
panorama,
not_panorama,
has_raw,
place,
no_place,
):
@@ -743,6 +749,7 @@ def query(
edited,
external_edit,
uti,
has_raw,
from_date,
to_date,
]
@@ -836,6 +843,7 @@ def query(
not_selfie=not_selfie,
panorama=panorama,
not_panorama=not_panorama,
has_raw=has_raw,
place=place,
no_place=no_place,
)
@@ -847,8 +855,8 @@ def query(
@cli.command(cls=ExportCommand)
@DB_OPTION
@query_options
@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.")
@query_options
@click.option(
"--overwrite",
is_flag=True,
@@ -864,27 +872,33 @@ def query(
"(e.g. DEST/2019/12/20/photoname.jpg).",
)
@click.option(
"--export-edited",
"--skip-edited",
is_flag=True,
help="Also export edited version of photo if an edited version exists. "
'Edited photo will be named in form of "photoname_edited.ext"',
help="Do not export edited version of photo if an edited version exists.",
)
@click.option(
"--export-bursts",
"--skip-bursts",
is_flag=True,
help="If a photo is a burst photo export all associated burst images in the library. "
"Not currently compatible with --download-misssing; see note on --download-missing.",
help="Do not export all associated burst images in the library if a photo is a burst photo. ",
)
@click.option(
"--export-live",
"--skip-live",
is_flag=True,
help="If a photo is a live photo export the associated live video component."
" Live video will have same name as photo but with .mov extension. ",
help="Do not export the associated live video component of a live photo.",
)
@click.option(
"--original-name",
"--skip-raw",
is_flag=True,
help="Use photo's original filename instead of current filename for export.",
help="Do not export associated RAW images of a RAW/jpeg pair. "
"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).",
)
@click.option(
"--current-name",
is_flag=True,
help="Use photo's current filename instead of original filename for export. "
"Note: Starting with Photos 5, all photos are renamed upon import. By default, "
"photos are exported with the the original name they had before import.",
)
@click.option(
"--sidecar",
@@ -907,7 +921,7 @@ def query(
"to interact with Photos to export the photo which will force Photos to download from iCloud if "
"the photo does not exist on disk. This will be slow and will require internet connection. "
"This obviously only works if the Photos library is synched to iCloud. "
"Note: --download-missing is not currently compatabile with --export-bursts; "
"Note: --download-missing does not currently export all burst images; "
"only the primary photo will be exported--associated burst images will be skipped.",
)
@click.option(
@@ -965,10 +979,11 @@ def export(
verbose,
overwrite,
export_by_date,
export_edited,
export_bursts,
export_live,
original_name,
skip_edited,
skip_bursts,
skip_live,
skip_raw,
current_name,
sidecar,
only_photos,
only_movies,
@@ -993,6 +1008,7 @@ def export(
not_selfie,
panorama,
not_panorama,
has_raw,
directory,
place,
no_place,
@@ -1004,6 +1020,10 @@ def export(
if more than one option is provided, they are treated as "AND"
(e.g. search for photos matching all options).
If no query options are provided, all photos will be exported.
By default, all versions of all photos will be exported including edited
versions, live photo movies, burst photos, and associated RAW images.
See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options
to modify this behavior.
"""
if not os.path.isdir(dest):
@@ -1032,6 +1052,17 @@ def export(
click.echo(cli.commands["export"].get_help(ctx), err=True)
return
# initialize export flags
# by default, will export all versions of photos unless skip flag is set
(export_edited, export_bursts, export_live, export_raw) = [
not x for x in [skip_edited, skip_bursts, skip_live, skip_raw]
]
# though the command line option is current_name, internally all processing
# logic uses original_name which is the boolean inverse of current_name
# because the original code used --original-name as an option
original_name = not current_name
# verify exiftool installed an in path
if exiftool:
try:
@@ -1108,6 +1139,7 @@ def export(
not_selfie=not_selfie,
panorama=panorama,
not_panorama=not_panorama,
has_raw=has_raw,
place=place,
no_place=no_place,
)
@@ -1141,6 +1173,7 @@ def export(
exiftool,
directory,
no_extended_attributes,
export_raw,
)
else:
for p in photos:
@@ -1158,6 +1191,7 @@ def export(
exiftool,
directory,
no_extended_attributes,
export_raw,
)
if export_paths:
click.echo(f"Exported {p.filename} to {export_paths}")
@@ -1232,6 +1266,9 @@ def print_photo_info(photos, json=False):
"hdr",
"selfie",
"panorama",
"has_raw",
"uti_raw",
"path_raw",
]
)
for p in photos:
@@ -1273,6 +1310,9 @@ def print_photo_info(photos, json=False):
p.hdr,
p.selfie,
p.panorama,
p.has_raw,
p.uti_raw,
p.path_raw,
]
)
for row in dump:
@@ -1328,6 +1368,7 @@ def _query(
not_selfie=None,
panorama=None,
not_panorama=None,
has_raw=None,
place=None,
no_place=None,
):
@@ -1516,6 +1557,9 @@ def _query(
elif not_incloud:
photos = [p for p in photos if not p.incloud]
if has_raw:
photos = [p for p in photos if p.has_raw]
return photos
@@ -1533,6 +1577,7 @@ def export_photo(
exiftool,
directory,
no_extended_attributes,
export_raw,
):
""" Helper function for export that does the actual export
photo: PhotoInfo object
@@ -1548,6 +1593,7 @@ def export_photo(
exiftool: use exiftool to write EXIF metadata directly to exported photo
directory: template used to determine output directory
no_extended_attributes: boolean; if True, exports photo without preserving extended attributes
export_raw: boolean; if True exports RAW image associate with the photo
returns list of path(s) of exported photo or None if photo was missing
"""
@@ -1626,6 +1672,7 @@ def export_photo(
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
live_photo=export_live,
raw_photo=export_raw,
overwrite=overwrite,
use_photos_export=use_photos_export,
exiftool=exiftool,

View File

@@ -16,8 +16,9 @@ _TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
# only version 3 - 4 have RKVersion.selfPortrait
_PHOTOS_3_VERSION = "3301"
# versions later than this have a different database structure
_PHOTOS_5_VERSION = "6000"
# versions 5.0 and later have a different database structure
_PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.4
# which major version operating systems have been tested
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
@@ -46,3 +47,7 @@ _PHOTOS_5_ALBUM_KIND = 2 # normal user album
_PHOTOS_5_SHARED_ALBUM_KIND = 1505 # shared album
_PHOTOS_5_FOLDER_KIND = 4000 # user folder
_PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.27.4"
__version__ = "0.28.2"

View File

@@ -12,7 +12,13 @@ PhotosDB.folders() returns a list of FolderInfo objects
import logging
from ._constants import _PHOTOS_5_ALBUM_KIND, _PHOTOS_5_FOLDER_KIND, _PHOTOS_5_VERSION
from ._constants import (
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_TOP_LEVEL_ALBUM,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_FOLDER_KIND,
)
class AlbumInfo:
@@ -53,14 +59,13 @@ class AlbumInfo:
["Top level folder", "sub folder 1", "sub folder 2", ...]
returns empty list if album is not in any folders """
if self._db._db_version < _PHOTOS_5_VERSION:
logging.warning("Folders not yet implemented for this DB version")
return []
try:
return self._folder_names
except AttributeError:
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
if self._db._db_version <= _PHOTOS_4_VERSION:
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
else:
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
return self._folder_names
@property
@@ -70,10 +75,6 @@ class AlbumInfo:
["Top level folder", "sub folder 1", "sub folder 2", ...]
returns empty list if album is not in any folders """
if self._db._db_version < _PHOTOS_5_VERSION:
logging.warning("Folders not yet implemented for this DB version")
return []
try:
return self._folders
except AttributeError:
@@ -83,19 +84,23 @@ class AlbumInfo:
@property
def parent(self):
""" returns FolderInfo object for parent folder or None if no parent (e.g. top-level album) """
if self._db._db_version < _PHOTOS_5_VERSION:
logging.warning("Folders not yet implemented for this DB version")
return None
try:
return self._parent
except AttributeError:
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
self._parent = (
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
if parent_pk != self._db._folder_root_pk
else None
)
if self._db._db_version <= _PHOTOS_4_VERSION:
parent_uuid = self._db._dbalbum_details[self._uuid]["folderUuid"]
self._parent = (
FolderInfo(db=self._db, uuid=parent_uuid)
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
else None
)
else:
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
self._parent = (
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
if parent_pk != self._db._folder_root_pk
else None
)
return self._parent
def __len__(self):
@@ -112,8 +117,12 @@ class FolderInfo:
def __init__(self, db=None, uuid=None):
self._uuid = uuid
self._db = db
self._pk = self._db._dbalbum_details[uuid]["pk"]
self._title = self._db._dbalbum_details[uuid]["title"]
if self._db._db_version <= _PHOTOS_4_VERSION:
self._pk = None
self._title = self._db._dbfolder_details[uuid]["name"]
else:
self._pk = self._db._dbalbum_details[uuid]["pk"]
self._title = self._db._dbalbum_details[uuid]["title"]
@property
def title(self):
@@ -131,13 +140,22 @@ class FolderInfo:
try:
return self._albums
except AttributeError:
albums = [
AlbumInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["kind"] == _PHOTOS_5_ALBUM_KIND
and detail["parentfolder"] == self._pk
]
if self._db._db_version <= _PHOTOS_4_VERSION:
albums = [
AlbumInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["albumSubclass"] == _PHOTOS_4_ALBUM_KIND
and detail["folderUuid"] == self._uuid
]
else:
albums = [
AlbumInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["kind"] == _PHOTOS_5_ALBUM_KIND
and detail["parentfolder"] == self._pk
]
self._albums = albums
return self._albums
@@ -147,12 +165,20 @@ class FolderInfo:
try:
return self._parent
except AttributeError:
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
self._parent = (
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
if parent_pk != self._db._folder_root_pk
else None
)
if self._db._db_version <= _PHOTOS_4_VERSION:
parent_uuid = self._db._dbfolder_details[self._uuid]["parentFolderUuid"]
self._parent = (
FolderInfo(db=self._db, uuid=parent_uuid)
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
else None
)
else:
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
self._parent = (
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
if parent_pk != self._db._folder_root_pk
else None
)
return self._parent
@property
@@ -161,13 +187,22 @@ class FolderInfo:
try:
return self._folders
except AttributeError:
folders = [
FolderInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["kind"] == _PHOTOS_5_FOLDER_KIND
and detail["parentfolder"] == self._pk
]
if self._db._db_version <= _PHOTOS_4_VERSION:
folders = [
FolderInfo(db=self._db, uuid=folder)
for folder, detail in self._db._dbfolder_details.items()
if not detail["intrash"]
and not detail["isMagic"]
and detail["parentFolderUuid"] == self._uuid
]
else:
folders = [
FolderInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["kind"] == _PHOTOS_5_FOLDER_KIND
and detail["parentfolder"] == self._pk
]
self._folders = folders
return self._folders

View File

@@ -21,8 +21,8 @@ from mako.template import Template
from ._constants import (
_MOVIE_TYPE,
_PHOTO_TYPE,
_PHOTOS_4_VERSION,
_PHOTOS_5_SHARED_PHOTO_PATH,
_PHOTOS_5_VERSION,
_TEMPLATE_DIR,
_XMP_TEMPLATE_NAME,
)
@@ -34,6 +34,8 @@ from .utils import (
_export_photo_uuid_applescript,
_get_resource_loc,
dd_to_dms_str,
findfiles,
get_preferred_uti_extension,
)
@@ -96,7 +98,7 @@ class PhotoInfo:
if self._info["isMissing"] == 1:
return photopath # path would be meaningless until downloaded
if self._db._db_version < _PHOTOS_5_VERSION:
if self._db._db_version <= _PHOTOS_4_VERSION:
vol = self._info["volume"]
if vol is not None:
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
@@ -135,7 +137,7 @@ class PhotoInfo:
photopath = None
if self._db._db_version < _PHOTOS_5_VERSION:
if self._db._db_version <= _PHOTOS_4_VERSION:
if self._info["hasAdjustments"]:
edit_id = self._info["edit_resource_id"]
if edit_id is not None:
@@ -239,6 +241,75 @@ class PhotoInfo:
return photopath
@property
def path_raw(self):
""" absolute path of associated RAW image or None if there is not one """
# In Photos 5, raw is in same folder as original but with _4.ext
# Unless "Copy Items to the Photos Library" is not checked
# then RAW image is not renamed but has same name is jpeg buth with raw extension
# Current implementation uses findfiles to find images with the correct raw UTI extension
# in same folder as the original and with same stem as original in form: original_stem*.raw_ext
# TODO: I don't like this -- would prefer a more deterministic approach but until I have more
# data on how Photos stores and retrieves RAW images, this seems to be working
if self._info["isMissing"] == 1:
return None # path would be meaningless until downloaded
if not self.has_raw:
return None # no raw image to get path for
# if self._info["shared"]:
# # shared photo
# photopath = os.path.join(
# self._db._library_path,
# _PHOTOS_5_SHARED_PHOTO_PATH,
# self._info["directory"],
# self._info["filename"],
# )
# return photopath
if self._db._db_version <= _PHOTOS_4_VERSION:
vol = self._info["raw_info"]["volume"]
if vol is not None:
photopath = os.path.join(
"/Volumes", vol, self._info["raw_info"]["imagePath"]
)
else:
photopath = os.path.join(
self._db._masters_path, self._info["raw_info"]["imagePath"]
)
if not os.path.isfile(photopath):
logging.debug(
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
filestem = pathlib.Path(self._info["filename"]).stem
raw_ext = get_preferred_uti_extension(self._info["UTI_raw"])
if self._info["directory"].startswith("/"):
filepath = self._info["directory"]
else:
filepath = os.path.join(self._db._masters_path, self._info["directory"])
glob_str = f"{filestem}*.{raw_ext}"
raw_file = findfiles(glob_str, filepath)
if len(raw_file) != 1:
logging.warning(
f"Error getting path to RAW file: {filepath}/{glob_str}"
)
photopath = None
else:
photopath = os.path.join(filepath, raw_file[0])
if not os.path.isfile(photopath):
logging.debug(
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
return photopath
@property
def description(self):
""" long / extended description of picture """
@@ -329,7 +400,7 @@ class PhotoInfo:
def shared(self):
""" returns True if photos is in a shared iCloud album otherwise false
Only valid on Photos 5; returns None on older versions """
if self._db._db_version >= _PHOTOS_5_VERSION:
if self._db._db_version > _PHOTOS_4_VERSION:
return self._info["shared"]
else:
return None
@@ -341,6 +412,14 @@ class PhotoInfo:
"""
return self._info["UTI"]
@property
def uti_raw(self):
""" Returns Uniform Type Identifier (UTI) for the RAW image if there is one
for example: com.canon.cr2-raw-image
Returns None if no associated RAW image
"""
return self._info["UTI_raw"]
@property
def ismovie(self):
""" Returns True if file is a movie, otherwise False
@@ -366,7 +445,7 @@ class PhotoInfo:
""" Returns True if photo is a cloud asset (in an iCloud library),
otherwise False
"""
if self._db._db_version < _PHOTOS_5_VERSION:
if self._db._db_version <= _PHOTOS_4_VERSION:
return (
True
if self._info["cloudLibraryState"] is not None
@@ -409,7 +488,7 @@ class PhotoInfo:
If photo is missing, returns None """
photopath = None
if self._db._db_version < _PHOTOS_5_VERSION:
if self._db._db_version <= _PHOTOS_4_VERSION:
if self.live_photo and not self.ismissing:
live_model_id = self._info["live_model_id"]
if live_model_id == None:
@@ -500,7 +579,7 @@ class PhotoInfo:
# implementation note: doesn't create the PlaceInfo object until requested
# then memoizes the object in self._place to avoid recreating the object
if self._db._db_version < _PHOTOS_5_VERSION:
if self._db._db_version <= _PHOTOS_4_VERSION:
try:
return self._place # pylint: disable=access-member-before-definition
except AttributeError:
@@ -521,12 +600,25 @@ class PhotoInfo:
self._place = None
return self._place
@property
def has_raw(self):
""" returns True if photo has an associated RAW image, otherwise False """
return self._info["has_raw"]
@property
def raw_original(self):
""" returns True if associated RAW image and the RAW image is selected in Photos
via "Use RAW as Original "
otherwise returns False """
return True if self._info["original_resource_choice"] == 1 else False
def export(
self,
dest,
*filename,
edited=False,
live_photo=False,
raw_photo=False,
overwrite=False,
increment=True,
sidecar_json=False,
@@ -548,6 +640,7 @@ class PhotoInfo:
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
@@ -624,7 +717,10 @@ class PhotoInfo:
# warn if suffixes don't match but ignore .JPG / .jpeg as
# Photo's often converts .JPG to .jpeg
suffixes = sorted([x.lower() for x in [dest.suffix, actual_suffix]])
if dest.suffix != actual_suffix and suffixes != [".jpeg", ".jpg"]:
if dest.suffix.lower() != actual_suffix.lower() and suffixes != [
".jpeg",
".jpg",
]:
logging.warning(
f"Invalid destination suffix: {dest.suffix}, should be {actual_suffix}"
)
@@ -697,6 +793,20 @@ class PhotoInfo:
exported_files.append(str(live_name))
else:
logging.warning(f"Skipping missing live movie for {filename}")
# copy associated RAW image if requested
if raw_photo and self.has_raw:
raw_path = pathlib.Path(self.path_raw)
raw_ext = raw_path.suffix
raw_name = dest.parent / f"{dest.stem}{raw_ext}"
if raw_path is not None:
logging.debug(
f"Exporting RAW photo of {filename} as {raw_name.name}"
)
_copy_file(str(raw_path), str(raw_name), norsrc=no_xattr)
exported_files.append(str(raw_name))
else:
logging.warning(f"Skipping missing RAW photo for {filename}")
else:
# use_photo_export
exported = None
@@ -947,6 +1057,9 @@ class PhotoInfo:
"hdr": self.hdr,
"selfie": self.selfie,
"panorama": self.panorama,
"has_raw": self.has_raw,
"uti_raw": self.uti_raw,
"path_raw": self.path_raw,
}
return yaml.dump(info, sort_keys=False)
@@ -993,6 +1106,9 @@ class PhotoInfo:
"hdr": self.hdr,
"selfie": self.selfie,
"panorama": self.panorama,
"has_raw": self.has_raw,
"uti_raw": self.uti_raw,
"path_raw": self.path_raw,
}
return json.dumps(pic)

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,11 @@
import fnmatch
import glob
import logging
import os
import os.path
import pathlib
import platform
import re
import sqlite3
import subprocess
import sys
@@ -11,6 +14,7 @@ import urllib.parse
from plistlib import load as plistload
import CoreFoundation
import CoreServices
import objc
from Foundation import *
@@ -302,6 +306,28 @@ def create_path_by_date(dest, dt):
return new_dest
def get_preferred_uti_extension(uti):
""" get preferred extension for a UTI type
uti: UTI str, e.g. 'public.jpeg'
returns: preferred extension as str """
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
ext = CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension
)
return ext
def findfiles(pattern, path_):
"""Returns list of filenames from path_ matched by pattern
shell pattern. Matching is case-insensitive."""
# See: https://gist.github.com/techtonik/5694830
rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
return [name for name in os.listdir(path_) if rule.match(name)]
# TODO: this doesn't always work, still looking for a way to
# force Photos to open the library being operated on
# def _open_photos_library_applescript(library_path):

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2019-12-08T16:44:38Z</date>
<date>2020-04-17T18:39:50Z</date>
</dict>
<key>PXPeopleScreenUnlocked</key>
<true/>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-01-22T02:10:26Z</date>
<date>2020-04-17T18:40:46Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-01-22T02:10:27Z</date>
<date>2020-04-17T18:39:51Z</date>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-01-19T17:29:28Z</date>
<date>2020-04-17T18:39:52Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>LastHistoryRowId</key>
<integer>414</integer>
<integer>502</integer>
<key>LibraryBuildTag</key>
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
<key>LibrarySchemaVersion</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -9,7 +9,7 @@
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>414</integer>
<integer>502</integer>
<key>LibraryBuildTag</key>
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
<key>LibrarySchemaVersion</key>

View File

@@ -9,12 +9,14 @@
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>TopLevelAlbums</string>
<string>QtSnVvTkQ%i2z3hB834M1A</string>
<string>TopLevelSlideshows</string>
<string>N7eQ4VhfTfeHFp9PPHaJDw</string>
</array>
<key>IPXWorkspaceControllerZoomLevelsKey</key>
<dict>
<key>kZoomLevelIdentifierAlbums</key>
<integer>10</integer>
<integer>5</integer>
<key>kZoomLevelIdentifierVersions</key>
<integer>7</integer>
</dict>
@@ -23,11 +25,11 @@
<key>key</key>
<integer>1</integer>
<key>lastKnownDisplayName</key>
<string>Test Album (1)</string>
<string>Pumpkin Farm (1)</string>
<key>type</key>
<string>album</string>
<key>uuid</key>
<string>Uq6qsKihRRSjMHTiD+0Azg</string>
<string>xJ8ya3NBRWC24gKhcwwNeQ</string>
</dict>
<key>lastKnownItemCounts</key>
<dict>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-03-27T04:00:09Z</date>
<date>2020-04-18T18:01:02Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-03-27T04:00:10Z</date>
<date>2020-04-18T17:22:55Z</date>
</dict>
</plist>

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2020-03-15T20:19:24Z</date>
<date>2020-04-17T17:51:16Z</date>
</dict>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-03-27T03:59:54Z</date>
<date>2020-04-17T17:49:52Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>LastHistoryRowId</key>
<integer>575</integer>
<integer>606</integer>
<key>LibraryBuildTag</key>
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
<key>LibrarySchemaVersion</key>

View File

@@ -9,7 +9,7 @@
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>575</integer>
<integer>606</integer>
<key>LibraryBuildTag</key>
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
<key>LibrarySchemaVersion</key>
@@ -24,7 +24,7 @@
<key>SnapshotCompletedDate</key>
<date>2019-07-27T13:16:43Z</date>
<key>SnapshotLastValidated</key>
<date>2020-03-27T04:02:59Z</date>
<date>2020-04-17T17:51:16Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>

View File

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

View File

@@ -3,24 +3,24 @@
<plist version="1.0">
<dict>
<key>BackgroundHighlightCollection</key>
<date>2020-04-12T19:50:22Z</date>
<date>2020-04-17T14:33:32Z</date>
<key>BackgroundHighlightEnrichment</key>
<date>2020-04-12T19:50:22Z</date>
<date>2020-04-17T14:33:32Z</date>
<key>BackgroundJobAssetRevGeocode</key>
<date>2020-04-12T21:11:06Z</date>
<date>2020-04-17T14:33:33Z</date>
<key>BackgroundJobSearch</key>
<date>2020-04-12T19:50:22Z</date>
<date>2020-04-17T14:33:33Z</date>
<key>BackgroundPeopleSuggestion</key>
<date>2020-04-12T19:50:22Z</date>
<date>2020-04-17T14:33:31Z</date>
<key>BackgroundUserBehaviorProcessor</key>
<date>2020-04-12T15:18:31Z</date>
<date>2020-04-17T07:32:04Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2020-04-12T21:11:07Z</date>
<date>2020-04-17T14:33:37Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-04-12T15:18:31Z</date>
<date>2020-04-17T07:32:00Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-04-12T19:50:23Z</date>
<date>2020-04-17T14:33:34Z</date>
<key>SiriPortraitDonation</key>
<date>2020-04-12T15:18:31Z</date>
<date>2020-04-17T07:32:04Z</date>
</dict>
</plist>

View File

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

View File

@@ -16,7 +16,7 @@ KEYWORDS = [
"United Kingdom",
]
PERSONS = ["Katie", "Suzy", "Maria"]
ALBUMS = ["Pumpkin Farm", "Last Import"]
ALBUMS = ["Pumpkin Farm", "Last Import", "AlbumInFolder"]
KEYWORDS_DICT = {
"Kids": 4,
"wedding": 2,
@@ -29,7 +29,7 @@ KEYWORDS_DICT = {
"United Kingdom": 1,
}
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1}
ALBUM_DICT = {"Pumpkin Farm": 3, "Last Import": 1}
ALBUM_DICT = {"Pumpkin Farm": 3, "Last Import": 1, "AlbumInFolder": 1}
def test_init():
@@ -124,7 +124,7 @@ def test_attributes():
)
assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm"]
assert p.albums == ["Pumpkin Farm", "AlbumInFolder"]
assert p.persons == ["Katie"]
assert p.path.endswith(
"/tests/Test-10.12.6.photoslibrary/Masters/2019/08/24/20190824-030824/Pumkins2.jpg"
@@ -148,7 +148,7 @@ def test_count():
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos()
assert len(photos) == 7
assert len(photos) == 9
def test_keyword_2():

View File

@@ -4,32 +4,33 @@ from osxphotos._constants import _UNKNOWN_PERSON
PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
# TOP_LEVEL_FOLDERS = ["Folder1"]
TOP_LEVEL_FOLDERS = ["Folder1"]
# TOP_LEVEL_CHILDREN = ["SubFolder1", "SubFolder2"]
TOP_LEVEL_CHILDREN = ["SubFolder1", "SubFolder2"]
# FOLDER_ALBUM_DICT = {"Folder1": [], "SubFolder1": [], "SubFolder2": ["AlbumInFolder"]}
FOLDER_ALBUM_DICT = {"Folder1": [], "SubFolder1": [], "SubFolder2": ["AlbumInFolder"]}
# ALBUM_NAMES = ["Pumpkin Farm", "AlbumInFolder", "Test Album", "Test Album"]
ALBUM_NAMES = ["Pumpkin Farm", "Test Album", "Test Album (1)"]
ALBUM_NAMES = ["Pumpkin Farm", "AlbumInFolder", "Test Album", "Test Album (1)"]
# ALBUM_PARENT_DICT = {
# "Pumpkin Farm": None,
# "AlbumInFolder": "SubFolder2",
# "Test Album": None,
# }
ALBUM_PARENT_DICT = {
"Pumpkin Farm": None,
"AlbumInFolder": "SubFolder2",
"Test Album": None,
"Test Album (1)": None,
}
# ALBUM_FOLDER_NAMES_DICT = {
# "Pumpkin Farm": [],
# "AlbumInFolder": ["Folder1", "SubFolder2"],
# "Test Album": [],
# }
ALBUM_FOLDER_NAMES_DICT = {
"Pumpkin Farm": [],
"AlbumInFolder": ["Folder1", "SubFolder2"],
"Test Album": [],
"Test Album (1)": [],
}
ALBUM_LEN_DICT = {
"Pumpkin Farm": 3,
"Test Album": 1,
"Test Album (1)": 1,
# "AlbumInFolder": 2,
"AlbumInFolder": 1,
}
ALBUM_PHOTO_UUID_DICT = {
@@ -40,10 +41,7 @@ ALBUM_PHOTO_UUID_DICT = {
],
"Test Album": ["8SOE9s0XQVGsuq4ONohTng"],
"Test Album (1)": ["15uNd7%8RguTEgNPKHfTWw"],
# "AlbumInFolder": [
# "3DD2C897-F19E-4CA6-8C22-B027D5A71907",
# "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51",
# ],
"AlbumInFolder": ["15uNd7%8RguTEgNPKHfTWw"],
}
UUID_DICT = {"two_albums": "8SOE9s0XQVGsuq4ONohTng"}
@@ -51,62 +49,57 @@ UUID_DICT = {"two_albums": "8SOE9s0XQVGsuq4ONohTng"}
######### Test FolderInfo ##########
def test_folders_1(caplog):
def test_folders_1():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
folders = photosdb.folders
assert folders == []
assert "Folders not yet implemented for this DB version" in caplog.text
# # top level folders
# folders = photosdb.folders
# assert len(folders) == 1
# top level folders
folders = photosdb.folder_info
assert len(folders) == 1
# # check folder names
# folder_names = [f.title for f in folders]
# assert sorted(folder_names) == sorted(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(caplog):
def test_folder_names():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# check folder names
folder_names = photosdb.folders
assert folder_names == []
assert "Folders not yet implemented for this DB version" in caplog.text
# assert sorted(folder_names) == sorted(TOP_LEVEL_FOLDERS)
assert folder_names == TOP_LEVEL_FOLDERS
assert sorted(folder_names) == sorted(TOP_LEVEL_FOLDERS)
@pytest.mark.skip(reason="Folders not yet impleted in Photos < 5")
def test_folders_len():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# top level folders
folders = photosdb.folders
folders = photosdb.folder_info
assert len(folders[0]) == len(TOP_LEVEL_CHILDREN)
@pytest.mark.skip(reason="Folders not yet impleted in Photos < 5")
def test_folders_children():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# top level folders
folders = photosdb.folders
folders = photosdb.folder_info
# children of top level folder
children = folders[0].folders
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].folders:
for child in folders[0].subfolders:
# check valid children FolderInfo
assert child.parent
assert child.parent.uuid == folders[0].uuid
@@ -116,38 +109,36 @@ def test_folders_children():
assert sorted(folder_names) == sorted(TOP_LEVEL_FOLDERS)
@pytest.mark.skip(reason="Folders not yet impleted in Photos < 5")
def test_folders_parent():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# top level folders
folders = photosdb.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.folders:
for child in folder.subfolders:
# children's parent uuid should match folder uuid
assert child.parent
assert child.parent.uuid == folder.uuid
@pytest.mark.skip(reason="Folders not yet impleted in Photos < 5")
def test_folders_albums():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
# top level folders
folders = photosdb.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.folders:
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])
@@ -162,14 +153,14 @@ def test_albums_1():
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
albums = photosdb.album_info
assert len(albums) == 3
assert len(albums) == 4
# check names
album_names = [a.title for a in albums]
assert sorted(album_names) == sorted(ALBUM_NAMES)
def test_albums_parent(caplog):
def test_albums_parent():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
@@ -178,11 +169,10 @@ def test_albums_parent(caplog):
for album in albums:
parent = album.parent.title if album.parent else None
assert "Folders not yet implemented for this DB version" in caplog.text
# assert parent == ALBUM_PARENT_DICT[album.title]
assert parent == ALBUM_PARENT_DICT[album.title]
def test_albums_folder_names(caplog):
def test_albums_folder_names():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
@@ -191,11 +181,10 @@ def test_albums_folder_names(caplog):
for album in albums:
folder_names = album.folder_names
assert "Folders not yet implemented for this DB version" in caplog.text
# assert folder_names == ALBUM_FOLDER_NAMES_DICT[album.title]
assert folder_names == ALBUM_FOLDER_NAMES_DICT[album.title]
def test_albums_folders(caplog):
def test_albums_folders():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
@@ -204,9 +193,8 @@ def test_albums_folders(caplog):
for album in albums:
folders = album.folder_list
assert "Folders not yet implemented for this DB version" in caplog.text
# folder_names = [f.title for f in folders]
# assert folder_names == ALBUM_FOLDER_NAMES_DICT[album.title]
folder_names = [f.title for f in folders]
assert folder_names == ALBUM_FOLDER_NAMES_DICT[album.title]
def test_albums_len():

View File

@@ -44,6 +44,18 @@ CLI_EXPORT_FILENAMES = [
"wedding_edited.jpeg",
]
CLI_EXPORT_FILENAMES_CURRENT = [
"1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg",
"DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg",
"DC99FBDD-7A52-4100-A5BB-344131646C30_edited.jpeg",
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg",
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51_edited.jpeg",
"D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg",
"F12384F6-CD17-4151-ACBA-AE0E3688539E.jpeg",
"6191423D-8DB8-4D4C-92BE-9BBBA308AAC4.jpeg",
"3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg",
]
CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1 = [
"2019/April/wedding.jpg",
"2019/July/Tulips.jpg",
@@ -210,21 +222,50 @@ def test_export():
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-name",
"--export-edited",
"-V",
],
)
result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"])
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES)
def test_export_current_name():
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_4), ".", "--current-name", "-V"]
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_CURRENT)
def test_export_skip_edited():
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--skip-edited", "-V"]
)
assert result.exit_code == 0
files = glob.glob("*")
assert "St James Park_edited.jpeg" not in files
def test_query_date():
import json
import osxphotos
@@ -272,7 +313,6 @@ def test_export_sidecar():
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-name",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
@@ -295,20 +335,30 @@ def test_export_live():
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, LIVE_PHOTOS_DB),
".",
"--live",
"--original-name",
"--export-live",
"-V",
],
export, [os.path.join(cwd, LIVE_PHOTOS_DB), ".", "--live", "-V"]
)
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_LIVE_ORIGINAL)
def test_export_skip_live():
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, LIVE_PHOTOS_DB), ".", "--skip-live", "-V"]
)
files = glob.glob("*")
assert "img_0728.mov" not in [f.lower() for f in files]
def test_export_raw():
import glob
import os
@@ -320,11 +370,40 @@ def test_export_raw():
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "-V"])
result = runner.invoke(
export,
[
os.path.join(cwd, RAW_PHOTOS_DB),
".",
"--current-name",
"--skip-edited",
"-V",
],
)
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_RAW)
# TODO: Update this once RAW db is added
# def test_skip_raw():
# import glob
# import os
# import os.path
# import osxphotos
# from osxphotos.__main__ import export
# runner = CliRunner()
# cwd = os.getcwd()
# # pylint: disable=not-context-manager
# with runner.isolated_filesystem():
# result = runner.invoke(
# export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "--skip-raw", "-V"]
# )
# files = glob.glob("*")
# for rawname in CLI_EXPORT_RAW:
# assert rawname.lower() not in [f.lower() for f in files]
def test_export_raw_original():
import glob
import os
@@ -337,7 +416,7 @@ def test_export_raw_original():
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "--original-name", "-V"]
export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "--skip-edited", "-V"]
)
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_RAW_ORIGINAL)
@@ -355,7 +434,7 @@ def test_export_raw_edited():
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "--export-edited", "-V"]
export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "--current-name", "-V"]
)
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_RAW_EDITED)
@@ -372,16 +451,7 @@ def test_export_raw_edited_original():
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, RAW_PHOTOS_DB),
".",
"--export-edited",
"--original-name",
"-V",
],
)
result = runner.invoke(export, [os.path.join(cwd, RAW_PHOTOS_DB), ".", "-V"])
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_RAW_EDITED_ORIGINAL)
@@ -403,7 +473,6 @@ def test_export_directory_template_1():
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-name",
"-V",
"--directory",
"{created.year}/{created.month}",
@@ -432,7 +501,6 @@ def test_export_directory_template_2():
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-name",
"-V",
"--directory",
"{place.name}",
@@ -461,7 +529,6 @@ def test_export_directory_template_3():
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-name",
"-V",
"--directory",
"{created.year}/{foo}",
@@ -485,14 +552,7 @@ def test_export_directory_template_album_1():
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-name",
"-V",
"--directory",
"{album}",
],
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--directory", "{album}"],
)
assert result.exit_code == 0
workdir = os.getcwd()
@@ -518,7 +578,6 @@ def test_export_directory_template_album_2():
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-name",
"-V",
"--directory",
"{album,NOALBUM}",
@@ -721,7 +780,7 @@ def test_no_folder_2_15():
assert item["albums"] == ["AlbumInFolder"]
def test_no_folder_1_14(caplog):
def test_no_folder_1_14():
# test --folder on 10.14
import json
import os
@@ -738,6 +797,5 @@ def test_no_folder_1_14(caplog):
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 0 # single element
assert "not yet implemented" in caplog.text
assert len(json_got) == 1 # single element
assert json_got[0]["uuid"] == "15uNd7%8RguTEgNPKHfTWw"

View File

@@ -104,7 +104,7 @@ def test_export_edited_default():
assert pathlib.Path(got_dest).name == FILENAME_DICT["current_edited"]
def test_export_edited_wrong_suffix(caplog):
def test_export_edited_wrong_suffix():
# export edited file with name provided but wrong suffix
# should produce a warning via logging.warning
import os
@@ -126,7 +126,6 @@ def test_export_edited_wrong_suffix(caplog):
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest, filename, edited=True)[0]
assert "Invalid destination suffix" in caplog.text
# assert "Invalid destination suffix" in caplog.text
assert got_dest == expected_dest
assert pathlib.Path(got_dest).name == filename

View File

@@ -18,7 +18,7 @@ KEYWORDS = [
"United Kingdom",
]
PERSONS = ["Katie", "Suzy", "Maria"]
ALBUMS = ["Pumpkin Farm", "Test Album", "Test Album (1)"]
ALBUMS = ["Pumpkin Farm", "AlbumInFolder", "Test Album", "Test Album (1)"]
KEYWORDS_DICT = {
"Kids": 4,
"wedding": 2,
@@ -31,7 +31,12 @@ KEYWORDS_DICT = {
"United Kingdom": 1,
}
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1}
ALBUM_DICT = {"Pumpkin Farm": 3, "Test Album": 1, "Test Album (1)": 1}
ALBUM_DICT = {
"Pumpkin Farm": 3,
"AlbumInFolder": 1,
"Test Album": 1,
"Test Album (1)": 1,
}
UUID_DICT = {
"favorite": "6bxcNnzRQKGnK4uPrCJ9UQ",
@@ -131,7 +136,9 @@ def test_attributes():
)
assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm", "Test Album (1)"]
assert sorted(p.albums) == sorted(
["Pumpkin Farm", "AlbumInFolder", "Test Album (1)"]
)
assert p.persons == ["Katie"]
assert p.path.endswith(
"/tests/Test-10.14.6.photoslibrary/Masters/2019/07/27/20190727-131650/Pumkins2.jpg"

View File

@@ -7,8 +7,13 @@ PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
PHOTOS_DB_PATH = "/Test-10.14.6.photoslibrary/database/photos.db"
PHOTOS_LIBRARY_PATH = "/Test-10.14.6.photoslibrary"
ALBUMS = ["Pumpkin Farm", "Test Album", "Test Album (1)"]
ALBUM_DICT = {"Pumpkin Farm": 3, "Test Album": 1, "Test Album (1)": 1}
ALBUMS = ["Pumpkin Farm", "AlbumInFolder", "Test Album", "Test Album (1)"]
ALBUM_DICT = {
"Pumpkin Farm": 3,
"AlbumInFolder": 1,
"Test Album": 1,
"Test Album (1)": 1,
}
def test_album_names():

View File

@@ -15,7 +15,7 @@ UUID_DICT = {
"0_2_0": "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4",
"folder_album_1": "3DD2C897-F19E-4CA6-8C22-B027D5A71907",
"folder_album_no_folder": "D79B8D77-BFFC-460B-9312-034F2877D35B",
"mojave_no_folder": "15uNd7%8RguTEgNPKHfTWw",
"mojave_album_1": "15uNd7%8RguTEgNPKHfTWw",
}
TEMPLATE_VALUES = {
@@ -341,17 +341,16 @@ def test_subst_multi_folder_albums_2():
def test_subst_multi_folder_albums_3(caplog):
""" Test substitutions for folder_album on < Photos 5 (not implemented) """
""" Test substitutions for folder_album on < Photos 5 """
import osxphotos
from osxphotos.template import render_filepath_template
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_14_6)
# photo in an album in a folder
photo = photosdb.photos(uuid=[UUID_DICT["mojave_no_folder"]])[0]
photo = photosdb.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
template = "{folder_album}"
expected = ["Pumpkin Farm", "Test Album (1)"]
expected = ["Folder1/SubFolder2/AlbumInFolder", "Pumpkin Farm", "Test Album (1)"]
rendered, unknown = render_filepath_template(template, photo)
assert sorted(rendered) == sorted(expected)
assert unknown == []
assert "not yet implemented" in caplog.text

View File

@@ -4,6 +4,8 @@ DB_LOCKED_10_12 = "./tests/Test-Lock-10_12.photoslibrary/database/photos.db"
DB_LOCKED_10_15 = "./tests/Test-Lock-10_15_1.photoslibrary/database/Photos.sqlite"
DB_UNLOCKED_10_15 = "./tests/Test-10.15.1.photoslibrary/database/photos.db"
UTI_DICT = {"public.jpeg": "jpeg", "com.canon.cr2-raw-image": "cr2"}
def test_debug_enable():
import osxphotos
@@ -89,3 +91,26 @@ def test_copy_file_norsrc():
result = _copy_file(src, temp_dir.name, norsrc=True)
assert result == 0
assert os.path.isfile(os.path.join(temp_dir.name, "wedding.jpg"))
def test_get_preferred_uti_extension():
from osxphotos.utils import get_preferred_uti_extension
for uti, extension in UTI_DICT.items():
assert get_preferred_uti_extension(uti) == extension
def test_findfiles():
import tempfile
import os.path
from osxphotos.utils import findfiles
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
fd = open(os.path.join(temp_dir.name, "file1.jpg"), "w+")
fd.close
fd = open(os.path.join(temp_dir.name, "file2.JPG"), "w+")
fd.close
files = findfiles("*.jpg", temp_dir.name)
assert len(files) == 2
assert "file1.jpg" in files
assert "file2.JPG" in files