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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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)
"""
if len(values) != len(sort_keys):
return ValueError("values and sort_keys must have same length")
return list(zip(*sorted(zip(sort_keys, values))))[1]
raise ValueError("values and sort_keys must be same length")
return [x for _, x in sorted(zip(sort_keys, values))]
class AlbumInfoBaseClass:
@ -166,14 +165,13 @@ class AlbumInfoBaseClass:
return self._owner
def asdict(self):
"""Return album info as a dict"""
"""Return album info as a dict; does not include photos"""
return {
"uuid": self.uuid,
"creation_date": self.creation_date,
"start_date": self.start_date,
"end_date": self.end_date,
"owner": self.owner,
"photos": [p.uuid for p in self.photos],
}
def __len__(self):
@ -299,7 +297,7 @@ class AlbumInfo(AlbumInfoBaseClass):
)
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["title"] = self.title
dict_data["folder_names"] = self.folder_names
@ -362,14 +360,13 @@ class ImportInfo(AlbumInfoBaseClass):
return self._photos
def asdict(self):
"""Return import info as a dict"""
"""Return import info as a dict; does not include photos"""
return {
"uuid": self.uuid,
"creation_date": self.creation_date,
"start_date": self.start_date,
"end_date": self.end_date,
"title": self.title,
"photos": [p.uuid for p in self.photos],
}
def __bool__(self):

View File

@ -911,7 +911,8 @@ def export(
# capture locals for use with ConfigOptions before changing any of them
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"
locals_["verbose"] = verbose_flag
@ -2254,7 +2255,7 @@ def export_photo_to_directory(
err=True,
)
if tries > retry:
results.error.append((str(pathlib.Path(dest) / filename), e))
results.error.append((str(pathlib.Path(dest) / filename), str(e)))
break
else:
rich_echo(

View File

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

View File

@ -517,39 +517,26 @@ class PhotoInfo:
@property
def person_info(self):
"""list of PersonInfo objects for person in picture"""
try:
return self._personinfo
except AttributeError:
self._personinfo = [
PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"]
]
return self._personinfo
return [PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"]]
@property
def face_info(self):
"""list of FaceInfo objects for faces in picture"""
try:
return self._faceinfo
except AttributeError:
try:
faces = self._db._db_faceinfo_uuid[self._uuid]
self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
except KeyError:
# no faces
self._faceinfo = []
return self._faceinfo
faces = self._db._db_faceinfo_uuid[self._uuid]
self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
except KeyError:
# no faces
self._faceinfo = []
return self._faceinfo
@property
def moment_info(self):
"""Moment photo belongs to"""
try:
return self._moment
except AttributeError:
try:
self._moment = MomentInfo(db=self._db, moment_pk=self._info["momentID"])
except ValueError:
self._moment = None
return self._moment
return MomentInfo(db=self._db, moment_pk=self._info["momentID"])
except ValueError:
return None
@property
def albums(self):
@ -566,65 +553,41 @@ class PhotoInfo:
@property
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"""
try:
return self._burst_albums
except AttributeError:
burst_albums = list(self.albums)
for photo in self.burst_photos:
if photo.burst_key:
burst_albums.extend(photo.albums)
self._burst_albums = list(set(burst_albums))
return self._burst_albums
burst_albums = list(self.albums)
for photo in self.burst_photos:
if photo.burst_key:
burst_albums.extend(photo.albums)
return list(set(burst_albums))
@property
def album_info(self):
"""list of AlbumInfo objects representing albums the photo is contained in"""
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
album_uuids = self._get_album_uuids()
return [AlbumInfo(db=self._db, uuid=album) for album in album_uuids]
@property
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."""
try:
return self._burst_album_info
except AttributeError:
burst_album_info = list(self.album_info)
for photo in self.burst_photos:
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
burst_album_info = list(self.album_info)
for photo in self.burst_photos:
if photo.burst_key:
burst_album_info.extend(photo.album_info)
return list(set(burst_album_info))
@property
def import_info(self):
"""ImportInfo object representing import session for the photo or None if no import session"""
try:
return self._import_info
except AttributeError:
self._import_info = (
ImportInfo(db=self._db, uuid=self._info["import_uuid"])
if self._info["import_uuid"] is not None
else None
)
return self._import_info
return (
ImportInfo(db=self._db, uuid=self._info["import_uuid"])
if self._info["import_uuid"] is not None
else None
)
@property
def project_info(self):
"""list of AlbumInfo objects representing projects for the photo or None if no projects"""
try:
return self._project_info
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
project_uuids = self._get_album_uuids(project=True)
return [ProjectInfo(db=self._db, uuid=album) for album in project_uuids]
@property
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)
# issue #512
title = self._info["name"]
title = None if title == "" else title
return title
return None if title == "" else title
@property
def uuid(self):
@ -1777,41 +1739,31 @@ class PhotoInfo:
}
return yaml.dump(info, sort_keys=False)
def asdict(self):
"""return dict representation"""
def asdict(self, shallow: bool = True) -> dict[str, Any]:
"""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]
exif_info = dataclasses.asdict(self.exif_info) if self.exif_info else {}
face_info = [face.asdict() for face in self.face_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]
person_info = [p.asdict() for p in self.person_info]
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 {}
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 {
"adjustments": adjustments,
"album_info": album_info,
dict_data = {
"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,
"cloud_guid": self.cloud_guid,
"cloud_metadata": self.cloud_metadata,
"cloud_owner_hashed_id": self.cloud_owner_hashed_id,
"comments": comments,
"date_added": self.date_added,
@ -1831,7 +1783,6 @@ class PhotoInfo:
"hdr": self.hdr,
"height": self.height,
"hidden": self.hidden,
"import_info": import_info,
"incloud": self.incloud,
"intrash": self.intrash,
"iscloudasset": self.iscloudasset,
@ -1841,7 +1792,6 @@ class PhotoInfo:
"israw": self.israw,
"isreference": self.isreference,
"keywords": self.keywords,
"labels_normalized": self.labels_normalized,
"labels": self.labels,
"latitude": self._latitude,
"library": self._db._library_path,
@ -1857,22 +1807,17 @@ class PhotoInfo:
"original_width": self.original_width,
"owner": self.owner,
"panorama": self.panorama,
"path_derivatives": self.path_derivatives,
"path_edited_live_photo": self.path_edited_live_photo,
"path_edited": self.path_edited,
"path_live_photo": self.path_live_photo,
"path_raw": self.path_raw,
"path": self.path,
"person_info": person_info,
"persons": self.persons,
"place": place,
"portrait": self.portrait,
"project_info": project_info,
"raw_original": self.raw_original,
"score": score,
"screenshot": self.screenshot,
"search_info_normalized": search_info_normalized,
"search_info": search_info,
"selfie": self.selfie,
"shared": self.shared,
"slow_mo": self.slow_mo,
@ -1888,7 +1833,38 @@ class PhotoInfo:
"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
Args:
@ -1897,36 +1873,16 @@ class PhotoInfo:
Returns:
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):
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()
dict_data = self.asdict()
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]
dict_data = self.asdict(shallow=True) if shallow else self.asdict(shallow=False)
for k, v in dict_data.items():
# sort lists such as keywords so JSON is consistent
@ -1949,15 +1905,9 @@ class PhotoInfo:
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()
dict_data = self.asdict()
dict_data = self.asdict(shallow=True)
for k in [
"album_info",
"burst_album_info",
"face_info",
"person_info",
"visible",
]:
for k in ["face_info", "visible"]:
del dict_data[k]
for k, v in dict_data.items():

View File

@ -516,7 +516,8 @@ class PhotosDB:
@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"""
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
try:
return self._album_info_shared
@ -543,7 +544,8 @@ class PhotosDB:
@property
def albums_shared(self):
"""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
# 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:
return None
# TODO: add to docs and test
def photos_by_uuid(self, uuids):
"""Returns a list of photos with UUID in uuids.
Does not generate error if invalid or missing UUID passed.
@ -3066,14 +3067,11 @@ class PhotosDB:
Returns:
list of PhotoInfo instance for photo with UUID matching uuid or [] if no match
"""
photos = []
for uuid in uuids:
try:
photos.append(PhotoInfo(db=self, uuid=uuid, info=self._dbphotos[uuid]))
except KeyError:
# ignore missing/invlaid UUID
pass
return photos
return [
PhotoInfo(db=self, uuid=uuid, info=self._dbphotos[uuid])
for uuid in uuids
if uuid in self._dbphotos
]
def query(self, options: QueryOptions) -> List[PhotoInfo]:
"""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):
"""Test PhotoInfo.json() with indent"""
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 "album_info" in photo_dict

View File

@ -1285,7 +1285,7 @@ def test_json(photosdb: osxphotos.PhotosDB):
def test_json_indent(photosdb: osxphotos.PhotosDB):
"""Test PhotoInfo.json() with indent"""
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 "album_info" in photo_dict
@ -1296,3 +1296,11 @@ def test_json_shallow(photosdb: osxphotos.PhotosDB):
photo_dict = json.loads(photo.json(shallow=True))
assert photo_dict["favorite"]
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()