Skip to content

Instantly share code, notes, and snippets.

@RhetTbull
Last active February 23, 2024 22:53
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save RhetTbull/41cc85e5bdeb30f761147ce32fba5c94 to your computer and use it in GitHub Desktop.
Save RhetTbull/41cc85e5bdeb30f761147ce32fba5c94 to your computer and use it in GitHub Desktop.
Access images from Apple Photos and associated metadata. Uses PyObjC to call native PhotoKit framekwork to access the user's Photos library.
""" Use Apple PhotoKit via PyObjC bridge to download and save a photo
from users's Photos Library
Copyright Rhet Turnbull, 2020. Released under MIT License.
Required pyobjc >= 6.2 see: https://pypi.org/project/pyobjc/ """
import platform
import logging
import sys
import CoreServices
import Foundation
import LaunchServices
import objc
import Photos
import Quartz
# NOTE: This requires user have granted access to the terminal (e.g. Terminal.app or iTerm)
# to access Photos. This should happen automatically the first time it's called. I've
# not figured out how to get the call to requestAuthorization_ to actually work in the case
# where Terminal doesn't automatically ask (e.g. if you use tcctutil to reset terminal priveleges)
# In the case where permission to use Photos was removed or reset, it looks like you also need
# to remove permission to for Full Disk Access then re-run the script in order for Photos to
# re-ask for permission
# pylint: disable=no-member
PHOTOS_VERSION_ORIGINAL = Photos.PHImageRequestOptionsVersionOriginal
PHOTOS_VERSION_CURRENT = Photos.PHImageRequestOptionsVersionCurrent
def get_os_version():
# returns tuple of int containing OS version
# e.g. 10.13.6 = (10, 13, 6)
version = platform.mac_ver()[0].split(".")
if len(version) == 2:
(ver, major) = version
minor = "0"
elif len(version) == 3:
(ver, major, minor) = version
else:
raise (
ValueError(
f"Could not parse version string: {platform.mac_ver()} {version}"
)
)
return (int(ver), int(major), int(minor))
class PhotoKitError(Exception):
"""Base class for exceptions in this module."""
pass
class PhotoKitFetchFailed(PhotoKitError):
"""Exception raised for errors in the input.
Attributes:
expression -- input expression in which the error occurred
message -- explanation of the error
"""
pass
# def __init__(self, expression, message):
# self.expression = expression
# self.message = message
def get_preferred_extension(uti):
""" get preferred extension for a UTI type
uti: UTI str, e.g. 'public.jpeg'
returns: preferred extension as str """
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
ext = CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension
)
return ext
class ImageData:
""" Simple class to hold the data passed to the handler for
requestImageDataAndOrientationForAsset_options_resultHandler_ """
def __init__(self):
self.metadata = None
self.uti = None
self.image_data = None
self.info = None
self.orientation = None
class PhotoAsset:
""" PhotoKit PHAsset representation """
def __init__(self, uuid):
""" uuid: universally unique identifier for photo in the Photo library """
self.uuid = uuid
# pylint: disable=no-member
options = Photos.PHContentEditingInputRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
# check authorization status
auth_status = self._authorize()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
sys.exit(
f"Could not get authorizaton to use Photos: auth_status = {auth_status}"
)
# get image manager and request options
self._manager = Photos.PHCachingImageManager.defaultManager()
try:
self._phasset = self._fetch(uuid)
except PhotoKitFetchFailed as e:
logging.warning(f"Failed to fetch PHAsset for UUID={uuid}")
raise e
def _authorize(self):
(ver, major, minor) = get_os_version()
auth_status = 0
if major < 16:
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
# it seems the first try fails after Terminal prompts user for access so try again
for _ in range(2):
Photos.PHPhotoLibrary.requestAuthorization_(self._auth_status)
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status == Photos.PHAuthorizationStatusAuthorized:
break
else:
# requestAuthorization deprecated in 10.16/11.0
# but requestAuthorizationForAccessLevel not yet implemented in pyobjc
# https://developer.apple.com/documentation/photokit/phphotolibrary/3616053-requestauthorizationforaccesslev?language=objc
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
# it seems the first try fails after Terminal prompts user for access so try again
for _ in range(2):
Photos.PHPhotoLibrary.requestAuthorization_(self._auth_status)
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status == Photos.PHAuthorizationStatusAuthorized:
break
return auth_status
def _fetch(self, uuid):
""" fetch a PHAsset with uuid = uuid """
# pylint: disable=no-member
fetch_options = Photos.PHFetchOptions.alloc().init()
fetch_result = Photos.PHAsset.fetchAssetsWithLocalIdentifiers_options_(
[self.uuid], fetch_options
)
if fetch_result and fetch_result.count() == 1:
phasset = fetch_result.objectAtIndex_(0)
return phasset
else:
raise PhotoKitFetchFailed(f"Fetch did not return result for uuid {uuid}")
def request_image_data(self, version=PHOTOS_VERSION_ORIGINAL):
""" request image data and metadata for self._phasset
version: which version to request
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
PHOTOS_VERSION_CURRENT: request current version with all edits """
# reference: https://developer.apple.com/documentation/photokit/phimagemanager/3237282-requestimagedataandorientationfo?language=objc
if version not in [PHOTOS_VERSION_CURRENT, PHOTOS_VERSION_ORIGINAL]:
raise ValueError("Invalid value for version")
# pylint: disable=no-member
options_request = Photos.PHImageRequestOptions.alloc().init()
options_request.setNetworkAccessAllowed_(True)
options_request.setSynchronous_(True)
options_request.setVersion_(version)
requestdata = ImageData()
handler = self._make_result_handle(requestdata)
self._manager.requestImageDataAndOrientationForAsset_options_resultHandler_(
self._phasset, options_request, handler
)
self._imagedata = requestdata
return requestdata
def has_adjustments(self):
""" 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
adjustment_resources = Photos.PHAssetResource.assetResourcesForAsset_(
self._phasset
)
for idx in range(adjustment_resources.count()):
if (
adjustment_resources.objectAtIndex_(idx).type()
== Photos.PHAssetResourceTypeAdjustmentData
):
return True
return False
def _auth_status(self, status):
""" Handler for requestAuthorization_ """
# This doesn't actually get called but requestAuthorization needs a callable handler
# The Terminal will handle the actual authorization when called
pass
def _make_result_handle(self, data):
""" Returns handler function to use with
requestImageDataAndOrientationForAsset_options_resultHandler_
data: Fetchdata class to hold resulting metadata
returns: handler function
Following call to requestImageDataAndOrientationForAsset_options_resultHandler_,
data will hold data from the fetch """
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) """
nonlocal data
options = {}
# pylint: disable=no-member
options[Quartz.kCGImageSourceShouldCache] = Foundation.kCFBooleanFalse
imgSrc = Quartz.CGImageSourceCreateWithData(imageData, options)
data.metadata = Quartz.CGImageSourceCopyPropertiesAtIndex(
imgSrc, 0, options
)
data.uti = dataUTI
data.orientation = orientation
data.info = info
data.image_data = imageData
return None
return handler
def main():
try:
uuid = sys.argv[1]
except:
sys.exit("Must provide uuid as first argument")
phasset = PhotoAsset(uuid)
imagedata = phasset.request_image_data(version=PHOTOS_VERSION_ORIGINAL)
print(f"adjustments: {phasset.has_adjustments()}")
photo = phasset._phasset
print(f"mediaType: {photo.mediaType()}")
print(f"mediaSubtypes: {photo.mediaSubtypes()}")
print(f"sourceType: {photo.sourceType()}")
print(f"pixelWidth: {photo.pixelWidth()}")
print(f"pixelHeight: {photo.pixelHeight()}")
print(f"creationDate: {photo.creationDate()}")
print(f"modificationDate: {photo.modificationDate()}")
print(f"location: {photo.location()}")
print(f"favorite: {photo.isFavorite()}")
print(f"hidden: {photo.isHidden()}")
# pylint: disable=unsubscriptable-object
print(f"metadata = {imagedata.metadata}")
print(f"uti = {imagedata.uti}")
print(f"url = {imagedata.info['PHImageFileURLKey']}")
print(f"degraded = {imagedata.info['PHImageResultIsDegradedKey']}")
print(f"orientation = {imagedata.orientation}")
# write the file
# TODO: add a export() method
ext = get_preferred_extension(imagedata.uti)
outfile = f"testimage.{ext}"
print(f"Writing image to {outfile}")
fd = open(outfile, "wb")
fd.write(imagedata.image_data)
fd.close()
# get the edited version
if phasset.has_adjustments():
imagedata = phasset.request_image_data(version=PHOTOS_VERSION_CURRENT)
ext = get_preferred_extension(imagedata.uti)
outfile = f"testimage_current.{ext}"
print(f"Writing image to {outfile}")
fd = open(outfile, "wb")
fd.write(imagedata.image_data)
fd.close()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment