Compare commits

..

7 Commits

Author SHA1 Message Date
Rhet Turnbull
1ebf995833 Bug fix for issue #172 2020-06-21 08:42:19 -07:00
Rhet Turnbull
538bac7ade More PhotoInfo.albums refactoring, closes #169 2020-06-21 08:18:11 -07:00
Rhet Turnbull
32806c8459 Updated CHANGELOG.md 2020-06-20 17:44:18 -07:00
Rhet Turnbull
cfabd0dbea Refactored album code in photosdb to fix issue #169 2020-06-20 17:31:33 -07:00
Rhet Turnbull
a23259948c Updated CHANGELOG.md 2020-06-20 08:43:42 -07:00
Rhet Turnbull
1212fad4ad Fixed PhotoInfo.albums, album_info for issue #169 2020-06-20 08:36:03 -07:00
Rhet Turnbull
567abe3311 Updated CHANGELOG.md 2020-06-18 22:52:21 -07:00
12 changed files with 258 additions and 109 deletions

View File

@@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.29.24](https://github.com/RhetTbull/osxphotos/compare/v0.29.23...v0.29.24)
> 21 June 2020
- Refactored album code in photosdb to fix issue #169 [`cfabd0d`](https://github.com/RhetTbull/osxphotos/commit/cfabd0dbead62c8ab6a774899239e5da5bfe1203)
#### [v0.29.23](https://github.com/RhetTbull/osxphotos/compare/v0.29.22...v0.29.23)
> 20 June 2020
- Fixed PhotoInfo.albums, album_info for issue #169 [`1212fad`](https://github.com/RhetTbull/osxphotos/commit/1212fad4adde0b4c6b2887392eed829d8d96d61d)
#### [v0.29.22](https://github.com/RhetTbull/osxphotos/compare/v0.29.19...v0.29.22)
> 19 June 2020
- Don't raise KeyError when SystemLibraryPath is absent [`#168`](https://github.com/RhetTbull/osxphotos/pull/168)
- Added check for export db in directory branch, closes #164 [`#164`](https://github.com/RhetTbull/osxphotos/issues/164)
- Added OSXPhotosDB.get_db_connection() [`43d28e7`](https://github.com/RhetTbull/osxphotos/commit/43d28e78f394fa33f8d88f64b56b7dc7258cd454)
- Added show() to photos_repl.py [`e98c3fe`](https://github.com/RhetTbull/osxphotos/commit/e98c3fe42912ac16d13675bf14154981089d41ea)
- Fixed get_last_library_path and get_system_library_path to not raise KeyError [`5a83218`](https://github.com/RhetTbull/osxphotos/commit/5a832181f73e082927c80864f2063e554906b06b)
- Don't raise KeyError when SystemLibraryPath is absent [`1fd0f96`](https://github.com/RhetTbull/osxphotos/commit/1fd0f96b14f0bc38e47bddb4cae12e19406324fb)
#### [v0.29.19](https://github.com/RhetTbull/osxphotos/compare/v0.29.18...v0.29.19)
> 14 June 2020

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.29.22"
__version__ = "0.29.26"

View File

@@ -21,12 +21,16 @@ import yaml
from .._constants import (
_MOVIE_TYPE,
_PHOTO_TYPE,
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_SHARED_ALBUM_KIND,
_PHOTOS_5_SHARED_PHOTO_PATH,
)
from ..albuminfo import AlbumInfo
from ..placeinfo import PlaceInfo4, PlaceInfo5
from ..phototemplate import PhotoTemplate
from ..placeinfo import PlaceInfo4, PlaceInfo5
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
@@ -341,21 +345,26 @@ class PhotoInfo:
@property
def albums(self):
""" list of albums picture is contained in """
albums = []
for album in self._info["albums"]:
if not self._db._dbalbum_details[album]["intrash"]:
albums.append(self._db._dbalbum_details[album]["title"])
return albums
try:
return self._albums
except AttributeError:
album_uuids = self._get_album_uuids()
self._albums = list(
{self._db._dbalbum_details[album]["title"] for album in album_uuids}
)
return self._albums
@property
def album_info(self):
""" list of AlbumInfo objects representing albums the photos is contained in """
albums = []
for album in self._info["albums"]:
if not self._db._dbalbum_details[album]["intrash"]:
albums.append(AlbumInfo(db=self._db, uuid=album))
return albums
try:
return self._album_info
except AttributeError:
album_uuids = self._get_album_uuids()
self._album_info = [
AlbumInfo(db=self._db, uuid=album) for album in album_uuids
]
return self._album_info
@property
def keywords(self):
@@ -655,6 +664,37 @@ class PhotoInfo:
""" Returns latitude, in degrees """
return self._info["latitude"]
def _get_album_uuids(self):
""" Return list of album UUIDs this photo is found in
Filters out albums in the trash and any special album types
Returns: list of album UUIDs
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
version4 = True
album_kind = [_PHOTOS_4_ALBUM_KIND]
else:
version4 = False
album_kind = [_PHOTOS_5_SHARED_ALBUM_KIND, _PHOTOS_5_ALBUM_KIND]
album_list = []
for album in self._info["albums"]:
detail = self._db._dbalbum_details[album]
if (
detail["kind"] in album_kind
and not detail["intrash"]
and (
not version4
# in Photos <= 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
# but should not be listed here; they can be distinguished by looking
# for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
or (version4 and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER)
)
):
album_list.append(album)
return album_list
def __repr__(self):
return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})"
@@ -772,12 +812,18 @@ class PhotoInfo:
}
return json.dumps(pic)
# compare two PhotoInfo objects for equality
def __eq__(self, other):
""" Compare two PhotoInfo objects for equality """
# Can't just compare the two __dicts__ because some methods (like albums)
# memoize their value once called in an instance variable (e.g. self._albums)
if isinstance(other, self.__class__):
return self.__dict__ == other.__dict__
return (
self._db.db_path == other._db.db_path
and self.uuid == other.uuid
and self._info == other._info
)
return False
def __ne__(self, other):
""" Compare two PhotoInfo objects for inequality """
return not self.__eq__(other)

View File

@@ -311,18 +311,16 @@ class PhotosDB:
def albums_as_dict(self):
""" return albums as dict of albums, count in reverse sorted order (descending) """
albums = {}
album_keys = [
k
for k in self._dbalbums_album.keys()
if self._dbalbum_details[k]["cloudownerhashedpersonid"] is None
and not self._dbalbum_details[k]["intrash"]
]
for k in album_keys:
title = self._dbalbum_details[k]["title"]
if title in albums:
albums[title] += len(self._dbalbums_album[k])
album_keys = self._get_album_uuids(shared=False)
for album in album_keys:
title = self._dbalbum_details[album]["title"]
if album in self._dbalbums_album:
try:
albums[title] += len(self._dbalbums_album[album])
except KeyError:
albums[title] = len(self._dbalbums_album[album])
else:
albums[title] = len(self._dbalbums_album[k])
albums[title] = 0 # empty album
albums = dict(sorted(albums.items(), key=lambda kv: kv[1], reverse=True))
return albums
@@ -331,25 +329,17 @@ class PhotosDB:
""" returns shared albums as dict of albums, count in reverse sorted order (descending)
valid only on Photos 5; on Photos <= 4, prints warning and returns empty dict """
# if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(
f"albums_shared not implemented for Photos versions < {_PHOTOS_5_VERSION}"
)
return {}
albums = {}
album_keys = [
k
for k in self._dbalbums_album.keys()
if self._dbalbum_details[k]["cloudownerhashedpersonid"] is not None
]
for k in album_keys:
title = self._dbalbum_details[k]["title"]
if title in albums:
albums[title] += len(self._dbalbums_album[k])
album_keys = self._get_album_uuids(shared=True)
for album in album_keys:
title = self._dbalbum_details[album]["title"]
if album in self._dbalbums_album:
try:
albums[title] += len(self._dbalbums_album[album])
except KeyError:
albums[title] = len(self._dbalbums_album[album])
else:
albums[title] = len(self._dbalbums_album[k])
albums[title] = 0 # empty album
albums = dict(sorted(albums.items(), key=lambda kv: kv[1], reverse=True))
return albums
@@ -410,32 +400,28 @@ class PhotosDB:
@property
def album_info(self):
""" return list of AlbumInfo objects for each album in the photos database """
return [
AlbumInfo(db=self, uuid=album)
for album in self._dbalbums_album.keys()
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is None
and not self._dbalbum_details[album]["intrash"]
]
try:
return self._album_info
except AttributeError:
self._album_info = [
AlbumInfo(db=self, uuid=album)
for album in self._get_album_uuids(shared=False)
]
return self._album_info
@property
def album_info_shared(self):
""" return list of AlbumInfo objects for each shared album in the photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list """
# if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(
f"albums_shared not implemented for Photos versions < {_PHOTOS_5_VERSION}"
)
return []
return [
AlbumInfo(db=self, uuid=album)
for album in self._dbalbums_album.keys()
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is not None
and not self._dbalbum_details[album]["intrash"]
]
try:
return self._album_info_shared
except AttributeError:
self._album_info_shared = [
AlbumInfo(db=self, uuid=album)
for album in self._get_album_uuids(shared=True)
]
return self._album_info_shared
@property
def albums(self):
@@ -444,13 +430,11 @@ class PhotosDB:
# Could be more than one album with same name
# Right now, they are treated as same album and photos are combined from albums with same name
albums = {
self._dbalbum_details[album]["title"]
for album in self._dbalbums_album.keys()
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is None
and not self._dbalbum_details[album]["intrash"]
}
return list(albums)
try:
return self._albums
except AttributeError:
self._albums = self._get_albums(shared=False)
return self._albums
@property
def albums_shared(self):
@@ -462,19 +446,11 @@ class PhotosDB:
# if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(
f"album_names_shared not implemented for Photos versions < {_PHOTOS_5_VERSION}"
)
return []
albums = {
self._dbalbum_details[album]["title"]
for album in self._dbalbums_album.keys()
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is not None
and not self._dbalbum_details[album]["intrash"]
}
return list(albums)
try:
return self._albums_shared
except AttributeError:
self._albums_shared = self._get_albums(shared=True)
return self._albums_shared
@property
def db_version(self):
@@ -634,6 +610,8 @@ class PhotosDB:
"folderUuid": album[5],
"albumType": album[6],
"albumSubclass": album[7],
# for compatability with Photos 5 where album kind is ZKIND
"kind": album[7],
}
# get details about folders
@@ -2098,6 +2076,65 @@ class PhotosDB:
hierarchy = _recurse_folder_hierarchy(folders)
return hierarchy
def _get_album_uuids(self, shared=False):
""" Return list of album UUIDs found in photos database
Filters out albums in the trash and any special album types
Args:
shared: boolean; if True, returns shared albums, else normal albums
Returns: list of album UUIDs
"""
if self._db_version <= _PHOTOS_4_VERSION:
version4 = True
if shared:
logging.warning(
f"Shared albums not implemented for Photos library version {self._db_version}"
)
return [] # not implemented for _PHOTOS_4_VERSION
else:
album_kind = _PHOTOS_4_ALBUM_KIND
else:
version4 = False
album_kind = _PHOTOS_5_SHARED_ALBUM_KIND if shared else _PHOTOS_5_ALBUM_KIND
album_list = []
# look through _dbalbum_details because _dbalbums_album won't have empty albums it
for album, detail in self._dbalbum_details.items():
if (
detail["kind"] == album_kind
and not detail["intrash"]
and (
(shared and detail["cloudownerhashedpersonid"] is not None)
or (not shared and detail["cloudownerhashedpersonid"] is None)
)
and (
not version4
# in Photos 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
# but should not be listed here; they can be distinguished by looking
# for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
or (version4 and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER)
)
):
album_list.append(album)
return album_list
def _get_albums(self, shared=False):
""" Return list of album titles found in photos database
Albums may have duplicate titles -- these will be treated as a single album.
Filters out albums in the trash and any special album types
Args:
shared: boolean; if True, returns shared albums, else normal albums
Returns: list of album names
"""
album_uuids = self._get_album_uuids(shared=shared)
return list({self._dbalbum_details[album]["title"] for album in album_uuids})
def photos(
self,
keywords=None,

View File

@@ -503,7 +503,6 @@ class PlaceInfo5(PlaceInfo):
""" revgeoloc_bplist: a binary plist blob containing
a serialized PLRevGeoLocationInfo object """
self._bplist = revgeoloc_bplist
# todo: check for None?
self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist)
self._process_place_info()
@@ -535,16 +534,21 @@ class PlaceInfo5(PlaceInfo):
@property
def address(self):
addr = self._plrevgeoloc.postalAddress
return PostalAddress(
street=addr._street,
sub_locality=addr._subLocality,
city=addr._city,
sub_administrative_area=addr._subAdministrativeArea,
state_province=addr._state,
postal_code=addr._postalCode,
country=addr._country,
iso_country_code=addr._ISOCountryCode,
)
if addr is not None:
postal_address = PostalAddress(
street=addr._street,
sub_locality=addr._subLocality,
city=addr._city,
sub_administrative_area=addr._subAdministrativeArea,
state_province=addr._state,
postal_code=addr._postalCode,
country=addr._country,
iso_country_code=addr._ISOCountryCode,
)
else:
postal_address = None
return postal_address
def _process_place_info(self):
""" Process sortedPlaceInfos to set self._name and self._names """
@@ -632,5 +636,5 @@ class PlaceInfo5(PlaceInfo):
"country_code": self.country_code,
"ishome": self.ishome,
"address_str": self.address_str,
"address": self.address._asdict(),
"address": self.address._asdict() if self.address is not None else None,
}

View File

@@ -16,7 +16,7 @@ KEYWORDS = [
"United Kingdom",
]
PERSONS = ["Katie", "Suzy", "Maria"]
ALBUMS = ["Pumpkin Farm", "Last Import", "AlbumInFolder"]
ALBUMS = ["Pumpkin Farm", "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, "AlbumInFolder": 1}
ALBUM_DICT = {"Pumpkin Farm": 3, "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", "AlbumInFolder"]
assert sorted(p.albums) == ["AlbumInFolder", "Pumpkin Farm"]
assert p.persons == ["Katie"]
assert p.path.endswith(
"/tests/Test-10.12.6.photoslibrary/Masters/2019/08/24/20190824-030824/Pumkins2.jpg"

View File

@@ -229,6 +229,7 @@ def test_albums_photos():
def test_photoinfo_albums():
""" Test PhotoInfo.albums """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
@@ -238,7 +239,20 @@ def test_photoinfo_albums():
assert "Pumpkin Farm" in albums
def test_photoinfo_albums_2():
""" Test that PhotoInfo.albums returns only number albums expected """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["two_albums"]])
albums = photos[0].albums
assert len(albums) == 2
def test_photoinfo_album_info():
""" test PhotoInfo.album_info """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
@@ -249,4 +263,4 @@ def test_photoinfo_album_info():
assert album_info[0].title in ["Pumpkin Farm", "Test Album"]
assert album_info[1].title in ["Pumpkin Farm", "Test Album"]
assert photos[0] in album_info[0].photos
assert photos[0].uuid in [photo.uuid for photo in album_info[0].photos]

View File

@@ -244,4 +244,4 @@ def test_photoinfo_album_info():
assert album_info[0].title in ["Pumpkin Farm", "Test Album"]
assert album_info[1].title in ["Pumpkin Farm", "Test Album"]
assert photos[0] in album_info[0].photos
assert photos[0].uuid in [photo.uuid for photo in album_info[0].photos]

View File

@@ -106,6 +106,7 @@ def test_init4():
except:
pass
def test_init5(mocker):
# test failed get_last_library_path
import osxphotos
@@ -116,7 +117,6 @@ def test_init5(mocker):
# get_last_library actually in utils but need to patch it in photosdb because it's imported into photosdb
# because of the layout of photosdb/ need to patch it this way...don't really understand why, but it works
mocker.patch("osxphotos.photosdb.photosdb.get_last_library_path", new=bad_library)
with pytest.raises(Exception):
assert osxphotos.PhotosDB()
@@ -207,7 +207,7 @@ def test_attributes():
)
assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm", "Test Album", "Multi Keyword"]
assert sorted(p.albums) == ["Multi Keyword", "Pumpkin Farm", "Test Album"]
assert p.persons == ["Katie"]
assert p.path.endswith(
"tests/Test-10.15.1.photoslibrary/originals/D/D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg"

View File

@@ -215,7 +215,7 @@ def test_attributes():
)
assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm", "Test Album"]
assert sorted(p.albums) == ["Pumpkin Farm", "Test Album"]
assert p.persons == ["Katie"]
assert p.path.endswith(
"tests/Test-10.15.4.photoslibrary/originals/D/D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg"

View File

@@ -28,6 +28,7 @@ ALBUMS = [
"AlbumInFolder",
"Raw",
"I have a deleted twin", # there's an empty album with same name that has been deleted
"EmptyAlbum",
]
KEYWORDS_DICT = {
"Kids": 4,
@@ -47,6 +48,7 @@ ALBUM_DICT = {
"AlbumInFolder": 2,
"Raw": 4,
"I have a deleted twin": 1,
"EmptyAlbum": 0,
} # Note: there are 2 albums named "Test Album" for testing duplicate album names
UUID_DICT = {
@@ -63,6 +65,7 @@ UUID_DICT = {
"no_external_edit": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51",
"export": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg"
"export_tif": "8846E3E6-8AC8-4857-8448-E3D025784410",
"in_album": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg"
}
UUID_PUMPKIN_FARM = [
@@ -225,7 +228,7 @@ def test_attributes():
)
assert p.description == "Girl holding pumpkin"
assert p.title == "I found one!"
assert p.albums == ["Pumpkin Farm", "Test Album"]
assert sorted(p.albums) == ["Pumpkin Farm", "Test Album"]
assert p.persons == ["Katie"]
assert p.path.endswith(
"tests/Test-10.15.5.photoslibrary/originals/D/D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg"
@@ -797,11 +800,29 @@ def test_export_14(caplog):
def test_eq():
""" Test equality of two PhotoInfo objects """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos1 = photosdb.photos(uuid=[UUID_DICT["export"]])
photos2 = photosdb.photos(uuid=[UUID_DICT["export"]])
photosdb1 = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb2 = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos1 = photosdb1.photos(uuid=[UUID_DICT["export"]])
photos2 = photosdb2.photos(uuid=[UUID_DICT["export"]])
assert photos1[0] == photos2[0]
def test_eq_2():
""" Test equality of two PhotoInfo objects when one has memoized property """
import osxphotos
photosdb1 = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb2 = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos1 = photosdb1.photos(uuid=[UUID_DICT["in_album"]])
photos2 = photosdb2.photos(uuid=[UUID_DICT["in_album"]])
# memoize a value
albums = photos1[0].albums
assert albums
assert photos1[0] == photos2[0]

View File

@@ -261,6 +261,7 @@ ALBUMS_JSON = {
"AlbumInFolder": 2,
"Test Album": 2,
"I have a deleted twin": 1,
"EmptyAlbum": 0,
},
"shared albums": {},
}
@@ -354,7 +355,10 @@ def test_query_uuid():
for key_ in json_expected:
assert key_ in json_got
if key_ != "path":
assert json_expected[key_] == json_got[key_]
if isinstance(json_expected[key_], list):
assert sorted(json_expected[key_]) == sorted(json_got[key_])
else:
assert json_expected[key_] == json_got[key_]
else:
assert json_expected[key_] in json_got[key_]