Bug memory leak 1047 (#1052)

* Fix for memory leak, #1047

* Refactored photos_by_uuid, AlbumInfo.asdict for speed optimization

* Fix for huge crash log, #1048

* Fix for error on export #1046

* add rajscode as a contributor for bug (#1049)

* update README.md [skip ci]

* update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* add wernerzj as a contributor for bug (#1050)

* update README.md [skip ci]

* update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
Co-authored-by: Rhet Turnbull <rturnbull@gmail.com>

* Fixed all-contributors badge

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
This commit is contained in:
Rhet Turnbull
2023-04-10 16:50:17 -07:00
committed by GitHub
parent 6f88f19950
commit 956cecfa30
7 changed files with 111 additions and 157 deletions

View File

@@ -50,9 +50,8 @@ def sort_list_by_keys(values, sort_keys):
ValueError: raised if len(values) != len(sort_keys) ValueError: raised if len(values) != len(sort_keys)
""" """
if len(values) != len(sort_keys): if len(values) != len(sort_keys):
return ValueError("values and sort_keys must have same length") raise ValueError("values and sort_keys must be same length")
return [x for _, x in sorted(zip(sort_keys, values))]
return list(zip(*sorted(zip(sort_keys, values))))[1]
class AlbumInfoBaseClass: class AlbumInfoBaseClass:
@@ -166,14 +165,13 @@ class AlbumInfoBaseClass:
return self._owner return self._owner
def asdict(self): def asdict(self):
"""Return album info as a dict""" """Return album info as a dict; does not include photos"""
return { return {
"uuid": self.uuid, "uuid": self.uuid,
"creation_date": self.creation_date, "creation_date": self.creation_date,
"start_date": self.start_date, "start_date": self.start_date,
"end_date": self.end_date, "end_date": self.end_date,
"owner": self.owner, "owner": self.owner,
"photos": [p.uuid for p in self.photos],
} }
def __len__(self): def __len__(self):
@@ -299,7 +297,7 @@ class AlbumInfo(AlbumInfoBaseClass):
) )
def asdict(self): def asdict(self):
"""Return album info as a dict""" """Return album info as a dict; does not include photos"""
dict_data = super().asdict() dict_data = super().asdict()
dict_data["title"] = self.title dict_data["title"] = self.title
dict_data["folder_names"] = self.folder_names dict_data["folder_names"] = self.folder_names
@@ -362,14 +360,13 @@ class ImportInfo(AlbumInfoBaseClass):
return self._photos return self._photos
def asdict(self): def asdict(self):
"""Return import info as a dict""" """Return import info as a dict; does not include photos"""
return { return {
"uuid": self.uuid, "uuid": self.uuid,
"creation_date": self.creation_date, "creation_date": self.creation_date,
"start_date": self.start_date, "start_date": self.start_date,
"end_date": self.end_date, "end_date": self.end_date,
"title": self.title, "title": self.title,
"photos": [p.uuid for p in self.photos],
} }
def __bool__(self): def __bool__(self):

View File

@@ -911,7 +911,8 @@ def export(
# capture locals for use with ConfigOptions before changing any of them # capture locals for use with ConfigOptions before changing any of them
locals_ = locals() locals_ = locals()
set_crash_data("locals", locals_) crash_data = locals_.copy()
set_crash_data("locals", crash_data)
# config expects --verbose to be named "verbose" not "verbose_flag" # config expects --verbose to be named "verbose" not "verbose_flag"
locals_["verbose"] = verbose_flag locals_["verbose"] = verbose_flag
@@ -2254,7 +2255,7 @@ def export_photo_to_directory(
err=True, err=True,
) )
if tries > retry: if tries > retry:
results.error.append((str(pathlib.Path(dest) / filename), e)) results.error.append((str(pathlib.Path(dest) / filename), str(e)))
break break
else: else:
rich_echo( rich_echo(

View File

@@ -1521,10 +1521,10 @@ class PhotoExporter:
if not options.dry_run: if not options.dry_run:
warning_, error_ = self._write_exif_data(src, options=options) warning_, error_ = self._write_exif_data(src, options=options)
if warning_: if warning_:
exiftool_results.exiftool_warning.append((dest, warning_)) exiftool_results.exiftool_warning.append((str(dest), str(warning_)))
if error_: if error_:
exiftool_results.exiftool_error.append((dest, error_)) exiftool_results.exiftool_error.append((str(dest), str(error_)))
exiftool_results.error.append((dest, error_)) exiftool_results.error.append((str(dest), str(error_)))
exiftool_results.exif_updated.append(dest) exiftool_results.exif_updated.append(dest)
exiftool_results.to_touch.append(dest) exiftool_results.to_touch.append(dest)

View File

@@ -517,39 +517,26 @@ class PhotoInfo:
@property @property
def person_info(self): def person_info(self):
"""list of PersonInfo objects for person in picture""" """list of PersonInfo objects for person in picture"""
try: return [PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"]]
return self._personinfo
except AttributeError:
self._personinfo = [
PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"]
]
return self._personinfo
@property @property
def face_info(self): def face_info(self):
"""list of FaceInfo objects for faces in picture""" """list of FaceInfo objects for faces in picture"""
try: try:
return self._faceinfo faces = self._db._db_faceinfo_uuid[self._uuid]
except AttributeError: self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
try: except KeyError:
faces = self._db._db_faceinfo_uuid[self._uuid] # no faces
self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces] self._faceinfo = []
except KeyError: return self._faceinfo
# no faces
self._faceinfo = []
return self._faceinfo
@property @property
def moment_info(self): def moment_info(self):
"""Moment photo belongs to""" """Moment photo belongs to"""
try: try:
return self._moment return MomentInfo(db=self._db, moment_pk=self._info["momentID"])
except AttributeError: except ValueError:
try: return None
self._moment = MomentInfo(db=self._db, moment_pk=self._info["momentID"])
except ValueError:
self._moment = None
return self._moment
@property @property
def albums(self): def albums(self):
@@ -566,65 +553,41 @@ class PhotoInfo:
@property @property
def burst_albums(self): def burst_albums(self):
"""If photo is burst photo, list of albums it is contained in as well as any albums the key photo is contained in, otherwise returns self.albums""" """If photo is burst photo, list of albums it is contained in as well as any albums the key photo is contained in, otherwise returns self.albums"""
try: burst_albums = list(self.albums)
return self._burst_albums for photo in self.burst_photos:
except AttributeError: if photo.burst_key:
burst_albums = list(self.albums) burst_albums.extend(photo.albums)
for photo in self.burst_photos: return list(set(burst_albums))
if photo.burst_key:
burst_albums.extend(photo.albums)
self._burst_albums = list(set(burst_albums))
return self._burst_albums
@property @property
def album_info(self): def album_info(self):
"""list of AlbumInfo objects representing albums the photo is contained in""" """list of AlbumInfo objects representing albums the photo is contained in"""
try: album_uuids = self._get_album_uuids()
return self._album_info return [AlbumInfo(db=self._db, uuid=album) for album in album_uuids]
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 @property
def burst_album_info(self): def burst_album_info(self):
"""If photo is a burst photo, returns list of AlbumInfo objects representing albums the photo is contained in as well as albums the burst key photo is contained in, otherwise returns self.album_info.""" """If photo is a burst photo, returns list of AlbumInfo objects representing albums the photo is contained in as well as albums the burst key photo is contained in, otherwise returns self.album_info."""
try: burst_album_info = list(self.album_info)
return self._burst_album_info for photo in self.burst_photos:
except AttributeError: if photo.burst_key:
burst_album_info = list(self.album_info) burst_album_info.extend(photo.album_info)
for photo in self.burst_photos: return list(set(burst_album_info))
if photo.burst_key:
burst_album_info.extend(photo.album_info)
self._burst_album_info = list(set(burst_album_info))
return self._burst_album_info
@property @property
def import_info(self): def import_info(self):
"""ImportInfo object representing import session for the photo or None if no import session""" """ImportInfo object representing import session for the photo or None if no import session"""
try: return (
return self._import_info ImportInfo(db=self._db, uuid=self._info["import_uuid"])
except AttributeError: if self._info["import_uuid"] is not None
self._import_info = ( else None
ImportInfo(db=self._db, uuid=self._info["import_uuid"]) )
if self._info["import_uuid"] is not None
else None
)
return self._import_info
@property @property
def project_info(self): def project_info(self):
"""list of AlbumInfo objects representing projects for the photo or None if no projects""" """list of AlbumInfo objects representing projects for the photo or None if no projects"""
try: project_uuids = self._get_album_uuids(project=True)
return self._project_info return [ProjectInfo(db=self._db, uuid=album) for album in project_uuids]
except AttributeError:
project_uuids = self._get_album_uuids(project=True)
self._project_info = [
ProjectInfo(db=self._db, uuid=album) for album in project_uuids
]
return self._project_info
@property @property
def keywords(self): def keywords(self):
@@ -638,8 +601,7 @@ class PhotoInfo:
# in this case, return None so result is the same as if title had never been set (which returns NULL) # in this case, return None so result is the same as if title had never been set (which returns NULL)
# issue #512 # issue #512
title = self._info["name"] title = self._info["name"]
title = None if title == "" else title return None if title == "" else title
return title
@property @property
def uuid(self): def uuid(self):
@@ -1777,41 +1739,31 @@ class PhotoInfo:
} }
return yaml.dump(info, sort_keys=False) return yaml.dump(info, sort_keys=False)
def asdict(self): def asdict(self, shallow: bool = True) -> dict[str, Any]:
"""return dict representation""" """Return dict representation of PhotoInfo object.
Args:
shallow: if True, return shallow representation (does not contain folder_info, person_info, etc.)
Returns:
dict representation of PhotoInfo object
Note:
The shallow representation is used internally by export as it contains only the subset of data needed for export.
"""
adjustments = self.adjustments.asdict() if self.adjustments else {}
album_info = [album.asdict() for album in self.album_info]
burst_album_info = [a.asdict() for a in self.burst_album_info]
burst_photos = [p.uuid for p in self.burst_photos]
comments = [comment.asdict() for comment in self.comments] comments = [comment.asdict() for comment in self.comments]
exif_info = dataclasses.asdict(self.exif_info) if self.exif_info else {} exif_info = dataclasses.asdict(self.exif_info) if self.exif_info else {}
face_info = [face.asdict() for face in self.face_info] face_info = [face.asdict() for face in self.face_info]
folders = {album.title: album.folder_names for album in self.album_info} folders = {album.title: album.folder_names for album in self.album_info}
import_info = self.import_info.asdict() if self.import_info else {}
likes = [like.asdict() for like in self.likes] likes = [like.asdict() for like in self.likes]
person_info = [p.asdict() for p in self.person_info]
place = self.place.asdict() if self.place else {} place = self.place.asdict() if self.place else {}
project_info = [p.asdict() for p in self.project_info]
score = dataclasses.asdict(self.score) if self.score else {} score = dataclasses.asdict(self.score) if self.score else {}
search_info = self.search_info.asdict() if self.search_info else {}
search_info_normalized = (
self.search_info_normalized.asdict() if self.search_info_normalized else {}
)
return { dict_data = {
"adjustments": adjustments,
"album_info": album_info,
"albums": self.albums, "albums": self.albums,
"burst_album_info": burst_album_info,
"burst_albums": self.burst_albums,
"burst_default_pick": self.burst_default_pick,
"burst_key": self.burst_key,
"burst_photos": burst_photos,
"burst_selected": self.burst_selected,
"burst": self.burst, "burst": self.burst,
"cloud_guid": self.cloud_guid, "cloud_guid": self.cloud_guid,
"cloud_metadata": self.cloud_metadata,
"cloud_owner_hashed_id": self.cloud_owner_hashed_id, "cloud_owner_hashed_id": self.cloud_owner_hashed_id,
"comments": comments, "comments": comments,
"date_added": self.date_added, "date_added": self.date_added,
@@ -1831,7 +1783,6 @@ class PhotoInfo:
"hdr": self.hdr, "hdr": self.hdr,
"height": self.height, "height": self.height,
"hidden": self.hidden, "hidden": self.hidden,
"import_info": import_info,
"incloud": self.incloud, "incloud": self.incloud,
"intrash": self.intrash, "intrash": self.intrash,
"iscloudasset": self.iscloudasset, "iscloudasset": self.iscloudasset,
@@ -1841,7 +1792,6 @@ class PhotoInfo:
"israw": self.israw, "israw": self.israw,
"isreference": self.isreference, "isreference": self.isreference,
"keywords": self.keywords, "keywords": self.keywords,
"labels_normalized": self.labels_normalized,
"labels": self.labels, "labels": self.labels,
"latitude": self._latitude, "latitude": self._latitude,
"library": self._db._library_path, "library": self._db._library_path,
@@ -1857,22 +1807,17 @@ class PhotoInfo:
"original_width": self.original_width, "original_width": self.original_width,
"owner": self.owner, "owner": self.owner,
"panorama": self.panorama, "panorama": self.panorama,
"path_derivatives": self.path_derivatives,
"path_edited_live_photo": self.path_edited_live_photo, "path_edited_live_photo": self.path_edited_live_photo,
"path_edited": self.path_edited, "path_edited": self.path_edited,
"path_live_photo": self.path_live_photo, "path_live_photo": self.path_live_photo,
"path_raw": self.path_raw, "path_raw": self.path_raw,
"path": self.path, "path": self.path,
"person_info": person_info,
"persons": self.persons, "persons": self.persons,
"place": place, "place": place,
"portrait": self.portrait, "portrait": self.portrait,
"project_info": project_info,
"raw_original": self.raw_original, "raw_original": self.raw_original,
"score": score, "score": score,
"screenshot": self.screenshot, "screenshot": self.screenshot,
"search_info_normalized": search_info_normalized,
"search_info": search_info,
"selfie": self.selfie, "selfie": self.selfie,
"shared": self.shared, "shared": self.shared,
"slow_mo": self.slow_mo, "slow_mo": self.slow_mo,
@@ -1888,7 +1833,38 @@ class PhotoInfo:
"width": self.width, "width": self.width,
} }
def json(self, indent: int | None = None, shallow: bool = False) -> str: # non-shallow keys
if not shallow:
dict_data["album_info"] = [album.asdict() for album in self.album_info]
dict_data["path_derivatives"] = self.path_derivatives
dict_data["adjustments"] = (
self.adjustments.asdict() if self.adjustments else {}
)
dict_data["burst_album_info"] = [a.asdict() for a in self.burst_album_info]
dict_data["burst_albums"] = self.burst_albums
dict_data["burst_default_pick"] = self.burst_default_pick
dict_data["burst_key"] = self.burst_key
dict_data["burst_photos"] = [p.uuid for p in self.burst_photos]
dict_data["burst_selected"] = self.burst_selected
dict_data["cloud_metadata"] = self.cloud_metadata
dict_data["import_info"] = (
self.import_info.asdict() if self.import_info else {}
)
dict_data["labels_normalized"] = self.labels_normalized
dict_data["person_info"] = [p.asdict() for p in self.person_info]
dict_data["project_info"] = [p.asdict() for p in self.project_info]
dict_data["search_info"] = (
self.search_info.asdict() if self.search_info else {}
)
dict_data["search_info_normalized"] = (
self.search_info_normalized.asdict()
if self.search_info_normalized
else {}
)
return dict_data
def json(self, indent: int | None = None, shallow: bool = True) -> str:
"""Return JSON representation """Return JSON representation
Args: Args:
@@ -1897,36 +1873,16 @@ class PhotoInfo:
Returns: Returns:
JSON string JSON string
Note:
The shallow representation is used internally by export as it contains only the subset of data needed for export.
""" """
def default(o): def default(o):
if isinstance(o, (datetime.date, datetime.datetime)): if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat() return o.isoformat()
dict_data = self.asdict() dict_data = self.asdict(shallow=True) if shallow else self.asdict(shallow=False)
if shallow:
# delete items that are not needed for shallow JSON
# these are removed to match behavior of osxphotos < 0.59.0 (See #999, #1039)
for key in [
"adjustments",
"album_info",
"burst_album_info",
"burst_albums",
"burst_default_pick",
"burst_key",
"burst_photos",
"burst_selected",
"cloud_metadata",
"import_info",
"labels_normalized",
"path_derivatives",
"person_info",
"project_info",
"search_info_normalized",
"search_info",
]:
del dict_data[key]
for k, v in dict_data.items(): for k, v in dict_data.items():
# sort lists such as keywords so JSON is consistent # sort lists such as keywords so JSON is consistent
@@ -1949,15 +1905,9 @@ class PhotoInfo:
if isinstance(o, (datetime.date, datetime.datetime)): if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat() return o.isoformat()
dict_data = self.asdict() dict_data = self.asdict(shallow=True)
for k in [ for k in ["face_info", "visible"]:
"album_info",
"burst_album_info",
"face_info",
"person_info",
"visible",
]:
del dict_data[k] del dict_data[k]
for k, v in dict_data.items(): for k, v in dict_data.items():

View File

@@ -516,7 +516,8 @@ class PhotosDB:
@property @property
def album_info_shared(self): def album_info_shared(self):
"""return list of AlbumInfo objects for each shared album in the photos database """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""" 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 _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
try: try:
return self._album_info_shared return self._album_info_shared
@@ -543,7 +544,8 @@ class PhotosDB:
@property @property
def albums_shared(self): def albums_shared(self):
"""return list of shared albums found in photos database """return list of shared albums found in photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list""" only valid for Photos 5; on Photos <= 4, prints warning and returns empty list
"""
# Could be more than one album with same name # 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 # Right now, they are treated as same album and photos are combined from albums with same name
@@ -3053,7 +3055,6 @@ class PhotosDB:
except KeyError: except KeyError:
return None return None
# TODO: add to docs and test
def photos_by_uuid(self, uuids): def photos_by_uuid(self, uuids):
"""Returns a list of photos with UUID in uuids. """Returns a list of photos with UUID in uuids.
Does not generate error if invalid or missing UUID passed. Does not generate error if invalid or missing UUID passed.
@@ -3066,14 +3067,11 @@ class PhotosDB:
Returns: Returns:
list of PhotoInfo instance for photo with UUID matching uuid or [] if no match list of PhotoInfo instance for photo with UUID matching uuid or [] if no match
""" """
photos = [] return [
for uuid in uuids: PhotoInfo(db=self, uuid=uuid, info=self._dbphotos[uuid])
try: for uuid in uuids
photos.append(PhotoInfo(db=self, uuid=uuid, info=self._dbphotos[uuid])) if uuid in self._dbphotos
except KeyError: ]
# ignore missing/invlaid UUID
pass
return photos
def query(self, options: QueryOptions) -> List[PhotoInfo]: def query(self, options: QueryOptions) -> List[PhotoInfo]:
"""Run a query against PhotosDB to extract the photos based on user supplied options """Run a query against PhotosDB to extract the photos based on user supplied options

View File

@@ -1497,7 +1497,7 @@ def test_json(photosdb: osxphotos.PhotosDB):
def test_json_indent(photosdb: osxphotos.PhotosDB): def test_json_indent(photosdb: osxphotos.PhotosDB):
"""Test PhotoInfo.json() with indent""" """Test PhotoInfo.json() with indent"""
photo = photosdb.get_photo(UUID_DICT["favorite"]) photo = photosdb.get_photo(UUID_DICT["favorite"])
photo_dict = json.loads(photo.json(indent=4)) photo_dict = json.loads(photo.json(indent=4, shallow=False))
assert photo_dict["favorite"] assert photo_dict["favorite"]
assert "album_info" in photo_dict assert "album_info" in photo_dict

View File

@@ -1285,7 +1285,7 @@ def test_json(photosdb: osxphotos.PhotosDB):
def test_json_indent(photosdb: osxphotos.PhotosDB): def test_json_indent(photosdb: osxphotos.PhotosDB):
"""Test PhotoInfo.json() with indent""" """Test PhotoInfo.json() with indent"""
photo = photosdb.get_photo(UUID_DICT["favorite"]) photo = photosdb.get_photo(UUID_DICT["favorite"])
photo_dict = json.loads(photo.json(indent=4)) photo_dict = json.loads(photo.json(indent=4, shallow=False))
assert photo_dict["favorite"] assert photo_dict["favorite"]
assert "album_info" in photo_dict assert "album_info" in photo_dict
@@ -1296,3 +1296,11 @@ def test_json_shallow(photosdb: osxphotos.PhotosDB):
photo_dict = json.loads(photo.json(shallow=True)) photo_dict = json.loads(photo.json(shallow=True))
assert photo_dict["favorite"] assert photo_dict["favorite"]
assert "album_info" not in photo_dict assert "album_info" not in photo_dict
def test_photosdb_photos_by_uuid(photosdb: osxphotos.PhotosDB):
"""Test PhotosDB.photos_by_uuid"""
photos = photosdb.photos_by_uuid(UUID_DICT.values())
assert len(photos) == len(UUID_DICT)
for photo in photos:
assert photo.uuid in UUID_DICT.values()