From a73db3a1bbc2a320d68dcf7f31f1074bc23a242a Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Tue, 29 Jun 2021 17:46:40 -0700 Subject: [PATCH] Updated photokit code to work with raw+jpeg, #478 --- osxphotos/_version.py | 2 +- osxphotos/photokit.py | 367 +++++++++++++++++++++++------------------ tests/test_photokit.py | 37 +++++ 3 files changed, 246 insertions(+), 160 deletions(-) diff --git a/osxphotos/_version.py b/osxphotos/_version.py index c4872d50..9fa5a3dc 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.42.48" +__version__ = "0.42.49" diff --git a/osxphotos/photokit.py b/osxphotos/photokit.py index 3a4e5e02..9da8990c 100644 --- a/osxphotos/photokit.py +++ b/osxphotos/photokit.py @@ -65,7 +65,7 @@ MIN_SLEEP = 0.015 ### utility functions def NSURL_to_path(url): - """ Convert URL string as represented by NSURL to a path string """ + """Convert URL string as represented by NSURL to a path string""" nsurl = Foundation.NSURL.alloc().initWithString_( Foundation.NSString.alloc().initWithString_(str(url)) ) @@ -75,7 +75,7 @@ def NSURL_to_path(url): def path_to_NSURL(path): - """ Convert path string to NSURL """ + """Convert path string to NSURL""" pathstr = Foundation.NSString.alloc().initWithString_(str(path)) url = Foundation.NSURL.fileURLWithPath_(pathstr) pathstr.dealloc() @@ -83,10 +83,10 @@ def path_to_NSURL(path): def check_photokit_authorization(): - """ Check authorization to use user's Photos Library + """Check authorization to use user's Photos Library Returns: - True if user has authorized access to the Photos library, otherwise False + True if user has authorized access to the Photos library, otherwise False """ auth_status = Photos.PHPhotoLibrary.authorizationStatus() @@ -94,7 +94,7 @@ def check_photokit_authorization(): def request_photokit_authorization(): - """ Request authorization to user's Photos Library + """Request authorization to user's Photos Library Returns: authorization status @@ -136,39 +136,39 @@ def request_photokit_authorization(): ### exceptions class PhotoKitError(Exception): - """Base class for exceptions in this module. """ + """Base class for exceptions in this module.""" pass class PhotoKitFetchFailed(PhotoKitError): - """Exception raised for errors in the input. """ + """Exception raised for errors in the input.""" pass class PhotoKitAuthError(PhotoKitError): - """Exception raised if unable to authorize use of PhotoKit. """ + """Exception raised if unable to authorize use of PhotoKit.""" pass class PhotoKitExportError(PhotoKitError): - """Exception raised if unable to export asset. """ + """Exception raised if unable to export asset.""" pass class PhotoKitMediaTypeError(PhotoKitError): - """ Exception raised if an unknown mediaType() is encountered """ + """Exception raised if an unknown mediaType() is encountered""" pass ### helper classes class ImageData: - """ Simple class to hold the data passed to the handler for - requestImageDataAndOrientationForAsset_options_resultHandler_ + """Simple class to hold the data passed to the handler for + requestImageDataAndOrientationForAsset_options_resultHandler_ """ def __init__( @@ -182,8 +182,7 @@ class ImageData: class AVAssetData: - """ Simple class to hold the data passed to the handler for - """ + """Simple class to hold the data passed to the handler for""" def __init__(self): self.asset = None @@ -193,7 +192,7 @@ class AVAssetData: class PHAssetResourceData: - """ Simple class to hold data from + """Simple class to hold data from requestDataForAssetResource:options:dataReceivedHandler:completionHandler: """ @@ -212,8 +211,8 @@ class PHAssetResourceData: class PhotoKitNotificationDelegate(NSObject): - """ Handles notifications from NotificationCenter; - used with asynchronous PhotoKit requests to stop event loop when complete + """Handles notifications from NotificationCenter; + used with asynchronous PhotoKit requests to stop event loop when complete """ def liveNotification_(self, note): @@ -227,11 +226,11 @@ class PhotoKitNotificationDelegate(NSObject): ### main class implementation class PhotoAsset: - """ PhotoKit PHAsset representation """ + """PhotoKit PHAsset representation""" def __init__(self, manager, phasset): - """ Return a PhotoAsset object - + """Return a PhotoAsset object + Args: manager = ImageManager object phasset: a PHAsset object @@ -242,32 +241,32 @@ class PhotoAsset: @property def phasset(self): - """ Return PHAsset instance """ + """Return PHAsset instance""" return self._phasset @property def uuid(self): - """ Return local identifier (UUID) of PHAsset """ + """Return local identifier (UUID) of PHAsset""" return self._phasset.localIdentifier() @property def isphoto(self): - """ Return True if asset is photo (image), otherwise False """ + """Return True if asset is photo (image), otherwise False""" return self.media_type == Photos.PHAssetMediaTypeImage @property def ismovie(self): - """ Return True if asset is movie (video), otherwise False """ + """Return True if asset is movie (video), otherwise False""" return self.media_type == Photos.PHAssetMediaTypeVideo @property def isaudio(self): - """ Return True if asset is audio, otherwise False """ + """Return True if asset is audio, otherwise False""" return self.media_type == Photos.PHAssetMediaTypeAudio @property def original_filename(self): - """ Return original filename asset was imported with """ + """Return original filename asset was imported with""" resources = self._resources() for resource in resources: if ( @@ -279,10 +278,22 @@ class PhotoAsset: return resource.originalFilename() return None + @property + def raw_filename(self): + """Return RAW filename for RAW+JPEG photos or None if no RAW asset""" + resources = self._resources() + for resource in resources: + if ( + self.isphoto + and resource.type() == Photos.PHAssetResourceTypeAlternatePhoto + ): + return resource.originalFilename() + return None + @property def hasadjustments(self): - """ Check to see if a PHAsset has adjustment data associated with it - Returns False if no adjustments, True if any adjustments """ + """Check to see if a PHAsset has adjustment data associated with it + Returns False if no adjustments, True if any adjustments""" # reference: https://developer.apple.com/documentation/photokit/phassetresource/1623988-assetresourcesforasset?language=objc @@ -299,112 +310,112 @@ class PhotoAsset: @property def media_type(self): - """ media type such as image or video """ + """media type such as image or video""" return self.phasset.mediaType() @property def media_subtypes(self): - """ media subtype """ + """media subtype""" return self.phasset.mediaSubtypes() @property def panorama(self): - """ return True if asset is panorama, otherwise False """ + """return True if asset is panorama, otherwise False""" return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoPanorama) @property def hdr(self): - """ return True if asset is HDR, otherwise False """ + """return True if asset is HDR, otherwise False""" return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoHDR) @property def screenshot(self): - """ return True if asset is screenshot, otherwise False """ + """return True if asset is screenshot, otherwise False""" return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoScreenshot) @property def live(self): - """ return True if asset is live, otherwise False """ + """return True if asset is live, otherwise False""" return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoLive) @property def streamed(self): - """ return True if asset is streamed video, otherwise False """ + """return True if asset is streamed video, otherwise False""" return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoStreamed) @property def slow_mo(self): - """ return True if asset is slow motion (high frame rate) video, otherwise False """ + """return True if asset is slow motion (high frame rate) video, otherwise False""" return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoHighFrameRate) @property def time_lapse(self): - """ return True if asset is time lapse video, otherwise False """ + """return True if asset is time lapse video, otherwise False""" return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoTimelapse) @property def portrait(self): - """ return True if asset is portrait (depth effect), otherwise False """ + """return True if asset is portrait (depth effect), otherwise False""" return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoDepthEffect) @property def burstid(self): - """ return burstIdentifier of image if image is burst photo otherwise None """ + """return burstIdentifier of image if image is burst photo otherwise None""" return self.phasset.burstIdentifier() @property def burst(self): - """ return True if image is burst otherwise False """ + """return True if image is burst otherwise False""" return bool(self.burstid) @property def source_type(self): - """ the means by which the asset entered the user's library """ + """the means by which the asset entered the user's library""" return self.phasset.sourceType() @property def pixel_width(self): - """ width in pixels """ + """width in pixels""" return self.phasset.pixelWidth() @property def pixel_height(self): - """ height in pixels """ + """height in pixels""" return self.phasset.pixelHeight() @property def date(self): - """ date asset was created """ + """date asset was created""" return self.phasset.creationDate() @property def date_modified(self): - """ date asset was modified """ + """date asset was modified""" return self.phasset.modificationDate() @property def location(self): - """ location of the asset """ + """location of the asset""" return self.phasset.location() @property def duration(self): - """ duration of the asset """ + """duration of the asset""" return self.phasset.duration() @property def favorite(self): - """ True if asset is favorite, otherwise False """ + """True if asset is favorite, otherwise False""" return self.phasset.isFavorite() @property def hidden(self): - """ True if asset is hidden, otherwise False """ + """True if asset is hidden, otherwise False""" return self.phasset.isHidden() def metadata(self, version=PHOTOS_VERSION_CURRENT): - """ Return dict of asset metadata - + """Return dict of asset metadata + Args: version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) """ @@ -412,17 +423,28 @@ class PhotoAsset: return imagedata.metadata def uti(self, version=PHOTOS_VERSION_CURRENT): - """ Return UTI of asset - + """Return UTI of asset + Args: version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) """ imagedata = self._request_image_data(version=version) return imagedata.uti + def uti_raw(self): + """Return UTI of RAW component of RAW+JPEG pair""" + resources = self._resources() + for resource in resources: + if ( + self.isphoto + and resource.type() == Photos.PHAssetResourceTypeAlternatePhoto + ): + return resource.uniformTypeIdentifier() + return None + def url(self, version=PHOTOS_VERSION_CURRENT): - """ Return URL of asset - + """Return URL of asset + Args: version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) """ @@ -430,8 +452,8 @@ class PhotoAsset: return str(imagedata.info["PHImageFileURLKey"]) def path(self, version=PHOTOS_VERSION_CURRENT): - """ Return path of asset - + """Return path of asset + Args: version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) """ @@ -440,8 +462,8 @@ class PhotoAsset: return url.fileSystemRepresentation().decode("utf-8") def orientation(self, version=PHOTOS_VERSION_CURRENT): - """ Return orientation of asset - + """Return orientation of asset + Args: version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) """ @@ -450,8 +472,8 @@ class PhotoAsset: @property def degraded(self, version=PHOTOS_VERSION_CURRENT): - """ Return True if asset is degraded version - + """Return True if asset is degraded version + Args: version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) """ @@ -459,15 +481,21 @@ class PhotoAsset: return imagedata.info["PHImageResultIsDegradedKey"] def export( - self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False + self, + dest, + filename=None, + version=PHOTOS_VERSION_CURRENT, + overwrite=False, + raw=False, ): - """ Export image to path + """Export image to path Args: dest: str, path to destination directory filename: str, optional name of exported file; if not provided, defaults to asset's original filename version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) overwrite: bool, if True, overwrites destination file if it already exists; default is False + raw: bool, if True, export RAW component of RAW+JPEG pair, default is False Returns: List of path to exported image(s) @@ -492,11 +520,28 @@ class PhotoAsset: output_file = None if self.isphoto: - imagedata = self._request_image_data(version=version) - if not imagedata.image_data: - raise PhotoKitExportError("Could not get image data") - - ext = get_preferred_uti_extension(imagedata.uti) + # will hold exported image data and needs to be cleaned up at end + imagedata = None + if raw: + # export the raw component + resources = self._resources() + for resource in resources: + if resource.type() == Photos.PHAssetResourceTypeAlternatePhoto: + data = self._request_resource_data(resource) + ext = pathlib.Path(self.raw_filename).suffix[1:] + break + else: + raise PhotoKitExportError( + "Could not get image data for RAW photo" + ) + else: + # TODO: if user has selected use RAW as original, this returns the RAW + # can get the jpeg with resource.type() == Photos.PHAssetResourceTypePhoto + imagedata = self._request_image_data(version=version) + if not imagedata.image_data: + raise PhotoKitExportError("Could not get image data") + ext = get_preferred_uti_extension(imagedata.uti) + data = imagedata.image_data output_file = dest / f"{filename.stem}.{ext}" @@ -504,7 +549,9 @@ class PhotoAsset: output_file = pathlib.Path(increment_filename(output_file)) with open(output_file, "wb") as fd: - fd.write(imagedata.image_data) + fd.write(data) + + if imagedata: del imagedata elif self.ismovie: videodata = self._request_video_data(version=version) @@ -526,14 +573,14 @@ class PhotoAsset: return [str(output_file)] def _request_image_data(self, version=PHOTOS_VERSION_ORIGINAL): - """ Request image data and metadata for self._phasset - + """Request image data and metadata for self._phasset + Args: version: which version to request - PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version + PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version PHOTOS_VERSION_CURRENT, request current version with all edits PHOTOS_VERSION_UNADJUSTED, request highest quality unadjusted version - + Returns: ImageData instance @@ -563,8 +610,8 @@ class PhotoAsset: event = threading.Event() def handler(imageData, dataUTI, orientation, info): - """ result handler for requestImageDataAndOrientationForAsset_options_resultHandler_ - all returned by the request is set as properties of nonlocal data (Fetchdata object) """ + """result handler for requestImageDataAndOrientationForAsset_options_resultHandler_ + all returned by the request is set as properties of nonlocal data (Fetchdata object)""" nonlocal requestdata @@ -594,19 +641,63 @@ class PhotoAsset: del requestdata return data + def _request_resource_data(self, resource): + """Request asset resource data (either photo or video component) + + Args: + resource: PHAssetResource to request + + Raises: + """ + + with objc.autorelease_pool(): + resource_manager = Photos.PHAssetResourceManager.defaultManager() + options = Photos.PHAssetResourceRequestOptions.alloc().init() + options.setNetworkAccessAllowed_(True) + + requestdata = PHAssetResourceData() + event = threading.Event() + + def handler(data): + """result handler for requestImageDataAndOrientationForAsset_options_resultHandler_ + all returned by the request is set as properties of nonlocal data (Fetchdata object)""" + + nonlocal requestdata + + requestdata.data += data + + def completion_handler(error): + if error: + raise PhotoKitExportError( + "Error requesting data for asset resource" + ) + event.set() + + resource_manager.requestDataForAssetResource_options_dataReceivedHandler_completionHandler_( + resource, options, handler, completion_handler + ) + + event.wait() + + # not sure why this is needed -- some weird ref count thing maybe + # if I don't do this, memory leaks + data = copy.copy(requestdata.data) + del requestdata + return data + def _make_result_handle_(self, data): - """ Make handler function and threading event to use with - requestImageDataAndOrientationForAsset_options_resultHandler_ - data: Fetchdata class to hold resulting metadata - returns: handler function, threading.Event() instance - Following call to requestImageDataAndOrientationForAsset_options_resultHandler_, - data will hold data from the fetch """ + """Make handler function and threading event to use with + requestImageDataAndOrientationForAsset_options_resultHandler_ + data: Fetchdata class to hold resulting metadata + returns: handler function, threading.Event() instance + Following call to requestImageDataAndOrientationForAsset_options_resultHandler_, + data will hold data from the fetch""" event = threading.Event() def handler(imageData, dataUTI, orientation, info): - """ result handler for requestImageDataAndOrientationForAsset_options_resultHandler_ - all returned by the request is set as properties of nonlocal data (Fetchdata object) """ + """result handler for requestImageDataAndOrientationForAsset_options_resultHandler_ + all returned by the request is set as properties of nonlocal data (Fetchdata object)""" nonlocal data @@ -627,14 +718,14 @@ class PhotoAsset: return handler, event def _resources(self): - """ Return list of PHAssetResource for object """ + """Return list of PHAssetResource for object""" resources = Photos.PHAssetResource.assetResourcesForAsset_(self.phasset) return [resources.objectAtIndex_(idx) for idx in range(resources.count())] class SlowMoVideoExporter(NSObject): def initWithAVAsset_path_(self, avasset, path): - """ init helper class for exporting slow-mo video + """init helper class for exporting slow-mo video Args: avasset: AVAsset @@ -649,15 +740,17 @@ class SlowMoVideoExporter(NSObject): return self def exportSlowMoVideo(self): - """ export slow-mo video with AVAssetExportSession - + """export slow-mo video with AVAssetExportSession + Returns: path to exported file """ with objc.autorelease_pool(): - exporter = AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_( - self.avasset, AVFoundation.AVAssetExportPresetHighestQuality + exporter = ( + AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_( + self.avasset, AVFoundation.AVAssetExportPresetHighestQuality + ) ) exporter.setOutputURL_(self.url) exporter.setOutputFileType_(AVFoundation.AVFileTypeQuickTimeMovie) @@ -666,7 +759,7 @@ class SlowMoVideoExporter(NSObject): self.done = False def handler(): - """ result handler for exportAsynchronouslyWithCompletionHandler """ + """result handler for exportAsynchronouslyWithCompletionHandler""" self.done = True exporter.exportAsynchronouslyWithCompletionHandler_(handler) @@ -700,7 +793,7 @@ class SlowMoVideoExporter(NSObject): class VideoAsset(PhotoAsset): - """ PhotoKit PHAsset representation of video asset """ + """PhotoKit PHAsset representation of video asset""" # TODO: doesn't work for slow-mo videos # see https://stackoverflow.com/questions/26152396/how-to-access-nsdata-nsurl-of-slow-motion-videos-using-photokit @@ -710,7 +803,7 @@ class VideoAsset(PhotoAsset): def export( self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False ): - """ Export video to path + """Export video to path Args: dest: str, path to destination directory @@ -766,7 +859,7 @@ class VideoAsset(PhotoAsset): def _export_slow_mo( self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False ): - """ Export slow-motion video to path + """Export slow-motion video to path Args: dest: str, path to destination directory @@ -815,14 +908,14 @@ class VideoAsset(PhotoAsset): # todo: rewrite this with NotificationCenter and App event loop? def _request_video_data(self, version=PHOTOS_VERSION_ORIGINAL): - """ Request video data for self._phasset - + """Request video data for self._phasset + Args: version: which version to request - PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version + PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version PHOTOS_VERSION_CURRENT, request current version with all edits PHOTOS_VERSION_UNADJUSTED, request highest quality unadjusted version - + Raises: ValueError if passed invalid value for version """ @@ -844,7 +937,7 @@ class VideoAsset(PhotoAsset): event = threading.Event() def handler(asset, audiomix, info): - """ result handler for requestAVAssetForVideo:asset options:options resultHandler """ + """result handler for requestAVAssetForVideo:asset options:options resultHandler""" nonlocal requestdata requestdata.asset = asset @@ -866,8 +959,8 @@ class VideoAsset(PhotoAsset): class LivePhotoRequest(NSObject): - """ Manage requests for live photo assets - See: https://developer.apple.com/documentation/photokit/phimagemanager/1616984-requestlivephotoforasset?language=objc + """Manage requests for live photo assets + See: https://developer.apple.com/documentation/photokit/phimagemanager/1616984-requestlivephotoforasset?language=objc """ def initWithManager_Asset_(self, manager, asset): @@ -880,7 +973,7 @@ class LivePhotoRequest(NSObject): return self def requestLivePhotoResources(self, version=PHOTOS_VERSION_CURRENT): - """ return the photos and video components of a live video as [PHAssetResource] """ + """return the photos and video components of a live video as [PHAssetResource]""" with objc.autorelease_pool(): options = Photos.PHLivePhotoRequestOptions.alloc().init() @@ -898,7 +991,7 @@ class LivePhotoRequest(NSObject): self.live_photo = None def handler(result, info): - """ result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler: """ + """result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler:""" if not info["PHImageResultIsDegradedKey"]: self.live_photo = result self.info = info @@ -940,7 +1033,7 @@ class LivePhotoRequest(NSObject): class LivePhotoAsset(PhotoAsset): - """ Represents a live photo """ + """Represents a live photo""" def export( self, @@ -951,7 +1044,7 @@ class LivePhotoAsset(PhotoAsset): photo=True, video=True, ): - """ Export image to path + """Export image to path Args: dest: str, path to destination directory @@ -1062,50 +1155,6 @@ class LivePhotoAsset(PhotoAsset): request.dealloc() return exported - def _request_resource_data(self, resource): - """ Request asset resource data (either photo or video component) - - Args: - resource: PHAssetResource to request - - Raises: - """ - - with objc.autorelease_pool(): - resource_manager = Photos.PHAssetResourceManager.defaultManager() - options = Photos.PHAssetResourceRequestOptions.alloc().init() - options.setNetworkAccessAllowed_(True) - - requestdata = PHAssetResourceData() - event = threading.Event() - - def handler(data): - """ result handler for requestImageDataAndOrientationForAsset_options_resultHandler_ - all returned by the request is set as properties of nonlocal data (Fetchdata object) """ - - nonlocal requestdata - - requestdata.data += data - - def completion_handler(error): - if error: - raise PhotoKitExportError( - "Error requesting data for asset resource" - ) - event.set() - - resource_manager.requestDataForAssetResource_options_dataReceivedHandler_completionHandler_( - resource, options, handler, completion_handler - ) - - event.wait() - - # not sure why this is needed -- some weird ref count thing maybe - # if I don't do this, memory leaks - data = copy.copy(requestdata.data) - del requestdata - return data - # def request_image_data(self, version=PHOTOS_VERSION_CURRENT): # # Returns an NSImage which isn't overly useful # # https://developer.apple.com/documentation/photokit/phimagemanager/1616964-requestimageforasset?language=objc @@ -1143,12 +1192,12 @@ class LivePhotoAsset(PhotoAsset): class PhotoLibrary: - """ Interface to PhotoKit PHImageManager and PHPhotoLibrary """ + """Interface to PhotoKit PHImageManager and PHPhotoLibrary""" def __init__(self): - """ Initialize ImageManager instance. Requests authorization to use the + """Initialize ImageManager instance. Requests authorization to use the Photos library if authorization has not already been granted. - + Raises: PhotoKitAuthError if unable to authorize access to PhotoKit """ @@ -1167,7 +1216,7 @@ class PhotoLibrary: self._phimagemanager = Photos.PHCachingImageManager.defaultManager() def request_authorization(self): - """ Request authorization to user's Photos Library + """Request authorization to user's Photos Library Returns: authorization status @@ -1177,7 +1226,7 @@ class PhotoLibrary: return self.auth_status def fetch_uuid_list(self, uuid_list): - """ fetch PHAssets with uuids in uuid_list + """fetch PHAssets with uuids in uuid_list Args: uuid_list: list of str (UUID of image assets to fetch) @@ -1206,7 +1255,7 @@ class PhotoLibrary: ) def fetch_uuid(self, uuid): - """ fetch PHAsset with uuid = uuid + """fetch PHAsset with uuid = uuid Args: uuid: str; UUID of image asset to fetch @@ -1224,8 +1273,8 @@ class PhotoLibrary: raise PhotoKitFetchFailed(f"Fetch did not return result for uuid {uuid}") def fetch_burst_uuid(self, burstid, all=False): - """ fetch PhotoAssets with burst ID = burstid - + """fetch PhotoAssets with burst ID = burstid + Args: burstid: str, burst UUID all: return all burst assets; if False returns only those selected by the user (including the "key photo" even if user hasn't manually selected it) @@ -1254,11 +1303,11 @@ class PhotoLibrary: ) def _asset_factory(self, phasset): - """ creates a PhotoAsset, VideoAsset, or LivePhotoAsset + """creates a PhotoAsset, VideoAsset, or LivePhotoAsset Args: - phasset: PHAsset object - + phasset: PHAsset object + Returns: PhotoAsset, VideoAsset, or LivePhotoAsset depending on type of PHAsset """ diff --git a/tests/test_photokit.py b/tests/test_photokit.py index 0b82a1cc..669c0767 100644 --- a/tests/test_photokit.py +++ b/tests/test_photokit.py @@ -57,6 +57,14 @@ UUID_DICT = { "burst_selected": 4, "burst_all": 5, }, + "raw+jpeg": { + "uuid": "E3DD04AF-CB65-4D9B-BB79-FF4C955533DB", + "filename": "IMG_1994.JPG", + "raw_filename": "IMG_1994.CR2", + "unadjusted_size": 16128420, + "uti_raw": "com.canon.cr2-raw-image", + "uti": "public.jpeg", + }, } @@ -78,10 +86,23 @@ def test_plain_photo(): lib = PhotoLibrary() photo = lib.fetch_uuid(uuid) assert photo.original_filename == filename + assert photo.raw_filename is None assert photo.isphoto assert not photo.ismovie +def test_raw_plus_jpeg(): + """test RAW+JPEG""" + uuid = UUID_DICT["raw+jpeg"]["uuid"] + + lib = PhotoLibrary() + photo = lib.fetch_uuid(uuid) + assert photo.original_filename == UUID_DICT["raw+jpeg"]["filename"] + assert photo.raw_filename == UUID_DICT["raw+jpeg"]["raw_filename"] + assert photo.uti_raw() == UUID_DICT["raw+jpeg"]["uti_raw"] + assert photo.uti() == UUID_DICT["raw+jpeg"]["uti"] + + def test_hdr(): """test hdr""" uuid = UUID_DICT["hdr"]["uuid"] @@ -196,6 +217,22 @@ def test_export_photo_current(): assert export_path.stat().st_size == test_dict["adjusted_size"] +def test_export_photo_raw(): + """test PhotoAsset.export for raw component""" + test_dict = UUID_DICT["raw+jpeg"] + uuid = test_dict["uuid"] + lib = PhotoLibrary() + photo = lib.fetch_uuid(uuid) + + with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir: + export_path = photo.export(tempdir, raw=True) + export_path = pathlib.Path(export_path[0]) + assert export_path.is_file() + filename = test_dict["raw_filename"] + assert export_path.stem == pathlib.Path(filename).stem + assert export_path.stat().st_size == test_dict["unadjusted_size"] + + ### VideoAsset