Source code for osxphotos.iphoto

"""Support for iPhoto libraries

This code is based on https://github.com/jensb/iphoto2xmp by @jensb
who kindly gave permission to use the derived code under the MIT license.
The original iphoto2xmp is licensed under the GPL v3 license.

The following code largely follows the structure of the original iphoto2xmp code
with adaptations to convert it to python, break into functions for readability,
and add additional queries needed by osxphotos.

The code is not optimized. For example, redundant data is stored in multiple
data structures and the various database files used by iPhoto are opened & closed
multiple times as needed. This was a deliberate design choice to make the code
match the original as closely as possible and to make it easier to follow the
logic of the original code. I also optimized for implementation speed over execution
speed. iPhoto has not been supported by Apple since 2015 so the expected use case
for this code is to convert an iPhoto library to a Photos library or to export the
iPhoto library. Unlike the rest of the osxphotos code, it is not expected the user
will be using this code repeatedly.

The iPhotoDB, iPhotoPhotoInfo, iPhotoAlbumInfo, etc. classes are minimal implementations
of the corresponding classes in osxphotos and are designed to be drop-in replacements
for the osxphotos classes.  This was done to minimize changes to the rest of the
osxphotos codebase. These iPhoto implementations do not implement all the methods
of the corresponding osxphotos classes, only those needed to export or convert
an iPhoto library.

All the iPhoto code is contained in this single file. I didn't want to mess with creating
a separate package and dealing with the type-hint hell from circular dependencies as all
the classes are tightly coupled.
"""

from __future__ import annotations

import dataclasses
import datetime
import functools
import inspect
import json
import logging
import os
import pathlib
import sqlite3
from functools import cached_property
from typing import Any, Callable, get_type_hints
from zoneinfo import ZoneInfo

import yaml

from ._constants import (
    _IPHOTO_VERSION,
    _UNKNOWN_PERSON,
    SIDECAR_EXIFTOOL,
    SIDECAR_JSON,
    SIDECAR_XMP,
    TIME_DELTA,
)
from .datetime_utils import datetime_has_tz, datetime_naive_to_local
from .exiftool import ExifToolCaching, get_exiftool_path
from .exifutils import angle_to_exif_orientation
from .exportoptions import ExportOptions
from .personinfo import MPRI_Reg_Rect, MWG_RS_Area
from .photoexporter import PhotoExporter
from .photoinfo import PhotoInfo
from .photoquery import QueryOptions, photo_query
from .phototemplate import PhotoTemplate, RenderOptions
from .platform import is_macos
from .scoreinfo import ScoreInfo
from .sqlite_utils import sqlite_columns
from .unicode import normalize_unicode
from .uti import get_preferred_uti_extension, get_uti_for_path
from .utils import hexdigest, noop, path_exists

if is_macos:
    from .fingerprint import fingerprint

logger = logging.getLogger("osxphotos")


[docs] class iPhotoDB: """Read an iPhoto library database; interface matches osxphotos.PhotosDB""" def __init__( self, dbfile: str, verbose: Callable[..., None] = None, exiftool: str | None = None, rich: bool = False, _skip_searchinfo: bool = True, ): """Create a new iPhotoDB object. Args: dbfile: specify full path to iPhoto library verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output. exiftool: optional path to exiftool for methods that require this (e.g. iPhotoPhotoInfo.exiftool); if not provided, will search PATH rich: use rich with verbose output _skip_searchinfo: if True, will not process search data from psi.sqlite; useful for processing standalone Photos.sqlite file Raises: PhotosDBReadError if dbfile is not a valid Photos library. TypeError if verbose is not None and not callable. Note: Unlike PhotosDB, you must specify only the path to the root library in dbfile, not the database file rich is not used with iPhoto _skip_searchinfo is not used with iPhoto """ self.library_path: pathlib.Path = pathlib.Path(dbfile).absolute() if not self.library_path.is_dir(): raise FileNotFoundError(f"Invalid iPhoto library path: {self.library_path}") if not self.library_path.joinpath("Database").is_dir(): raise FileNotFoundError(f"Invalid iPhoto library path: {self.library_path}") self._library_path = str(self.library_path) # compatibility with PhotosDB # for compatibility with PhotosDB but not a 1:1 mapping as iPhoto uses several databases self.db_path = str(self.library_path.joinpath("Database/apdb/Library.apdb")) if verbose is None: verbose = noop elif not callable(verbose): raise TypeError("verbose must be callable") self.verbose = verbose self._verbose = self.verbose # compatibility with PhotosDB self._rich = rich # currently unused, compatibility with PhotosDB self._exiftool_path = exiftool # initialize database dictionaries self._db_photos = {} # mapping of uuid to photo data self._db_event_notes = {} # mapping of modelId to event notes self._db_places = {} # mapping of modelId to places self._db_properties = {} # mapping of versionId to properties self._db_exif_info = {} # mapping of versionId to EXIF info self._db_persons = {} # mapping of modelId to persons self._db_faces = {} # mapping of modelID to face info self._db_faces_edited = {} # mapping of modelID to face info for edited photos self._db_folders = {} # mapping of modelId to folders self._db_albums = {} # mapping of modelId to albums self._db_volumes = {} # mapping of volume uuid to volume name # set _db_version and _photos_ver even though they're not used in iPhoto because other code depends on these self._db_version = _IPHOTO_VERSION self._photos_ver = 0 self._load_library() self._source = "iPhoto" def _load_library(self): """Load iPhoto library""" self.verbose(f"Loading iPhoto library: {self.library_path}") self._load_library_db() self._load_properties_db() self._load_persons() self._load_face_info() self._load_edited_face_info() self._load_folders() self._load_albums() self._load_keywords() self._load_volumes() self._build_photo_paths() # logger.debug(f"{self._db_photos=}") # logger.debug(f"{self._db_event_notes=}") # logger.debug(f"{self._db_places=}") # logger.debug(f"{self._db_properties=}") # logger.debug(f"{self._db_exif_info=}") # logger.debug(f"{self._db_persons=}") # logger.debug(f"{self._db_faces=}") # logger.debug(f"{self._db_faces_edited=}") # logger.debug(f"{self._db_folders=}") # logger.debug(f"{self._db_albums=}") # logger.debug(f"{self._db_volumes=}") def _load_library_db(self): """Load the Library.apdb database""" library_db = self.library_path.joinpath("Database/apdb/Library.apdb") conn = sqlite3.connect(library_db) rkfolder_columns = sqlite_columns(conn, "RKFolder") rkmaster_columns = sqlite_columns(conn, "RKMaster") query = f""" SELECT RKVersion.modelId AS id, RKVersion.masterId AS master_id, RKVersion.name AS title, RKFolder.name AS rollname, RKFolder.modelId AS roll, RKFolder.minImageDate AS roll_min_image_date, RKFolder.maxImageDate AS roll_max_image_date, {"RKFolder.minImageTimeZoneName" if "minImageTimeZoneName" in rkfolder_columns else "NULL"} AS roll_min_image_tz, {"RKFolder.maxImageTimeZoneName" if "maxImageTimeZoneName" in rkfolder_columns else "NULL"} AS roll_max_image_tz, RKFolder.posterVersionUuid AS poster_version_uuid, -- event thumbnail image uuid RKFolder.createDate AS date_foldercreation, -- is this the 'imported as' date? RKVersion.uuid AS uuid, RKMaster.uuid AS master_uuid, -- master (unedited) image. Required for face rectangle conversion. -- RKVersion.versionNumber AS version_number, -- 1 if edited image, 0 if original image RKVersion.mainRating AS rating, -- TODO: Rating is always applied to the master image, not the edited one RKMaster.type AS mediatype, -- IMGT, VIDT RKMaster.imagePath AS imagepath, -- 2015/04/27/20150427-123456/FOO.RW2, yields Masters/$imagepath and -- Previews: either Previews/$imagepath/ or dirname($imagepath)/$uuid/basename($imagepath) -- ,RKVersion.createDate AS date_imported RKMaster.createDate AS date_imported, RKVersion.imageDate AS date_taken, -- ,RKMaster.imageDate AS datem RKVersion.exportImageChangeDate AS date_modified, -- ,RKMaster.fileCreationDate AS date_filecreation -- is this the 'date imported'? No -- ,RKMaster.fileModificationDate AS date_filemod -- ,replace(RKImportGroup.name, ' @ ', 'T') AS date_importgroup -- contains datestamp of import procedure for a group of files, -- but this is apparently incorrect for images before 2012 -> ignore RKVersion.imageTimeZoneName AS timezone, RKVersion.exifLatitude AS latitude, RKVersion.exifLongitude AS longitude, RKVersion.isHidden AS hidden, RKVersion.isFlagged AS flagged, RKVersion.isOriginal AS original, RKMaster.isInTrash AS in_trash, RKVersion.masterHeight AS master_height, -- Height of original image (master) RKVersion.masterWidth AS master_width, -- Width of original image (master) RKVersion.processedHeight AS processed_height, -- Height of processed (eg. cropped, rotated) image RKVersion.processedWidth AS processed_width, -- Width of processed (eg. cropped, rotated) image RKVersion.overridePlaceId AS place_id, -- modelId of Properties::RKPlace RKVersion.faceDetectionRotationFromMaster AS face_rotation, -- don't know, maybe a hint for face detection algorithm RKVersion.rotation AS rotation, -- was the original image rotated? RKVersion.hasAdjustments as hasadjustments, RKVersion.fileName as filename, RKMaster.fileVolumeUuid AS volume_uuid, RKMaster.isMissing AS ismissing, RKMaster.isTrulyRaw AS truly_raw, RKMaster.fileIsReference AS is_reference, RKMaster.originalFileSize as original_filesize, {"RKMaster.burstUuid" if "burstUuid" in rkmaster_columns else "NULL"} as burst_uuid FROM RKVersion LEFT JOIN RKFolder ON RKVersion.projectUuid = RKFolder.uuid LEFT JOIN RKMaster ON RKMaster.uuid = RKVersion.masterUuid LEFT JOIN RKImportGroup ON RKMaster.importGroupUuid = RKImportGroup.uuid WHERE RKVersion.versionNumber = 1 """ logger.debug(f"Executing query: {query}") conn.row_factory = sqlite3.Row cursor = conn.cursor() results = cursor.execute(query).fetchall() for row in results: self._db_photos[row["uuid"]] = dict(row) # normalize unicode for uuid in self._db_photos: self._db_photos[uuid]["title"] = normalize_unicode( self._db_photos[uuid]["title"] ) self._db_photos[uuid]["rollname"] = normalize_unicode( self._db_photos[uuid]["rollname"] ) self.verbose(f"Loaded {len(self._db_photos)} assets from iPhoto library") # Event notes (pre-iPhoto 9.1) query = """ SELECT RKNote.modelId AS modelId, RKNote.note AS note, RKFolder.name AS name, RKFolder.uuid AS folder_uuid, RKFolder.modelId AS folder_model_id FROM RKNote LEFT JOIN RKFolder on RKNote.attachedToUuid = RKFolder.uuid WHERE RKFolder.name IS NOT NULL AND RKFolder.name != '' ORDER BY RKFolder.modelId """ logger.debug(f"Executing query: {query}") self.verbose("Loading event notes from iPhoto library") results = cursor.execute(query).fetchall() for row in results: row = dict(row) row["note"] = normalize_unicode(row["note"]) row["name"] = normalize_unicode(row["name"]) self._db_event_notes[int(row["folder_model_id"])] = dict(row) conn.close() def _load_properties_db(self): """Load the Properties.apdb database""" properties_db = self.library_path.joinpath("Database/apdb/Properties.apdb") # Places query = """ SELECT RKPlace.modelId, RKPlace.uuid, RKPlace.defaultName, RKPlace.minLatitude, RKPlace.minLongitude, RKPlace.maxLatitude, RKPlace.maxLongitude, RKPlace.centroid, RKPlace.userDefined FROM RKPlace """ logger.debug(f"Executing query: {query}") conn = sqlite3.connect(properties_db) conn.row_factory = sqlite3.Row cursor = conn.cursor() self.verbose("Loading places from iPhoto library") results = cursor.execute(query).fetchall() for row in results: self._db_places[int(row["modelId"])] = dict(row) # normalize unicode for model_id in self._db_places: self._db_places[model_id]["defaultName"] = normalize_unicode( self._db_places[model_id]["defaultName"] ) # Properties query = """ SELECT RKIptcProperty.modelId AS id, RKIptcProperty.versionId AS versionId, RKIptcProperty.modDate AS modDate, RKUniqueString.stringProperty AS string FROM RKIptcProperty LEFT JOIN RKUniqueString ON RKIptcProperty.stringId = RKUniqueString.modelId WHERE RKIptcProperty.propertyKey = 'Caption/Abstract' -- description ORDER BY versionId """ logger.debug(f"Executing query: {query}") self.verbose("Loading properties from iPhoto library") results = cursor.execute(query).fetchall() for row in results: row = dict(row) row["string"] = normalize_unicode(row["string"]) self._db_properties[row["versionId"]] = dict(row) # put description back into _db_photos # mapping of versionId -> RKVersion.modelID -> uuid version_id_to_uuid = {} for uuid, photo in self._db_photos.items(): version_id = photo["id"] if version_id not in version_id_to_uuid: version_id_to_uuid[version_id] = uuid for version_id, data in self._db_properties.items(): if uuid := version_id_to_uuid.get(version_id): self._db_photos[uuid]["description"] = data["string"] # orientation query = """ SELECT RKOtherProperty.versionId AS version_id, RKUniqueString.stringProperty AS str FROM RKOtherProperty LEFT JOIN RKUniqueString ON RKUniqueString.modelId = RKOtherProperty.stringId WHERE RKOtherProperty.propertyKey = 'Orientation' """ logger.debug(f"Executing query: {query}") self.verbose("Loading orientation from iPhoto library") results = cursor.execute(query).fetchall() for row in results: if uuid := version_id_to_uuid.get(row["version_id"]): self._db_photos[uuid]["orientation"] = row["str"].lower() # EXIF Properties query = """ SELECT RKExifStringProperty.versionId as versionId, RKExifStringProperty.propertyKey AS property, RKUniqueString.stringProperty AS value FROM RKExifStringProperty INNER JOIN RKUniqueString ON RKUniqueString.modelId = RKExifStringProperty.stringId """ logger.debug(f"Executing query: {query}") self.verbose("Loading EXIF properties from iPhoto library") results = cursor.execute(query).fetchall() for row in results: row = dict(row) row["value"] = normalize_unicode(row["value"]) if row["versionId"] not in self._db_exif_info: self._db_exif_info[row["versionId"]] = {} self._db_exif_info[row["versionId"]][row["property"]] = row["value"] # EXIF data is stored separately whether string or numerical query = """ SELECT RKExifNumberProperty.versionId AS versionId, RKExifNumberProperty.propertyKey AS property, RKExifNumberProperty.numberProperty AS value FROM RKExifNumberProperty """ logger.debug(f"Executing query: {query}") results = cursor.execute(query).fetchall() for row in results: row = dict(row) if row["versionId"] not in self._db_exif_info: self._db_exif_info[row["versionId"]] = {} self._db_exif_info[row["versionId"]][row["property"]] = row["value"] conn.close() def _load_persons(self): """Load persons from Faces.db database""" faces_db = self.library_path.joinpath("Database/apdb/Faces.db") # Faces query = """ SELECT modelId, uuid, faceKey as face_key, name, email FROM RKFaceName """ logger.debug(f"Executing query: {query}") conn = sqlite3.connect(faces_db) conn.row_factory = sqlite3.Row cursor = conn.cursor() self.verbose("Loading faces from iPhoto library") results = cursor.execute(query).fetchall() for row in results: self._db_persons[int(row["modelId"])] = dict(row) conn.close() def _load_face_info(self) -> dict[str, dict[Any, Any]]: """Load face info for each photo from database""" face_db = self.library_path.joinpath("Database/apdb/Faces.db") query = """ SELECT RKDetectedFace.modelId, -- primary key RKDetectedFace.uuid AS detect_uuid, -- primary key RKDetectedFace.masterUuid, -- --> Library::RKMaster::uuid RKDetectedFace.faceKey AS face_key, -- --> RKFaceName::faceKey -- *relative* coordinates within *original, non-rotated* image (0..1) -- Y values are counted from the bottom in iPhoto, but X values are counted from the left like usual! RKDetectedFace.topLeftX, 1 - RKDetectedFace.topLeftY AS topLeftY, RKDetectedFace.topRightX, 1 - RKDetectedFace.topRightY AS topRightY, RKDetectedFace.bottomLeftX, 1 - RKDetectedFace.bottomLeftY AS bottomLeftY, RKDetectedFace.bottomRightX, 1 - RKDetectedFace.bottomRightY AS bottomRightY, abs( RKDetectedFace.topLeftX - RKDetectedFace.bottomRightX ) AS width, abs( RKDetectedFace.topLeftY - RKDetectedFace.bottomRightY ) AS height, RKDetectedFace.width AS image_width, -- TODO: check whether face was meant to be rotated? RKDetectedFace.height AS image_height, RKDetectedFace.faceDirectionAngle AS face_dir_angle, RKDetectedFace.faceAngle AS face_angle, -- always 0? RKDetectedFace.confidence AS confidence, RKDetectedFace.rejected AS rejected, RKDetectedFace.ignore AS ignore, RKFaceName.uuid AS name_uuid, RKFaceName.name AS name, -- more reliable, also seems to contain manually added names RKFaceName.fullName AS full_name, -- might be empty if person is not listed in user's address book RKFaceName.email AS email FROM RKDetectedFace LEFT JOIN RKFaceName ON RKFaceName.faceKey = RKDetectedFace.faceKey WHERE RKDetectedFace.masterUuid = ? -- master_uuid AND RKDetectedFace.ignore = 0 AND RKDetectedFace.rejected = 0 -- ORDER BY RKDetectedFace.modelId """ logger.debug(f"Executing query: {query}") conn = sqlite3.connect(face_db) conn.row_factory = sqlite3.Row cursor = conn.cursor() self.verbose("Loading face info from iPhoto library") for uuid, photo in self._db_photos.items(): master_uuid = photo["master_uuid"] results = cursor.execute(query, (master_uuid,)).fetchall() self._db_photos[uuid]["faces"] = [] for row in results: row = dict(row) # normalize unicode row["name"] = normalize_unicode(row["name"]) row["full_name"] = normalize_unicode(row["full_name"]) row["email"] = normalize_unicode(row["email"]) # assign to library data for matching uuid self._db_photos[uuid]["faces"].append(row) self._db_faces[row["modelId"]] = row conn.close() def _load_edited_face_info(self): """Load edited face info for each photo from database""" library_db = self.library_path.joinpath("Database/apdb/Library.apdb") conn = sqlite3.connect(library_db) # get edited face info query = """ SELECT RKVersionFaceContent.modelId AS id, RKVersionFaceContent.versionId AS version_id, RKVersionFaceContent.masterId AS master_id, RKVersionFaceContent.faceKey AS face_key, """ if "faceRectLeft" in sqlite_columns(conn, "RKVersionFaceContent"): query += """ RKVersionFaceContent.faceRectLeft AS topLeftX, -- use same naming scheme as in 'faces' 1 - RKVersionFaceContent.faceRectTop AS bottomRightY, -- Y values are counted from the bottom in this table! RKVersionFaceContent.faceRectWidth AS width, RKVersionFaceContent.faceRectHeight AS height, RKVersionFaceContent.faceRectWidth + RKVersionFaceContent.faceRectLeft AS bottomRightX, 1 - RKVersionFaceContent.faceRectTop - RKVersionFaceContent.faceRectHeight AS topLeftY """ else: query += """ 0 as topLeftX, 0 as bottomRightY, 0 as width, 0 as height, 0 as bottomRightX, 0 as topLeftY """ query += """ FROM RKVersionFaceContent WHERE RKVersionFaceContent.versionId = ? -- id of the photo ORDER BY RKVersionFaceContent.versionId """ logger.debug(f"Executing query: {query}") conn.row_factory = sqlite3.Row cursor = conn.cursor() self.verbose("Loading edited face info from iPhoto library") for photo in self._db_photos.values(): version_id = photo["id"] results = cursor.execute(query, (version_id,)).fetchall() photo["edited_faces"] = [] for row in results: row = dict(row) row["confidence"] = ( 0.0 # TODO: figure out original face and use those values ) face_key = row["face_key"] for person in self._db_persons.values(): if face_key == person["face_key"]: row["name"] = person["name"] row["email"] = person["email"] row["full_name"] = "" break else: row["name"] = "" row["full_name"] = "" row["email"] = "" # assign to library data for matching uuid photo["edited_faces"].append(row) self._db_faces_edited[row["id"]] = row conn.close() def _load_folders(self): """Load folders from iPhoto library""" # Get Folders and Albums. Convert to (hierarchical) keywords since "Albums" are nothing but tag collections. # Also get search criteria for "smart albums". Save into text file (for lack of better solution). # 1. Get folder structure, create tag pathnames as strings. # Folders are just a pseudo hierarchy and can contain Albums and Smart Albums. library_db = self.library_path.joinpath("Database/apdb/Library.apdb") conn = sqlite3.connect(library_db) rkfolder_columns = sqlite_columns(conn, "RKFolder") query = f""" SELECT modelId, uuid, folderType, name, parentFolderUuid, folderPath, isMagic, createDate as date, minImageDate AS min_image_date, maxImageDate AS max_image_date, {"minImageTimeZoneName" if "minImageTimeZoneName" in rkfolder_columns else "NULL"} AS min_image_tz, {"maxImageTimeZoneName" if "maxImageTimeZoneName" in rkfolder_columns else "NULL"} AS max_image_tz FROM RKFolder """ logger.debug(f"Executing query: {query}") conn.row_factory = sqlite3.Row cursor = conn.cursor() self.verbose("Loading folders from iPhoto library") results = cursor.execute(query).fetchall() for row in results: self._db_folders[row["modelID"]] = dict(row) # normalize unicode for model_id in self._db_folders: self._db_folders[model_id]["name"] = normalize_unicode( self._db_folders[model_id]["name"] ) self._db_folders[model_id]["folderPath"] = normalize_unicode( self._db_folders[model_id]["folderPath"] ) # folderPath is a string like "modelId1/modelId2/...". # convert these using the real folder names to get the path strings. # the top level libray folder is always modelId 1 and has name '' for model_id, folder_data in self._db_folders.items(): folder_list = [] for folder_id in folder_data["folderPath"].split("/"): if folder_id == "": continue folder_id = int(folder_id) folder_name = self._db_folders[folder_id]["name"] ismagic = bool(self._db_folders[folder_id]["isMagic"]) if folder_name == "" or ismagic: # skip magic folders like "TopLevelAlbums" # if someone has a folder with no name, it will be skipped continue folder_list.append(folder_name) self._db_folders[model_id]["folderlist"] = folder_list conn.close() def _load_albums(self): """Load albums from iPhoto library""" library_db = self.library_path.joinpath("Database/apdb/Library.apdb") query = """ SELECT RKAlbumVersion.modelId, RKAlbumVersion.versionId, -- -->Library::RKVersion::modelId RKAlbumVersion.albumId, RKAlbum.name, RKAlbum.uuid as album_uuid, RKFolder.modelId AS folder_id, RKFolder.uuid AS folder_uuid FROM RKAlbumVersion LEFT JOIN RKAlbum ON RKAlbumVersion.albumId = RKAlbum.modelId LEFT JOIN RKFolder ON RKFolder.uuid = RKAlbum.folderUuid """ logger.debug(f"Executing query: {query}") conn = sqlite3.connect(library_db) conn.row_factory = sqlite3.Row cursor = conn.cursor() self.verbose("Loading albums from iPhoto library") results = cursor.execute(query).fetchall() for row in results: row = dict(row) row["name"] = normalize_unicode(row["name"]) version_id = row["versionId"] if version_id not in self._db_albums: self._db_albums[version_id] = [] self._db_albums[version_id].append(row) # get album hierarchy for albums in self._db_albums.values(): for album in albums: album["path"] = [ *self._db_folders[album["folder_id"]]["folderlist"], album["name"], ] # add album data to library data for uuid, library in self._db_photos.items(): self._db_photos[uuid]["albums"] = self._db_albums.get(library["id"], []) conn.close() def _load_keywords(self): """Load keywords from the database""" db = self.library_path.joinpath("Database/apdb/Library.apdb") query = """ SELECT RKVersion.uuid AS uuid, RKKeyword.modelId AS modelId, RKKeyword.name AS name FROM RKKeywordForVersion INNER JOIN RKversion ON RKKeywordForVersion.versionId=RKVersion.modelId INNER JOIN RKKeyword ON RKKeywordForVersion.keywordId=RKKeyword.modelId """ logger.debug(f"Executing query: {query}") conn = sqlite3.connect(db) conn.row_factory = sqlite3.Row cursor = conn.cursor() self.verbose("Loading keywords from iPhoto library") results = cursor.execute(query).fetchall() for row in results: uuid = row["uuid"] if uuid not in self._db_photos: # logger.warning(f"Missing uuid {uuid} in _db_library") continue if "keywords" not in self._db_photos[uuid]: self._db_photos[uuid]["keywords"] = [] self._db_photos[uuid]["keywords"].append(normalize_unicode(row["name"])) conn.close() def _load_volumes(self): """Load volume data for referenced files""" library_db = self.library_path.joinpath("Database/apdb/Library.apdb") query = """ SELECT RKVolume.uuid as uuid, RKVolume.name as name FROM RKVolume """ logger.debug(f"Executing query: {query}") conn = sqlite3.connect(library_db) conn.row_factory = sqlite3.Row cursor = conn.cursor() self.verbose("Loading volumes from iPhoto library") results = cursor.execute(query).fetchall() for row in results: # TODO: does unicode normalization make sense here? self._db_volumes[row["uuid"]] = row["name"] conn.close() def _build_photo_paths(self): """Build photo paths for each photo in the library""" # original path for uuid, photo in self._db_photos.items(): if photo["is_reference"]: volume_uuid = photo["volume_uuid"] volume_name = self._db_volumes[volume_uuid] photo["photo_path"] = pathlib.Path("/Volumes").joinpath( volume_name, photo["imagepath"] ) else: photo["photo_path"] = self.library_path.joinpath( "Masters", photo["imagepath"] ) # derivative paths image_path = pathlib.Path(photo["imagepath"]) path_previews = self.library_path.joinpath( "Thumbnails", image_path.parent, photo["uuid"] ) derivatives = list(path_previews.glob("*")) # sort by size, largest first derivatives.sort(key=lambda x: x.stat().st_size, reverse=True) photo["path_derivatives"] = [str(x) for x in derivatives] # edited path if photo["hasadjustments"]: image_path = pathlib.Path(photo["imagepath"]) path_edited = self.library_path.joinpath( "Previews", image_path.parent, uuid ) edited_files = list(path_edited.glob("*")) # edited image named with Photo's title not imagepath.stem if edited_files := [ x for x in edited_files if normalize_unicode(x.stem) == photo["title"] ]: photo["path_edited"] = edited_files[0] else: photo["path_edited"] = "" else: photo["path_edited"] = "" @cached_property def db_version(self) -> str: """Return the database version as stored in Library.apdb RKAdminData table""" library_db = self.library_path.joinpath("Database/apdb/Library.apdb") query = """ SELECT propertyValue FROM RKAdminData WHERE propertyName IN ('versionMajor', 'versionMinor'); """ logger.debug(f"Executing query: {query}") conn = sqlite3.connect(library_db) cursor = conn.cursor() results = cursor.execute(query).fetchall() return ".".join(row[0] for row in results) @cached_property def photos_version(self) -> str: """Returns version of the library as a string""" library_db = self.library_path.joinpath("Database/apdb/Library.apdb") query = """ SELECT propertyValue FROM RKAdminData WHERE propertyName IN ('applicationIdentifier'); """ logger.debug(f"Executing query: {query}") conn = sqlite3.connect(library_db) cursor = conn.cursor() results = cursor.execute(query).fetchall() return f"{results[0][0]} - {self.db_version}"
[docs] def get_db_connection(self) -> tuple[sqlite3.Connection, sqlite3.Cursor]: """Get connection to the working copy of the Photos database Returns: tuple of (connection, cursor) to sqlite3 database Raises: NotImplementedError on iPhoto """ raise NotImplementedError("get_db_connection not implemented for iPhoto")
@property def keywords_as_dict(self) -> dict[str, int]: """Return keywords as dict of list of keywords keyed by count of photos""" keywords = {} for photo in self._db_photos.values(): if "keywords" in photo: for keyword in photo["keywords"]: if keyword not in keywords: keywords[keyword] = 0 keywords[keyword] += 1 return keywords @property def persons_as_dict(self) -> dict[str, list[str]]: """Return persons as dict of list of persons keyed count of photos""" persons = {} for photo in self._db_photos.values(): if photo["hasadjustments"]: face_list = photo.get("edited_faces", []) else: face_list = photo.get("faces", []) for face in face_list: face_name = face["name"] if face_name not in persons: persons[face_name] = 0 persons[face_name] += 1 return persons @property def albums_as_dict(self) -> dict[str, int]: """Return albums as dict of list of albums keyed by count of photos""" albums = {} for photo in self._db_photos.values(): for album in photo["albums"]: album_name = album["name"] if album_name not in albums: albums[album_name] = 0 albums[album_name] += 1 return albums @property def album_info(self) -> list[iPhotoAlbumInfo]: """Return list of iPhotoAlbumInfo objects for each album in the library""" album_info = {} for albums in self._db_albums.values(): for album in albums: if album["album_uuid"] not in album_info: album_info[album["album_uuid"]] = iPhotoAlbumInfo(album, self) return list(album_info.values()) @property def albums(self) -> list[str]: """Return list of album names""" return list(self.albums_as_dict.keys())
[docs] def photos( self, keywords: list[str] | None = None, uuid: list[str] | None = None, persons: list[str] | None = None, albums: list[str] | None = None, images: bool = True, movies: bool = True, from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None, intrash: bool = False, ) -> list[iPhotoPhotoInfo]: """Return a list of iPhotoPhotoInfo objects If called with no args, returns the entire database of photos If called with args, returns photos matching the args (e.g. keywords, persons, etc.) If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons) If more than one keyword, uuid, persons, albums is passed, they are treated as "OR" criteria e.g. keywords=["wedding","vacation"] returns photos matching either keyword from_date and to_date may be either naive or timezone-aware datetime.datetime objects. If naive, timezone will be assumed to be local timezone. Args: keywords: list of keywords to search for uuid: list of UUIDs to search for persons: list of persons to search for albums: list of album names to search for images: if True, returns image files, if False, does not return images; default is True movies: if True, returns movie files, if False, does not return movies; default is True from_date: return photos with creation date >= from_date (datetime.datetime object, default None) to_date: return photos with creation date < to_date (datetime.datetime object, default None) intrash: if True, returns only images in "Recently deleted items" folder, if False returns only photos that aren't deleted; default is False Returns: list of iPhotoPhotoInfo objects """ photos = [iPhotoPhotoInfo(uuid, self) for uuid in self._db_photos] if uuid: photos = [photo for photo in photos if photo.uuid in uuid] if not images: photos = [photo for photo in photos if not photo.isphoto] if not movies: photos = [photo for photo in photos if not photo.ismovie] if keywords: for keyword in keywords: photos = [ photo for photo in photos if photo.keywords and keyword in photo.keywords ] if persons: for person in persons: photos = [ photo for photo in photos if photo.persons and person in photo.persons ] if albums: for album in albums: photos = [ photo for photo in photos if photo.albums and album in photo.albums ] if from_date: if not datetime_has_tz(from_date): from_date = datetime_naive_to_local(from_date) photos = [photo for photo in photos if photo.date >= from_date] if to_date: if not datetime_has_tz(to_date): to_date = datetime_naive_to_local(to_date) photos = [photo for photo in photos if photo.date < to_date] if intrash: photos = [photo for photo in photos if photo.intrash] else: photos = [photo for photo in photos if not photo.intrash] return photos
[docs] def get_photo(self, uuid: str) -> iPhotoPhotoInfo: """Return photo by uuid""" if uuid not in self._db_photos: raise ValueError(f"Photo with uuid {uuid} not found") return iPhotoPhotoInfo(uuid, self)
[docs] def query(self, options: QueryOptions) -> list[iPhotoPhotoInfo]: """Run a query against PhotosDB to extract the photos based on user supplied options Args: options: a QueryOptions instance """ return photo_query(self, options)
def __repr__(self): return f"osxphotos.{self.__class__.__name__}(dbfile='{self.db_path}')" def __len__(self) -> int: """Return number of photos in the library""" return len(self.photos())
[docs] class iPhotoPhotoInfo: """PhotoInfo implementation for iPhoto""" def __init__(self, uuid: str, db: iPhotoDB): self._uuid = uuid self._db = db self._info = self._db._db_photos[self._uuid] self._id = self._info["id"] # modelID / versionId self._attributes = get_user_attributes(PhotoInfo) self._verbose = db._verbose # compatibility with PhotoInfo @property def uuid(self) -> str: """UUID of photo""" return self._uuid @property def filename(self) -> str: """Filename of photo""" return self._db._db_photos[self._uuid]["filename"] @property def original_filename(self) -> str: """Original filename of photo""" return self._db._db_photos[self._uuid]["filename"] @property def isphoto(self) -> bool: """Return True if asset is a photo""" return self._db._db_photos[self._uuid]["mediatype"] == "IMGT" @property def ismovie(self) -> bool: """Return True if asset is a movie""" return self._db._db_photos[self._uuid]["mediatype"] == "VIDT" @property def israw(self) -> bool: """Return True if asset is a raw image""" return bool(self._db._db_photos[self._uuid]["truly_raw"]) @property def raw_original(self) -> bool: """Return True if asset original is a raw image""" return bool(self._db._db_photos[self._uuid]["truly_raw"]) @property def uti(self) -> str | None: """UTI of current version of photo (edited if hasadjustments, otherwise original)""" # this isn't stored in the database so we have to determine from filename if self.hasadjustments and self.path_edited: return get_uti_for_path(self.path_edited) return get_uti_for_path(self.filename) @property def uti_original(self) -> str | None: """UTI of original version of photo""" return get_uti_for_path(self.filename) @property def uti_edited(self) -> str | None: """UTI of edited version of photo""" return ( get_uti_for_path(self.path_edited) if self.hasadjustments and self.path_edited else None ) @property def uti_raw(self) -> str | None: """UTI of raw version of photo""" return get_uti_for_path(self.path) if self.israw else None @property def ismissing(self) -> bool: """Return True if asset is missing""" return self._db._db_photos[self._uuid]["ismissing"] @property def isreference(self) -> bool: """Return True if asset is a referenced file""" return self._db._db_photos[self._uuid]["is_reference"] @property def date(self) -> datetime.datetime: """Date photo was taken""" return iphoto_date_to_datetime( self._db._db_photos[self._uuid]["date_taken"], self._db._db_photos[self._uuid]["timezone"], ) @property def date_modified(self) -> datetime.datetime: """Date modified in library""" return iphoto_date_to_datetime( self._db._db_photos[self._uuid]["date_modified"], self._db._db_photos[self._uuid]["timezone"], ) @property def date_added(self) -> datetime.datetime: """Date added to library""" return iphoto_date_to_datetime( self._db._db_photos[self._uuid]["date_imported"], self._db._db_photos[self._uuid]["timezone"], ) @property def tzoffset(self) -> int: """TZ Offset from GMT in seconds""" tzname = self._db._db_photos[self._uuid]["timezone"] if not tzname: return 0 tz = ZoneInfo(tzname) return int(tz.utcoffset(datetime.datetime.now()).total_seconds()) @property def path(self) -> str | None: """Path to original photo asset in library""" path = self._db._db_photos[self._uuid]["photo_path"] if path_exists(path): return str(path) logger.debug(f"Photo path {path} does not exist") return None @property def path_edited(self) -> str | None: """Path to edited asset in library""" path = self._db._db_photos[self._uuid]["path_edited"] if path_exists(path): return str(path) logger.debug(f"Edited photo path {path} does not exist") return None @property def path_derivatives(self) -> list[str]: """Path to derivatives in library""" # don't need to check for existence since we just globbed the directory return self._db._db_photos[self._uuid]["path_derivatives"] @property def description(self) -> str: """Description of photo""" return self._db._db_photos[self._uuid].get("description", "") @property def title(self) -> str | None: """Title of photo""" return self._db._db_photos[self._uuid].get("title", None) @property def favorite(self) -> bool: """Returns True if photo is favorite; iPhoto doesn't have favorite so always returns False""" return False @property def flagged(self) -> bool: """Returns True if photo is flagged""" return bool(self._db._db_photos[self._uuid].get("flagged", False)) @property def rating(self) -> int: """Rating of photo as int from 0 to 5""" return self._db._db_photos[self._uuid]["rating"] @property def hidden(self) -> bool: """True if photo is hidden""" return bool(self._db._db_photos[self._uuid]["hidden"]) @property def visible(self) -> bool: """True if photo is visible in Photos; always returns False for iPhoto""" logger.debug("visible not implemented for iPhoto") return False @property def intrash(self) -> bool: """True if photo is in the Photos trash""" return self._db._db_photos[self._uuid]["in_trash"] @property def persons(self) -> list[str]: """List of persons in photo""" faces = self._get_faces() persons = [] for face in faces: if person := face["name"]: persons.append(person) else: persons.append(_UNKNOWN_PERSON) return sorted(persons, key=lambda x: x or "") @property def person_info(self) -> list[iPhotoPersonInfo]: """List of iPhotoPersonInfo objects for photo""" faces = self._get_faces() return sorted( [iPhotoPersonInfo(face, self._db) for face in faces], key=lambda x: x.name or "", ) @property def face_info(self) -> list[iPhotoFaceInfo]: """List of iPhotoFaceInfo objects for photo""" faces = self._get_faces() return sorted( [iPhotoFaceInfo(self, face, self._db) for face in faces], key=lambda x: x.name or "", ) @property def keywords(self) -> list[str]: """Keywords for photo""" return sorted(self._db._db_photos[self._uuid].get("keywords", [])) @property def hasadjustments(self) -> bool: """True if photo has adjustments""" return bool(self._db._db_photos[self._uuid]["hasadjustments"]) @property def width(self) -> int: """Width of photo in pixels""" return self._db._db_photos[self._uuid]["processed_width"] @property def height(self) -> int: """Height of photo in pixels""" return self._db._db_photos[self._uuid]["processed_height"] @property def original_width(self) -> int: """Original width of photo in pixels""" return self._db._db_photos[self._uuid]["master_width"] @property def original_height(self) -> int: """Original height of photo in pixels""" return self._db._db_photos[self._uuid]["master_height"] @property def latitude(self) -> float | None: """Latitude of photo""" return self._db._db_photos[self._uuid]["latitude"] @property def longitude(self) -> float | None: """Longitude of photo""" return self._db._db_photos[self._uuid]["longitude"] @property def location(self) -> tuple[float, float] | tuple[None, None]: """Location of photo as (latitude, longitude)""" return (self.latitude, self.longitude) @property def original_filesize(self) -> int: """Size of original file in bytes""" return self._db._db_photos[self._uuid]["original_filesize"] @property def albums(self) -> list[str]: """List of albums photo is contained in""" return sorted( [ album["name"] for album in self._db._db_photos[self._uuid].get("albums", []) ], key=lambda x: x or "", ) @property def album_info(self) -> list[iPhotoAlbumInfo]: """ "Return list of iPhotoAlbumInfo objects for photo""" return sorted( [ iPhotoAlbumInfo(album, self._db) for album in self._db._db_photos[self._uuid].get("albums", []) ], key=lambda x: x.title or "", ) @property def event_info(self) -> iPhotoEventInfo | None: """Return iPhotoEventInfo object for photo or None if photo is not in an event""" if event := self._db._db_photos[self._uuid].get("roll"): if event_data := self._db._db_folders.get(event): return iPhotoEventInfo(event_data, self._db) return None @property def moment_info(self) -> iPhotoMomentInfo | None: """Return iPhotoMomentInfo object for photo or None if photo is not in a moment; for iPhoto returns event as moment""" # iPhoto doesn't actually have moment so use event if event := self._db._db_photos[self._uuid].get("roll"): if event_data := self._db._db_folders.get(event): return iPhotoMomentInfo(event_data, self._db) return None @cached_property def fingerprint(self) -> str | None: """Returns fingerprint of original photo as a string; returns None if not available. On linux, returns None.""" if not is_macos: logger.warning("fingerprint only supported on macOS") return None if not self.path: logger.debug(f"Missing path, cannot compute fingerprint for {self.uuid}") return None return fingerprint(self.path) @cached_property def exif_info(self) -> iPhotoExifInfo: """Return iPhotoExifInfo object for photo""" exif_info = self._db._db_exif_info.get(self._id, {}) return iPhotoExifInfo( flash_fired=bool(exif_info.get("Flash", False)), iso=exif_info.get("ISOSpeedRating", 0), metering_mode=exif_info.get("MeteringMode", 0), sample_rate=0, track_format=0, white_balance=exif_info.get("WhiteBalance", 0), aperture=exif_info.get("ApertureValue", 0.0), bit_rate=exif_info.get("DataRate", 0.0), duration=exif_info.get("MovieDuration", 0.0), exposure_bias=exif_info.get("ExposureBiasValue", 0.0), focal_length=exif_info.get("FocalLength", 0.0), fps=exif_info.get("FPS", 0.0), latitude=exif_info.get("Latitude", 0.0), longitude=exif_info.get("Longitude", 0.0), shutter_speed=exif_info.get("ShutterSpeed", 0.0), camera_make=exif_info.get("Make", ""), camera_model=exif_info.get("Model", ""), codec="", lens_model=exif_info.get("LensModel", ""), software=exif_info.get("Software", ""), dict=exif_info, ) @property def burst_albums(self) -> list[str]: """For iPhoto, returns self.albums; this is different behavior than Photos""" return self.albums @property def burst_album_info(self) -> list[iPhotoAlbumInfo]: """For iPhoto, returns self.album_info; this is different behavior than Photos""" return self.album_info @property def burst(self) -> bool: """Returns True if photo is part of a Burst photo set, otherwise False""" return bool(self._info["burst_uuid"]) @property def burst_photos(self) -> list[PhotoInfo]: """If photo is a burst photo, returns list of iPhotoPhotoInfo objects that are part of the same burst photo set; otherwise returns empty list. self is not included in the returned list""" if not self.burst: return [] burst_uuid = self._info["burst_uuid"] return [ iPhotoPhotoInfo(uuid, self._db) for uuid in self._db._db_photos if uuid != self.uuid and self._db._db_photos[uuid]["burst_uuid"] == burst_uuid ] @cached_property def hexdigest(self) -> str: """Returns a unique digest of the photo's properties and metadata; useful for detecting changes in any property/metadata of the photo""" return hexdigest(self._json_hexdigest()) @property def score(self) -> ScoreInfo: return ScoreInfo( overall=0.0, curation=0.0, promotion=0.0, highlight_visibility=0.0, behavioral=0.0, failure=0.0, harmonious_color=0.0, immersiveness=0.0, interaction=0.0, interesting_subject=0.0, intrusive_object_presence=0.0, lively_color=0.0, low_light=0.0, noise=0.0, pleasant_camera_tilt=0.0, pleasant_composition=0.0, pleasant_lighting=0.0, pleasant_pattern=0.0, pleasant_perspective=0.0, pleasant_post_processing=0.0, pleasant_reflection=0.0, pleasant_symmetry=0.0, sharply_focused_subject=0.0, tastefully_blurred=0.0, well_chosen_subject=0.0, well_framed_subject=0.0, well_timed_shot=0.0, ) @property def orientation(self) -> int: """returns EXIF orientation of the current photo version as int or 0 if current orientation cannot be determined""" # iPhoto doesn't store orientation but does store rotation from which we can get orientation rotation = self._info.get("rotation") if rotation is None: return 0 return angle_to_exif_orientation(rotation)
[docs] def export( self, dest: str, filename: str | None = None, edited: bool = False, raw_photo: bool = False, export_as_hardlink: bool = False, overwrite: bool = False, increment: bool = True, sidecar_json: bool = False, sidecar_exiftool: bool = False, sidecar_xmp: bool = False, exiftool: bool = False, use_albums_as_keywords: bool = False, use_persons_as_keywords: bool = False, keyword_template: list[str] | None = None, description_template: str | None = None, render_options: RenderOptions | None = None, **kwargs, ) -> list[str]: """Export a photo Args: dest: must be valid destination path (or exception raised) filename: (optional): name of exported picture; if not provided, will use current filename **NOTE**: if provided, user must ensure file extension (suffix) is correct. For example, if photo is .CR2 file, edited image may be .jpeg. If you provide an extension different than what the actual file is, export will print a warning but will export the photo using the incorrect file extension (unless use_photos_export is true, in which case export will use the extension provided by Photos upon export; in this case, an incorrect extension is silently ignored). e.g. to get the extension of the edited photo, reference iPhotoPhotoInfo.path_edited edited: (boolean, default=False); if True will export the edited version of the photo, otherwise exports the original version (or raise exception if no edited version) raw_photo: (boolean, default=False); if True, will also export the associated RAW photo export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them overwrite: (boolean, default=False); if True will overwrite files if they already 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 sidecar_json: if set will write a json sidecar with data in format readable by exiftool sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`) sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`) sidecar_xmp: if set will write an XMP sidecar with IPTC data sidecar filename will be dest/filename.xmp exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file returns list of full paths to the exported files use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords when exporting metadata with exiftool or sidecar use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords when exporting metadata with exiftool or sidecar keyword_template: (list of strings); list of template strings that will be rendered as used as keywords description_template: string; optional template string that will be rendered for use as photo description render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer Returns: list of paths to photos exported """ if kwargs: raise NotImplementedError( f"Unsupported export options: {', '.join(kwargs.keys())}" ) exporter = PhotoExporter(self) sidecar = 0 if sidecar_json: sidecar |= SIDECAR_JSON if sidecar_exiftool: sidecar |= SIDECAR_EXIFTOOL if sidecar_xmp: sidecar |= SIDECAR_XMP if not filename: if not edited: filename = self.original_filename else: original_name = pathlib.Path(self.original_filename) if self.path_edited: ext = pathlib.Path(self.path_edited).suffix else: uti = self.uti_edited if edited and self.uti_edited else self.uti ext = get_preferred_uti_extension(uti) ext = f".{ext}" filename = f"{original_name.stem}_edited{ext}" options = ExportOptions( description_template=description_template, edited=edited, exiftool=exiftool, export_as_hardlink=export_as_hardlink, increment=increment, keyword_template=keyword_template, overwrite=overwrite, raw_photo=raw_photo, render_options=render_options, sidecar=sidecar, use_albums_as_keywords=use_albums_as_keywords, use_persons_as_keywords=use_persons_as_keywords, ) results = exporter.export(dest, filename=filename, options=options) return results.exported
[docs] def render_template( self, template_str: str, options: RenderOptions | None = None ) -> tuple[list[str], list[str]]: """Renders a template string for iPhotoPhotoInfo instance using PhotoTemplate Args: template_str: a template string with fields to render options: a RenderOptions instance Returns: ([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values """ options = options or RenderOptions() template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path) return template.render(template_str, options)
@cached_property def exiftool(self) -> ExifToolCaching | None: """Returns a ExifToolCaching (read-only instance of ExifTool) object for the photo. Requires that exiftool (https://exiftool.org/) be installed If exiftool not installed, logs warning and returns None If photo path is missing, returns None """ try: exiftool_path = self._db._exiftool_path or get_exiftool_path() if self.path is not None and pathlib.Path(self.path).is_file(): exiftool = ExifToolCaching(self.path, exiftool=exiftool_path) else: exiftool = None except FileNotFoundError: # get_exiftool_path raises FileNotFoundError if exiftool not found exiftool = None logging.warning( "exiftool not in path; download and install from https://exiftool.org/" ) return exiftool
[docs] def asdict(self, shallow: bool = True) -> dict[str, Any]: """Return dict representation of iPhotoPhotoInfo object. Args: shallow: if True, return shallow representation (does not contain folder_info, person_info, etc.) Returns: dict representation of iPhotoPhotoInfo object Note: The shallow representation is used internally by export as it contains only the subset of data needed for export. """ 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} likes = [like.asdict() for like in self.likes] place = self.place.asdict() if self.place else {} score = dataclasses.asdict(self.score) if self.score else {} # do not add any new properties to data_dict as this is used by export to determine # if a photo needs to be re-exported and adding new properties may cause all photos # to be re-exported # see below `if not shallow:` dict_data = { "albums": self.albums, "burst": self.burst, "cloud_guid": self.cloud_guid, "cloud_owner_hashed_id": self.cloud_owner_hashed_id, "comments": comments, "date_added": self.date_added, "date_modified": self.date_modified, "date_trashed": self.date_trashed, "date": self.date, "description": self.description, "exif_info": exif_info, "external_edit": self.external_edit, "face_info": face_info, "favorite": self.favorite, "filename": self.filename, "fingerprint": self.fingerprint, "folders": folders, "has_raw": self.has_raw, "hasadjustments": self.hasadjustments, "hdr": self.hdr, "height": self.height, "hidden": self.hidden, "incloud": self.incloud, "intrash": self.intrash, "iscloudasset": self.iscloudasset, "ismissing": self.ismissing, "ismovie": self.ismovie, "isphoto": self.isphoto, "israw": self.israw, "isreference": self.isreference, "keywords": self.keywords, "labels": self.labels, "latitude": self._latitude, "library": self._db._library_path, "likes": likes, "live_photo": self.live_photo, "location": self.location, "longitude": self._longitude, "orientation": self.orientation, "original_filename": self.original_filename, "original_filesize": self.original_filesize, "original_height": self.original_height, "original_orientation": self.original_orientation, "original_width": self.original_width, "owner": self.owner, "panorama": self.panorama, "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, "persons": self.persons, "place": place, "portrait": self.portrait, "raw_original": self.raw_original, "score": score, "screenshot": self.screenshot, "screen_recording": self.screen_recording, "selfie": self.selfie, "shared": self.shared, "slow_mo": self.slow_mo, "time_lapse": self.time_lapse, "title": self.title, "tzoffset": self.tzoffset, "uti_edited": self.uti_edited, "uti_original": self.uti_original, "uti_raw": self.uti_raw, "uti": self.uti, "uuid": self.uuid, "visible": self.visible, "width": self.width, } # non-shallow keys # add any new properties here 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 {} ) dict_data["syndicated"] = self.syndicated dict_data["saved_to_library"] = self.saved_to_library dict_data["shared_moment"] = self.shared_moment dict_data["shared_library"] = self.shared_library dict_data["rating"] = self.rating return dict_data
[docs] def json(self, indent: int | None = None, shallow: bool = True) -> str: """Return JSON representation Args: indent: indent level for JSON, if None, no indent shallow: if True, return shallow JSON representation (does not contain folder_info, person_info, etc.) 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(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 # but do not sort certain items like location if k in ["location"]: continue if v and isinstance(v, (list, tuple)) and not isinstance(v[0], dict): dict_data[k] = sorted(v, key=lambda v: v if v is not None else "") return json.dumps(dict_data, sort_keys=True, default=default, indent=indent)
def _json_hexdigest(self) -> str: """JSON for use by hexdigest()""" # This differs from json() because hexdigest must not change if metadata changed # With json(), sort order of lists of dicts is not consistent but these aren't needed # for computing hexdigest so we can ignore them # also don't use visible because it changes based on Photos UI state def default(o): if isinstance(o, (datetime.date, datetime.datetime)): return o.isoformat() dict_data = self.asdict(shallow=True) for k in ["face_info", "visible"]: del dict_data[k] for k, v in dict_data.items(): # sort lists such as keywords so JSON is consistent # but do not sort certain items like location if k in ["location"]: continue if v and isinstance(v, (list, tuple)) and not isinstance(v[0], dict): dict_data[k] = sorted(v, key=lambda v: v if v is not None else "") return json.dumps(dict_data, sort_keys=True, default=default) def _get_faces(self) -> list[dict[str, Any]]: """Get faces for photo""" return ( self._db._db_photos[self._uuid].get("edited_faces", []) if self.hasadjustments else self._db._db_photos[self._uuid].get("faces", []) ) def __repr__(self) -> str: return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})" def __str__(self) -> str: """string representation of iPhotoPhotoInfo object""" date_iso = self.date.isoformat() date_modified_iso = ( self.date_modified.isoformat() if self.date_modified else None ) exif = str(self.exif_info) if self.exif_info else None score = str(self.score) if self.score else None info = { "uuid": self.uuid, "filename": self.filename, "original_filename": self.original_filename, "date": date_iso, "description": self.description, "title": self.title, "keywords": self.keywords, "albums": self.albums, "persons": self.persons, "path": self.path, "ismissing": self.ismissing, "hasadjustments": self.hasadjustments, "external_edit": self.external_edit, "favorite": self.favorite, "hidden": self.hidden, "latitude": self._latitude, "longitude": self._longitude, "path_edited": self.path_edited, "shared": self.shared, "isphoto": self.isphoto, "ismovie": self.ismovie, "uti": self.uti, "burst": self.burst, "live_photo": self.live_photo, "path_live_photo": self.path_live_photo, "iscloudasset": self.iscloudasset, "incloud": self.incloud, "date_modified": date_modified_iso, "portrait": self.portrait, "screenshot": self.screenshot, "screen_recording": self.screen_recording, "slow_mo": self.slow_mo, "time_lapse": self.time_lapse, "hdr": self.hdr, "selfie": self.selfie, "panorama": self.panorama, "has_raw": self.has_raw, "uti_raw": self.uti_raw, "path_raw": self.path_raw, "place": self.place, "exif": exif, "score": score, "intrash": self.intrash, "height": self.height, "width": self.width, "orientation": self.orientation, "original_height": self.original_height, "original_width": self.original_width, "original_orientation": self.original_orientation, "original_filesize": self.original_filesize, } return yaml.dump(info, sort_keys=False) def __getattr__(self, name: str) -> Any: """If attribute is not found in iPhotoPhotoInfo, look at PhotoInfo and return default type""" if name not in self._attributes: raise AttributeError(f"Invalid attribute: {name}") logger.debug(f"Returning default value for {name}; not implemented for iPhoto") try: return default_return_value(self._attributes[name]) except Exception as e: # on <= Python 3.9, default_return_value can raise exception for Union types logger.warning("Error getting default value for {name}: {e}") return None
[docs] class iPhotoPersonInfo: """PersonInfo implementation for iPhoto""" def __init__(self, face: dict[str, Any], db: iPhotoDB): self._face = face self._db = db face_key = self._face["face_key"] for person in self._db._db_persons.values(): if face_key == person["face_key"]: self._person = person break else: logger.debug(f"Didn't find person for face {face_key}") self._person = {} @property def uuid(self) -> str: """UUID of person""" return self._person.get("uuid", "") @property def name(self) -> str: """Name of person""" # self._person["name"] could be None return self._person.get("name") or _UNKNOWN_PERSON @property def keyphoto(self) -> iPhotoPhotoInfo | None: """Key photo for person""" logger.debug("Not implemented for iPhoto") return None @property def keyface(self) -> iPhotoFaceInfo | None: """Key face for person""" logger.debug("Not implemented for iPhoto") return None @property def photos(self) -> list[iPhotoPhotoInfo]: """List of photos face is contained in""" photos = [] for uuid, photo in self._db._db_photos: photos.extend( iPhotoPhotoInfo(uuid, self._db) for face in photo["faces"] if face["face_key"] == self._face["face_key"] ) return photos @property def facecount(self) -> int: """Count of faces for person""" faces = 0 for photo in self._db._db_photos.values(): for face in photo["faces"]: if face["face_key"] == self._face["face_key"]: faces += 1 return faces @property def favorite(self) -> bool: """Returns False for iPhoto""" logger.debug("Not implemented for iPhoto") return False @property def sort_order(self) -> int: """Always returns 0 for iPhoto""" logger.debug("Not implemented for iPhoto") return 0 @property def feature_less(self) -> bool: """Always returns False for iPhoto""" logger.debug("Not implemented for iPhoto") return False
[docs] def asdict(self) -> dict[str, Any]: """Return person as dict""" return { "uuid": self.uuid, "name": self.name, "displayname": self.name, "keyface": self.keyface, "facecount": self.facecount, "keyphoto": self.keyphoto, "favorite": self.favorite, "sort_order": self.sort_order, "feature_less": self.feature_less, }
[docs] def json(self) -> str: """Return person as json""" return json.dumps(self.asdict())
[docs] class iPhotoFaceInfo: """FaceInfo implementation for iPhoto""" def __init__(self, photo: iPhotoPhotoInfo, face: dict[str, Any], db: iPhotoDB): self._face = face self._db = db self.photo = photo face_key = self._face["face_key"] for person in self._db._db_persons.values(): if face_key == person["face_key"]: self._person = person break else: logger.debug(f"Didn't find person for face {face_key}") self._person = {} @property def name(self) -> str | None: """Name of person in the photo or None""" # self._person["name"] could be None return self._person.get("name") or _UNKNOWN_PERSON @property def center(self) -> tuple[int, int]: """Coordinates, in PIL format, for center of face Returns: tuple of coordinates in form (x, y) """ return self._make_point((self.center_x, self.center_y)) @property def center_x(self) -> float: """X coordinate for center of face as percent of width""" return self._face["topLeftX"] + self._face["width"] / 2 @property def center_y(self) -> float: """Y coordinate for center of face as percent of height""" if self.photo._info["orientation"] == "portrait": # y coords are reversed for portraits return self._face["bottomRightY"] + self._face["height"] / 2 return self._face["topLeftY"] + self._face["height"] / 2 @property def quality(self) -> float: """Quality (confidence) of face detection""" return self._face["confidence"] def _make_point(self, xy: tuple[int, int]) -> tuple[int, int]: """Translate an (x, y) tuple based on image orientation and convert to image coordinates Arguments: xy: tuple of (x, y) coordinates for point to translate in format used by Photos (percent of height/width) Returns: (x, y) tuple of translated coordinates in pixels in PIL format/reference frame """ x, y = xy photo = self.photo if photo.hasadjustments: # edited version dx = photo.width dy = photo.height else: # original version dx = photo.original_width dy = photo.original_height return (int(x * dx), int(y * dy)) @property def size(self) -> float: """Size of face as percent of image width""" return self._face["width"] @property def size_pixels(self) -> int: """Size of face in pixels (centered around center_x, center_y) Returns: size, in int pixels, of a circle drawn around the center of the face """ width = ( self.photo.width if self.photo.hasadjustments else self.photo.original_width ) return self._face["width"] * width @property def person_info(self) -> iPhotoPersonInfo: """iPhotoPersonInfo object for face""" return iPhotoPersonInfo(self._face, self._db)
[docs] def roll_pitch_yaw(self) -> tuple[float, float, float]: """Roll, pitch, yaw of face in radians as tuple""" return (0, 0, 0)
@property def roll(self) -> float: """Return roll angle in radians of the face region""" roll, _, _ = self.roll_pitch_yaw() return roll @property def pitch(self) -> float: """Return pitch angle in radians of the face region""" _, pitch, _ = self.roll_pitch_yaw() return pitch @property def yaw(self) -> float: """Return yaw angle in radians of the face region""" _, _, yaw = self.roll_pitch_yaw() return yaw @property def mwg_rs_area(self) -> MWG_RS_Area: """Get coordinates for Metadata Working Group Region Area. Returns: MWG_RS_Area named tuple with x, y, h, w where: x = stArea:x y = stArea:y h = stArea:h w = stArea:w Reference: https://photo.stackexchange.com/questions/106410/how-does-xmp-define-the-face-region """ x, y = self.center_x, self.center_y w = self._face["width"] h = self._face["height"] return MWG_RS_Area(x, y, h, w) @property def mpri_reg_rect(self) -> MPRI_Reg_Rect: """Get coordinates for Microsoft Photo Region Rectangle. Returns: MPRI_Reg_Rect named tuple with x, y, h, w where: x = x coordinate of top left corner of rectangle y = y coordinate of top left corner of rectangle h = height of rectangle w = width of rectangle Reference: https://docs.microsoft.com/en-us/windows/win32/wic/-wic-people-tagging """ # x, y = self.center_x, self.center_y photo = self.photo if photo.hasadjustments: # edited version image_width = photo.width image_height = photo.height else: # original version image_width = photo.original_width image_height = photo.original_height h = self.size_pixels / image_width w = self.size_pixels / image_height x = int(self._face["topLeftX"] * image_width) if photo._info["orientation"] == "portrait": # y coords are reversed for portraits y = int(self._face["bottomRightY"] * image_height) else: y = int(self._face["topLeftY"] * image_height) return MPRI_Reg_Rect(x, y, h, w)
[docs] def face_rect(self) -> list[tuple[int, int]]: """Get face rectangle coordinates for current version of the associated image If image has been edited, rectangle applies to edited version, otherwise original version Coordinates in format and reference frame used by PIL Returns: list [(x0, x1), (y0, y1)] of coordinates in reference frame used by PIL """ photo = self.photo if photo.hasadjustments: # edited version image_width = photo.width image_height = photo.height else: # original version image_width = photo.original_width image_height = photo.original_height # convert to PIL format # sourcery skip: hoist-statement-from-if if self.photo._info["orientation"] == "portrait": # y coordinates are reversed x0 = int(self._face["topLeftX"] * image_width) y0 = int(self._face["bottomRightY"] * image_height) x1 = int(self._face["bottomRightX"] * image_width) y1 = int(self._face["topLeftY"] * image_height) else: x0 = int(self._face["topLeftX"] * image_width) y0 = int(self._face["topLeftY"] * image_height) x1 = int(self._face["bottomRightX"] * image_width) y1 = int(self._face["bottomRightY"] * image_height) return [(x0, y0), (x1, y1)]
[docs] def asdict(self) -> dict[str, Any]: """Returns dict representation of class instance""" # roll, pitch, yaw = self.roll_pitch_yaw() return { # "uuid": self.uuid, "name": self.name, "center_x": self.center_x, "center_y": self.center_y, "center": self.center, "size": self.size, "face_rect": self.face_rect(), "mpri_reg_rect": self.mpri_reg_rect._asdict(), "mwg_rs_area": self.mwg_rs_area._asdict(), "quality": self.quality, # "source_width": self.source_width, # "source_height": self.source_height, }
[docs] def json(self) -> str: """Return JSON representation of iPhotoFaceInfo instance""" return json.dumps(self.asdict())
[docs] class iPhotoAlbumInfo: """AlbumInfo class for iPhoto""" def __init__(self, album: dict[str, Any], db: iPhotoDB): self._album = album self._db = db self._album_id = album["albumId"] @property def uuid(self) -> str: """UUID of album""" return self._album["album_uuid"] @property def title(self) -> str: """Title of album""" return self._album["name"] @property def photos(self) -> list[iPhotoPhotoInfo]: """Return list of photos contained in the album""" photos = [] for uuid, photo in self._db._db_photos.items(): for album in photo["albums"]: if album["albumId"] == self._album_id: photos.append(iPhotoPhotoInfo(uuid, self._db)) break return photos @property def folder_names(self) -> list[str]: """Return hierarchical list of folders the album is contained in the folder list is in form: ["Top level folder", "sub folder 1", "sub folder 2", ...] or empty list if album is not in any folders """ path = self._album["path"] if path: path = path[:-1] # remove album name from end of path return path @property def folder_list(self) -> list[iPhotoFolderInfo]: """Returns hierachical list of iPhotoFolderInfo objects for each folder the album is contained in or empty list if album is not in any folders """ folder_list = [] parent = self.parent while parent: folder_list.insert(0, parent) parent = parent.parent return folder_list @property def parent(self) -> iPhotoFolderInfo | None: """returns iPhotoFolderInfo object for parent folder or None if no parent (e.g. top-level album)""" parent_id = self._album["folder_id"] parent_folder = self._db._db_folders[parent_id] if bool(parent_folder["isMagic"]): return None return iPhotoFolderInfo(parent_folder, self._db) @property def sort_order(self) -> int: """Return sort order; not implemented, always 0""" logger.debug("Not implemented for iPhoto") return 0
[docs] def photo_index(self, photo: iPhotoPhotoInfo) -> int: """Return index of photo in album; not implemented, always 0""" logger.debug("Not implemented for iPhoto") return 0
[docs] def asdict(self) -> dict[str, Any]: """Return album info as a dict; does not include photos""" return { "uuid": self.uuid, "title": self.title, "folder_names": self.folder_names, "folder_list": [f.uuid for f in self.folder_list], "sort_order": self.sort_order, "parent": self.parent.uuid if self.parent else None, }
[docs] def json(self) -> str: """JSON representation of album""" return json.dumps(self.asdict())
def __len__(self) -> int: """return number of photos contained in album""" return len(self.photos)
[docs] class iPhotoFolderInfo: """ Info about a specific folder, contains all the details about the folder including folders, albums, etc """ def __init__(self, folder: dict[Any, Any], db: iPhotoDB): self._folder = folder self._folderid = folder["modelId"] self._db = db @property def title(self) -> str: """Title of folder""" return self._folder["name"] @property def uuid(self) -> str: """UUID of folder""" return self._folder["uuid"] @property def album_info(self) -> list[iPhotoAlbumInfo]: """Return list of albums (as iPhotoAlbumInfo objects) contained in the folder""" folder_albums = [] for albums in self._db._db_albums.values(): folder_albums.extend( iPhotoAlbumInfo(album, self._db) for album in albums if album["folder_id"] == self._folderid ) return folder_albums @property def parent(self) -> iPhotoFolderInfo | None: """Return iPhotoFolderInfo object for parent or None if no parent (e.g. top-level folder)""" if parent_uuid := self._folder["parentFolderUuid"]: return next( ( ( None if bool(folder["isMagic"]) else iPhotoFolderInfo(folder, self._db) ) for folder in self._db._db_folders.values() if folder["uuid"] == parent_uuid ), None, ) else: return None @property def subfolders(self) -> list[iPhotoFolderInfo]: """Return list of folders (as FolderInfo objects) contained in the folder""" subfolders = [] for folder in self._db._db_folders.values(): if folder["parentFolderUuid"] == self.uuid: if bool(folder["isMagic"]): # skip magic folders like "TopLevelAlbums" continue subfolders.append(iPhotoFolderInfo(folder, self._db)) return subfolders
[docs] def asdict(self) -> dict[str, Any]: """Return folder info as a dict""" return { "title": self.title, "uuid": self.uuid, "parent": self.parent.uuid if self.parent is not None else None, "subfolders": [f.uuid for f in self.subfolders], "albums": [a.uuid for a in self.album_info], }
[docs] def json(self) -> str: """Return folder info as json""" return json.dumps(self.asdict())
def __len__(self) -> int: """returns count of folders + albums contained in the folder""" return len(self.subfolders) + len(self.album_info)
class iPhotoEventInfo: """iPhoto Event info""" def __init__(self, event: dict[Any, Any], db: iPhotoDB): self._event = event # self._folderid = folder["modelId"] self._db = db @property def pk(self) -> int: """Primary key of the event.""" return int(self._event.get("modelId", 0)) @property def location(self) -> tuple[None, None]: """Location of the event.""" logger.debug("Not implemented for iPhoto") return None, None @property def title(self) -> str: """Title of the event.""" return str(self._event.get("name", "")) @property def subtitle(self) -> str: """Subtitle of the event.""" logger.debug("Not implemented for iPhoto") return "" @property def start_date(self) -> datetime.datetime | None: """Start date of the event.""" return iphoto_date_to_datetime( self._event["min_image_date"], self._event["min_image_tz"], ) @property def end_date(self) -> datetime.datetime | None: """Stop date of the event.""" return iphoto_date_to_datetime( self._event["max_image_date"], self._event["max_image_tz"], ) @property def date(self) -> datetime.datetime | None: """Date of the event.""" # use end_date as iPhoto doesn't record a separate date return self.end_date @property def _date_created(self) -> datetime.datetime | None: """Date the event created in iPhoto.""" # not common with Photos MomentInfo so leave private return naive_iphoto_date_to_datetime(self._event["date"]) @property def modification_date(self) -> datetime.datetime | None: """Modification date of the event.""" logger.debug("Not implemented for iPhoto") return None @property def photos(self) -> list[iPhotoPhotoInfo]: """All photos in this moment""" roll = self._event.get("modelId") photos = [p for p in self._db._db_photos.values() if p.get("roll") == roll] return [iPhotoPhotoInfo(p["uuid"], self._db) for p in photos] @property def note(self) -> str: """Return note associated with event""" roll = self._event.get("modelId") if note := self._db._db_event_notes.get(roll): return note.get("note", "") return "" def asdict(self) -> dict[str, Any]: """Returns all moment info as dictionary""" return { "pk": self.pk, "location": self.location, "title": self.title, "subtitle": self.subtitle, "start_date": self.start_date.isoformat() if self.start_date else None, "end_date": self.end_date.isoformat() if self.end_date else None, "date": self.date.isoformat() if self.date else None, "modification_date": ( self.modification_date.isoformat() if self.modification_date else None ), "note": self.note, # "photos": self.photos, } class iPhotoMomentInfo(iPhotoEventInfo): """Info about a photo moment; iPhoto doesn't have moments but Events are close""" ... @dataclasses.dataclass(frozen=True) class iPhotoExifInfo: """EXIF info associated with a photo from the iPhoto library""" flash_fired: bool iso: int metering_mode: int sample_rate: int track_format: int white_balance: int aperture: float bit_rate: float duration: float exposure_bias: float focal_length: float fps: float latitude: float longitude: float shutter_speed: float camera_make: str camera_model: str codec: str lens_model: str software: str dict: dict[str, Any] ### Utility functions ### def iphoto_date_to_datetime( date: int | None, tz: str | None = None ) -> datetime.datetime: """ "Convert iPhoto date to datetime; if tz provided, will be timezone aware Args: date: iPhoto date tz: timezone name Returns: datetime.datetime Note: If date is None or invalid, will return 1970-01-01 00:00:00 """ try: dt = datetime.datetime.fromtimestamp(date + TIME_DELTA) except (ValueError, TypeError): dt = datetime.datetime(1970, 1, 1) if tz: dt = dt.replace(tzinfo=ZoneInfo(tz)) return dt def naive_iphoto_date_to_datetime(date: int) -> datetime.datetime: """ "Convert iPhoto date to datetime with local timezone Args: date: iPhoto date Returns: timezone aware datetime.datetime in local timezone Note: If date is invalid, will return 1970-01-01 00:00:00 """ try: dt = datetime.datetime.fromtimestamp(date + TIME_DELTA) except ValueError: dt = datetime.datetime(1970, 1, 1) return datetime_naive_to_local(dt) def default_return_value(name: str) -> Any: """Inspect name and return default value if there is one otherwise None optimized for PhotoInfo may not work for other classes. If used to inspect a method or function that uses '|' to indicate a UnionType, requires Python 3.10 or greater because get_type_hints will fail on union types in earlier versions of Python. """ if isinstance(name, property): hints = get_type_hints(name.fget) elif isinstance(name, functools.cached_property): hints = get_type_hints(name.func) else: hints = get_type_hints(name) return_type = hints.get("return") # inspect return_type and take best guess at default value # needs to run on Python 3.9 so can't depend on types.UnionType (3.10) return_type = str(return_type) if "| None" in return_type: return None elif return_type == str(bool): return False elif return_type == str(str): return "" elif return_type == str(int): return 0 elif return_type == str(float): return 0.0 elif return_type.startswith("list[") or return_type.startswith("List["): return [] elif "tuple[None, None]" in return_type: return (None, None) elif return_type.startswith("tuple[") or return_type.startswith("Tuple["): return () elif return_type.startswith("dict[") or return_type.startswith("Dict["): return dict() elif return_type.startswith("set[") or return_type.startswith("Set["): return set() else: logger.warning(f"Unknown return type: {return_type}") return None def get_user_attributes(cls: Any) -> dict[str, Any]: """Get user attributes from a class""" # reference: https://stackoverflow.com/questions/4241171/inspect-python-class-attributes builtin_attributes = dir(type("dummy", (object,), {})) attrs = {} bases = reversed(inspect.getmro(cls)) for base in bases: if hasattr(base, "__dict__"): attrs.update(base.__dict__) elif hasattr(base, "__slots__"): if hasattr(base, base.__slots__[0]): # We're dealing with a non-string sequence or one char string for item in base.__slots__: attrs[item] = getattr(base, item) else: # We're dealing with a single identifier as a string attrs[base.__slots__] = getattr(base, base.__slots__) for key in builtin_attributes: del attrs[key] # we can be sure it will be present so no need to guard this return attrs def is_iphoto_library(library: str | pathlib.Path | os.PathLike) -> bool: """Return True if library is an iPhoto library, else False""" library = library if isinstance(library, pathlib.Path) else pathlib.Path(library) if not library.is_dir(): return False if not library.joinpath("AlbumData.xml").is_file(): return False return bool(library.joinpath("Database", "Library.apdb").is_file())