Skip to content

Reference

PhotoLibrary

Interface to PhotoKit PHImageManager and PHPhotoLibrary

Source code in photokit/photolibrary.py
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
class PhotoLibrary:
    """Interface to PhotoKit PHImageManager and PHPhotoLibrary"""

    def __init__(self, library_path: str | None = None):
        """Initialize ImageManager instance.  Requests authorization to use the
        Photos library if authorization has not already been granted.

        Args:
            library_path: str, path to Photos library to use; if None, uses default shared library

        Raises:
            PhotoKitAuthError if unable to authorize access to PhotoKit
            PhotoKitError if attempt to call single-library mode API after multi-library mode API

        Note:
            Access to the default shared library is provided via documented PhotoKit APIs.
            Access to other libraries via library_path is provided via undocumented private PhotoKit APIs.
            Thus this may break at any time.
        """

        # check authorization status
        auth_status = PhotoLibrary.authorization_status()
        if True not in auth_status:
            raise PhotoKitAuthError(f"Unable to access Photos library: {auth_status}")

        # if library_path is None, use default shared library
        global _global_single_library_mode
        if not library_path:
            if not _global_single_library_mode:
                # cannot use single-library mode APIs again after using multi-library mode APIs
                raise PhotoKitError(
                    "Cannot use single-library mode APIs after using multi-library mode APIs"
                )
            _global_single_library_mode = True
            self._phimagemanager = Photos.PHCachingImageManager.defaultManager()
            self._phphotolibrary = Photos.PHPhotoLibrary.sharedPhotoLibrary()
            self._photosdb = PhotosDB(PhotoLibrary.system_photo_library_path())
        else:
            # undocumented private API to get PHPhotoLibrary for a specific library
            Photos.PHPhotoLibrary.enableMultiLibraryMode()
            _global_single_library_mode = False
            self._phphotolibrary = (
                Photos.PHPhotoLibrary.alloc().initWithPhotoLibraryURL_type_(
                    NSURL.fileURLWithPath_(library_path), 0
                )
            )
            self._phimagemanager = Photos.PHImageManager.alloc().init()
            self._photosdb = PhotosDB(library_path)

    @staticmethod
    def enable_multi_library_mode():
        """Enable multi-library mode.  This is a no-op if already enabled.

        Note:
            Some PhotoKit APIs only work in multi-library mode.
            Once enabled, it cannot be disabled and only single-library mode APIs will work.
            In practice, you should not need to use this and PhotoLibrary will manage this automatically.
        """
        Photos.PHPhotoLibrary.enableMultiLibraryMode()
        global _global_single_library_mode
        _global_single_library_mode = False

    @staticmethod
    def multi_library_mode() -> bool:
        """Return True if multi-library mode is enabled, False otherwise"""
        return not _global_single_library_mode

    @staticmethod
    def system_photo_library_path() -> str:
        """Return path to system photo library"""
        return NSURL_to_path(Photos.PHPhotoLibrary.systemPhotoLibraryURL())

    @staticmethod
    def authorization_status() -> tuple[bool, bool]:
        """Get authorization status to use user's Photos Library

        Returns: tuple of bool for (read_write, add_only) authorization status
        """

        (ver, major, _) = get_macos_version()
        if (int(ver), int(major)) < (10, 16):
            auth_status = Photos.PHPhotoLibrary.authorizationStatus()
            if auth_status == Photos.PHAuthorizationStatusAuthorized:
                return (True, True)
            return (False, False)

        # requestAuthorization deprecated in 10.16/11.0
        # use requestAuthorizationForAccessLevel instead
        # ref: https://developer.apple.com/documentation/photokit/phphotolibrary/3616052-authorizationstatusforaccessleve?language=objc
        read_write = Photos.PHPhotoLibrary.authorizationStatusForAccessLevel_(
            PHAccessLevelReadWrite
        )
        add_only = Photos.PHPhotoLibrary.authorizationStatusForAccessLevel_(
            PHAccessLevelAddOnly
        )
        return (
            read_write == Photos.PHAuthorizationStatusAuthorized,
            add_only == Photos.PHAuthorizationStatusAuthorized,
        )

    @staticmethod
    def request_authorization(
        access_level: int = PHAccessLevelReadWrite,
    ):
        """Request authorization to user's Photos Library

        Args:
            access_level: (int) PHAccessLevelAddOnly or PHAccessLevelReadWrite

        Returns: True if authorization granted, False otherwise

        Note: In actual practice, the terminal process running the python code
            will do the actual request. This method exists for use in bundled apps
            created with py2app, etc.  It has not yet been well tested.
        """

        def handler(status):
            pass

        read_write, add_only = PhotoLibrary.authorization_status()
        if (
            access_level == PHAccessLevelReadWrite
            and read_write
            or access_level == PHAccessLevelAddOnly
            and add_only
        ):
            # already have access
            return True

        (ver, major, _) = get_macos_version()
        if (int(ver), int(major)) < (10, 16):
            # it seems the first try fails after Terminal prompts user for access so try again
            for _ in range(2):
                Photos.PHPhotoLibrary.requestAuthorization_(handler)
                auth_status = Photos.PHPhotoLibrary.authorizationStatus()
                if auth_status == Photos.PHAuthorizationStatusAuthorized:
                    break
            return bool(auth_status)

        # requestAuthorization deprecated in 10.16/11.0
        # use requestAuthorizationForAccessLevel instead
        for _ in range(2):
            auth_status = (
                Photos.PHPhotoLibrary.requestAuthorizationForAccessLevel_handler_(
                    access_level, handler
                )
            )
            read_write, add_only = PhotoLibrary.authorization_status()
            if (
                access_level == PHAccessLevelReadWrite
                and read_write
                or access_level == PHAccessLevelAddOnly
                and add_only
            ):
                return True
        return bool(auth_status)

    @staticmethod
    def create_library(library_path: str | pathlib.Path | os.PathLike) -> PhotoLibrary:
        """Create a new Photos library at library_path

        Args:
            library_path: str or pathlib.Path, path to new library

        Returns: PhotoLibrary object for new library

        Raises:
            FileExistsError if library already exists at library_path
            PhotoKitCreateLibraryError if unable to create library

        Note:
            This only works in multi-library mode; multi-library mode will be enabled if not already enabled.
            This may file (after a long timeout) if a library with same name was recently created
            (even if it has since been deleted).
        """
        library_path = (
            str(library_path) if not isinstance(library_path, str) else library_path
        )
        if pathlib.Path(library_path).is_dir():
            raise FileExistsError(f"Library already exists at {library_path}")

        # This only works in multi-library mode
        PhotoLibrary.enable_multi_library_mode()

        # Sometimes this can generate error messages to stderr regarding CoreData XPC errors
        # I have not yet figured out what causes this
        # Suppress the errors with pipes() and raise error when it times out
        # Error appears to occur if a library with same name was recently created (even if it has since been deleted)
        with pipes() as (out, err):
            photo_library = Photos.PHPhotoLibrary.alloc().initWithPhotoLibraryURL_type_(
                NSURL.fileURLWithPath_(library_path), 0
            )
            if photo_library.createPhotoLibraryUsingOptions_error_(None, None):
                return PhotoLibrary(library_path)
            else:
                raise PhotoKitCreateLibraryError(
                    f"Unable to create library at {library_path}"
                )

    def library_path(self) -> str:
        """Return path to Photos library"""
        return NSURL_to_path(self._phphotolibrary.photoLibraryURL())

    def assets(self, uuids: list[str] | None = None) -> list[Asset]:
        """Return list of all assets in the library or subset filtered by UUID.

        Args:
            uuids: (list[str]) UUID of image assets to fetch; if None, fetch all assets

        Returns: list of Asset objects

        Note: Does not currently return assets that are hidden or in trash nor non-selected burst assets
        """
        if uuids:
            return self._assets_from_uuid_list(uuids)

        if PhotoLibrary.multi_library_mode():
            asset_uuids = self._photosdb.get_asset_uuids()
            return self._assets_from_uuid_list(asset_uuids)

        with objc.autorelease_pool():
            options = Photos.PHFetchOptions.alloc().init()
            # options.setIncludeHiddenAssets_(True)
            # TODO: to access hidden photos, Photos > Settings > General > Privacy > Use Touch ID or Password
            # must be turned off
            # print(options.includeHiddenAssets())
            assets = Photos.PHAsset.fetchAssetsWithOptions_(options)
            asset_list = [assets.objectAtIndex_(idx) for idx in range(assets.count())]
            return [self._asset_factory(asset) for asset in asset_list]

    def albums(self, top_level: bool = False) -> list[Album]:
        """Return list of albums in the library

        Args:
            top_level: if True, return only top level albums

        Returns: list of Album objects
        """
        if PhotoLibrary.multi_library_mode():
            album_uuids = self._photosdb.get_album_uuids(top_level=top_level)
            return self._albums_from_uuid_list(album_uuids)

        with objc.autorelease_pool():
            # these are single library mode only
            # this gets all user albums
            if top_level:
                # this gets top level albums but also folders (PHCollectionList)
                albums = (
                    Photos.PHCollectionList.fetchTopLevelUserCollectionsWithOptions_(
                        None
                    )
                )
            else:
                albums = Photos.PHAssetCollection.fetchAssetCollectionsWithType_subtype_options_(
                    Photos.PHAssetCollectionTypeAlbum,
                    Photos.PHAssetCollectionSubtypeAny,
                    None,
                )

            album_list = []
            for i in range(albums.count()):
                album = albums.objectAtIndex_(i)
                # filter out PHCollectionList (folders), PHCloudSharedAlbum (shared albums)
                if not isinstance(
                    album, (Photos.PHCollectionList, Photos.PHCloudSharedAlbum)
                ):
                    album_list.append(album)
            return [Album(self, album) for album in album_list]

    def album(self, uuid: str | None = None, title: str | None = None) -> Album:
        """Get Album by UUID or name

        Args:
            uuid: str | None; UUID of album to fetch
            title: str | None; title/name of album to fetch

        Returns: Album object

        Raises:
            PhotoKitFetchFailed if fetch failed (album not found)
            ValueError if both uuid and title are None or both are not None

        Note: You must pass only one of uuid or title, not both. If more than one album has the same title,
        the behavior is undefined; one of the albums will be returned but no guarantee is made as to which one.
        """

        if not (uuid or title) or (uuid and title):
            raise ValueError(
                f"Must pass either uuid or title but not both: {uuid=}, {title=}"
            )

        if uuid:
            try:
                result = self._albums_from_uuid_list([uuid])
                return result[0]
            except Exception as e:
                raise PhotoKitFetchFailed(
                    f"Fetch did not return result for uuid {uuid}: {e}"
                )

        if title:
            albums = self.albums()
            for album in albums:
                if album.title == title:
                    return album
            raise PhotoKitFetchFailed(f"Fetch did not return result for title {title}")

    def create_album(self, title: str) -> Album:
        """Create a new album in the library

        Args:
            title: str, title of new album

        Returns: Album object for new album

        Raises:
            PhotoKitAlbumCreateError if unable to create album
        """

        with objc.autorelease_pool():
            event = threading.Event()

            # Create a new album
            def completion_handler(success, error):
                if error:
                    raise PhotoKitAlbumCreateError(
                        f"Error creating album {title}: {error}"
                    )
                event.set()

            album_uuid = None

            def create_album_handler(title):
                nonlocal album_uuid

                creation_request = Photos.PHAssetCollectionChangeRequest.creationRequestForAssetCollectionWithTitle_(
                    title
                )

                album_uuid = (
                    creation_request.placeholderForCreatedAssetCollection().localIdentifier()
                )

            self._phphotolibrary.performChanges_completionHandler_(
                lambda: create_album_handler(title), completion_handler
            )

            event.wait()

            return self.album(album_uuid)

    def delete_album(self, album: Album):
        """Delete album in the library

        Args:
            album: Album object to delete

        Raises:
            PhotoKitAlbumDeleteError if unable to create album
        """

        with objc.autorelease_pool():
            event = threading.Event()

            def completion_handler(success, error):
                if error:
                    raise PhotoKitAlbumDeleteError(
                        f"Error deleting album {album}: {error}"
                    )
                event.set()

            def delete_album_handler(album):
                deletion_request = (
                    Photos.PHAssetCollectionChangeRequest.deleteAssetCollections_(
                        [album.collection]
                    )
                )

            self._phphotolibrary.performChanges_completionHandler_(
                lambda: delete_album_handler(album), completion_handler
            )

            event.wait()

    def folders(self):
        """ "Return list of folders in the library"""
        with objc.autorelease_pool():
            # these are single library mode only
            # this gets all user albums
            # albums = (
            #     Photos.PHAssetCollection.fetchAssetCollectionsWithType_subtype_options_(
            #         Photos.PHAssetCollectionTypeAlbum,
            #         Photos.PHAssetCollectionSubtypeAny,
            #         None,
            #     )
            # )
            #
            # this gets top level albums but also folders (PHCollectionList)
            folders = (
                Photos.PHCollectionList.fetchCollectionListsWithType_subtype_options_(
                    Photos.PHCollectionListTypeFolder,
                    Photos.PHCollectionListSubtypeAny,
                    None,
                )
            )
            for i in range(folders.count()):
                folder = folders.objectAtIndex_(i)
                print(folder)

    def asset(self, uuid: str) -> Asset:
        """Return Asset with uuid = uuid

        Args:
            uuid: str; UUID of image asset to fetch

        Returns:
            PhotoAsset object

        Raises:
            PhotoKitFetchFailed if fetch failed

        Note:
            uuid may be a UUID or the full local identifier of the requested asset
        """
        try:
            result = self._assets_from_uuid_list([uuid])
            return result[0]
        except Exception as e:
            raise PhotoKitFetchFailed(
                f"Fetch did not return result for uuid {uuid}: {e}"
            )

    def fetch_burst_uuid(self, burstid, all=False):
        """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)

        Returns:
            list of PhotoAsset objects

        Raises:
            PhotoKitFetchFailed if fetch failed
        """

        fetch_options = Photos.PHFetchOptions.alloc().init()
        fetch_options.setIncludeAllBurstAssets_(all)
        fetch_results = Photos.PHAsset.fetchAssetsWithBurstIdentifier_options_(
            burstid, fetch_options
        )
        if fetch_results and fetch_results.count() >= 1:
            return [
                self._asset_factory(fetch_results.objectAtIndex_(idx))
                for idx in range(fetch_results.count())
            ]
        else:
            raise PhotoKitFetchFailed(
                f"Fetch did not return result for burstid {burstid}"
            )

    def delete_assets(self, photoassets: list[PhotoAsset]):
        """Delete assets.

        Args:
            photoassets: list of PhotoAsset objects to delete
        Note that this will prompt the user to confirm deletion of assets.
        """
        with objc.autorelease_pool():
            assets = [asset.phasset for asset in photoassets]
            self._phphotolibrary.performChangesAndWait_error_(
                lambda: Photos.PHAssetChangeRequest.deleteAssets_(assets), None
            )

    # // Create an asset representation of the image file
    # [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
    #     PHAssetCreationRequest *creationRequest = [PHAssetCreationRequest creationRequestForAsset];
    #     [creationRequest addResourceWithType:PHAssetResourceTypePhoto fileURL:imageURL options:nil];

    #     // Add the asset to the user's Photos library
    #     PHAssetCollectionChangeRequest *albumChangeRequest = [PHAssetCollectionChangeRequest changeRequestForAssetCollection:userLibrary];
    #     [albumChangeRequest addAssets:@[creationRequest.placeholderForCreatedAsset]];

    # } completionHandler:^(BOOL success, NSError *error) {
    #     if (!success) {
    #         NSLog(@"Failed to import image into Photos library: %@", error);
    #     } else {
    #         NSLog(@"Image imported successfully.");
    #     }
    # }];

    def add_photo(self, photo_path: str | pathlib.Path | os.PathLike) -> PhotoAsset:
        """Add a photo to the Photos library

        Args:
            photo_path: path to image file to add

        Returns:
            PhotoAsset object for added photo

        Raises:
            FileNotFoundError if photo_path does not exist
            PhotoKitImportError if unable to import image
        """
        if not pathlib.Path(photo_path).is_file():
            raise FileNotFoundError(f"Could not find image file {photo_path}")

        return self._add_asset(photo_path, Photos.PHAssetResourceTypePhoto)

    def add_video(self, video_path: str | pathlib.Path | os.PathLike) -> VideoAsset:
        """Add a video to the Photos library

        Args:
            video_path: path to video file to add

        Returns:
            VideoAsset object for added photo

        Raises:
            FileNotFoundError if video_path does not exist
            PhotoKitImportError if unable to import image
        """
        if not pathlib.Path(video_path).is_file():
            raise FileNotFoundError(f"Could not find video file {video_path}")

        return self._add_asset(video_path, Photos.PHAssetResourceTypeVideo)

    def add_live_photo(
        self,
        photo_path: str | pathlib.Path | os.PathLike,
        video_path: str | pathlib.Path | os.PathLike,
    ) -> LivePhotoAsset:
        """Add a live photo/video pair to the Photos library

        Args:
            photo_path: path to image file to add
            video_path: path to paired video file to add

        Returns:
            LivePhotoAsset object for added live photo/video pair

        Raises:
            FileNotFoundError if phto_path or video_path does not exist
            PhotoKitImportError if unable to import image
        """
        if not pathlib.Path(photo_path).is_file():
            raise FileNotFoundError(f"Could not find photo file {photo_path}")
        if not pathlib.Path(video_path).is_file():
            raise FileNotFoundError(f"Could not find video file {video_path}")

        return self._add_asset(
            photo_path,
            Photos.PHAssetResourceTypePhoto,
            video_path,
            Photos.PHAssetResourceTypePairedVideo,
        )

    def add_raw_pair_photo(
        self,
        raw_path: str | pathlib.Path | os.PathLike,
        jpeg_path: str | pathlib.Path | os.PathLike,
    ) -> LivePhotoAsset:
        """Add a RAW+JPEG pair to the Photos library

        Args:
            raw_path: path to RAW image file to add
            jpeg_path: path to paired JPEG file to add

        Returns:
            PhotoAsset object for added photo pair

        Raises:
            FileNotFoundError if phto_path or video_path does not exist
            PhotoKitImportError if unable to import image

        Note:
            The JPEG image will be treated as the "Original" image in Photos and
            the paired RAW will be considered the alternate image.
            This is consistent with Photos default behavior.
        """
        if not pathlib.Path(raw_path).is_file():
            raise FileNotFoundError(f"Could not find photo file {raw_path}")
        if not pathlib.Path(jpeg_path).is_file():
            raise FileNotFoundError(f"Could not find photo file {jpeg_path}")

        return self._add_asset(
            jpeg_path,
            Photos.PHAssetResourceTypePhoto,
            raw_path,
            Photos.PHAssetResourceTypeAlternatePhoto,
        )

    def _add_asset(
        self,
        asset_path: str | pathlib.Path | os.PathLike,
        asset_type: int,
        asset_additional_resource_path: str | pathlib.Path | os.PathLike | None = None,
        asset_additional_resource_type: int | None = None,
    ) -> Asset:
        """Add an asset to the Photos library

        Args:
            asset_path: path to file to add
            asset_type: AssetType, type of asset to add
            asset_additional_resource_path: path to additional file to add (e.g. Live video or RAW file)
            asset_additional_resource_type: AssetType, type of additional asset to add

        Returns:
            Asset object for added photo

        Raises:
            PhotoKitImportError if unable to import image
        """
        asset_path = str(asset_path)
        if asset_additional_resource_path:
            if not asset_additional_resource_type:
                raise ValueError("Must pass asset_additional_resource_type")
            asset_additional_resource_path = str(asset_additional_resource_path)

        with objc.autorelease_pool():
            asset_url = NSURL.fileURLWithPath_(asset_path)
            asset_additional_resource_url = (
                NSURL.fileURLWithPath_(asset_additional_resource_path)
                if asset_additional_resource_path
                else None
            )

            event = threading.Event()

            # Create an asset representation of the image file
            def completion_handler(success, error):
                if error:
                    raise PhotoKitImportError(f"Error importing asset: {error}")
                event.set()

            asset_uuid = None

            def import_asset_changes_handler():
                nonlocal asset_uuid

                creation_request = (
                    Photos.PHAssetCreationRequest.creationRequestForAsset()
                )
                creation_request.addResourceWithType_fileURL_options_(
                    asset_type, asset_url, None
                )

                if asset_additional_resource_path:
                    creation_request.addResourceWithType_fileURL_options_(
                        asset_additional_resource_type,
                        asset_additional_resource_url,
                        None,
                    )

                asset_uuid = (
                    creation_request.placeholderForCreatedAsset().localIdentifier()
                )

            self._phphotolibrary.performChanges_completionHandler_(
                lambda: import_asset_changes_handler(), completion_handler
            )

            event.wait()

            return self.asset(asset_uuid)

    def create_keyword(self, keyword: str) -> Photos.PHKeyword:
        """Add a new keyword to the Photos library.

        Args:
            keyword: str, keyword to add

        Returns: PHKeyword object for new keyword

        Raises:
            PhotoKitCreateKeywordError if unable to create keyword

        Note: this does not add the keyword to any assets; it only creates the keyword in the library.
        Keywords must be created in the library before they can be added to assets.

        In general you should be able to use Asset().keywords setter to add a keyword to an asset without
        calling this method directly.
        """

        with objc.autorelease_pool():
            event = threading.Event()

            # Create a new keyword in the library
            def completion_handler(success, error):
                if error:
                    raise PhotoKitCreateKeywordError(f"Error creating keyword: {error}")
                event.set()

            keyword_uuid = None

            def create_keyword_change_handler(keyword):
                nonlocal keyword_uuid

                creation_request = (
                    Photos.PHKeywordChangeRequest.creationRequestForKeyword()
                )
                creation_request.setTitle_(keyword)

                keyword_uuid = (
                    creation_request.placeholderForCreatedKeyword().localIdentifier()
                )

            self._phphotolibrary.performChanges_completionHandler_(
                lambda: create_keyword_change_handler(keyword), completion_handler
            )

            event.wait()
            logger.debug(f"Created keyword {keyword} with uuid {keyword_uuid}")

            if keyword_object := self._keywords_from_title_list([keyword]):
                return keyword_object[0]
            else:
                raise PhotoKitCreateKeywordError(f"Error creating keyword {keyword}")

    def smart_album(
        self,
        album_name: str | None = None,
        album_type: PhotoLibrarySmartAlbumType | None = None,
        user: bool = False,
    ) -> Album:
        """Get smart album with given name

        Args:
            album_name: name of smart album to fetch
            album_type: PhotoLibrarySmartAlbumType of smart album to fetch
            user: if True, fetch user smart album instead of system smart album

        Returns: Album object for smart album

        Raises:
            PhotoKitFetchFailed if fetch failed (album not found)
            ValueError if both album_name and album_type are None or both are not None

        Note: This only works in single library mode. If more than one album has the same name,
        the first one found will be returned but no guarantee is made as to which one.
        """

        if PhotoLibrary.multi_library_mode():
            raise NotImplementedError(
                "Fetching smart albums not implemented in multi-library mode"
            )

        if (album_name and album_type) or (not album_name and not album_type):
            raise ValueError(
                f"Must pass one of album_name or album_type: {album_name=}, {album_type=}"
            )

        # single library mode
        subtype = album_type.value if album_type else 0
        with objc.autorelease_pool():
            options = Photos.PHFetchOptions.alloc().init()
            if user:
                options.setIncludeUserSmartAlbums_(True)
            albums = (
                Photos.PHAssetCollection.fetchAssetCollectionsWithType_subtype_options_(
                    Photos.PHAssetCollectionTypeSmartAlbum,
                    subtype,
                    options,
                )
            )

            # album type
            if album_type:
                if not albums.count():
                    raise PhotoKitFetchFailed(
                        f"Fetch did not return result for album_type {album_type}"
                    )
                return Album(self, albums.objectAtIndex_(0))

            # album name
            for i in range(albums.count()):
                album = albums.objectAtIndex_(i)
                if album.localizedTitle() == album_name and (
                    not user or album.isUserSmartAlbum()
                ):
                    return Album(self, album)
            raise PhotoKitFetchFailed(
                f"Fetch did not return result for album {album_name}"
            )

    def smart_albums(self, user: bool = False) -> list[Album]:
        """Get list of smart albums

        Args:
            user: if True, fetch user smart albums instead of system smart albums

        Returns: list of Album objects for smart albums

        Raises:
            PhotoKitFetchFailed if fetch failed (album not found)

        Note: This only works in single library mode
        """

        if PhotoLibrary.multi_library_mode():
            raise NotImplementedError(
                "Fetching smart albums not implemented in multi-library mode"
            )

        # single library mode
        subtype = 0
        with objc.autorelease_pool():
            options = Photos.PHFetchOptions.alloc().init()
            if user:
                options.setIncludeUserSmartAlbums_(True)
            albums = (
                Photos.PHAssetCollection.fetchAssetCollectionsWithType_subtype_options_(
                    Photos.PHAssetCollectionTypeSmartAlbum,
                    subtype,
                    options,
                )
            )

            album_list = []
            for i in range(albums.count()):
                album = albums.objectAtIndex_(i)
                if user and not album.isUserSmartAlbum():
                    continue
                elif not user and album.isUserSmartAlbum():
                    continue
                album_list.append(Album(self, album))

            if not album_list:
                raise PhotoKitFetchFailed(
                    f"Fetch did not return result for smart albums"
                )

            return album_list

    def _albums_from_uuid_list(self, uuids: list[str]) -> list[Album]:
        """Get albums from list of uuids

        Args:
            uuids: list of str (UUID of image assets to fetch)

        Returns: list of Album objects

        Raises:
            PhotoKitFetchFailed if fetch failed
        """

        uuids = [uuid.split("/")[0] for uuid in uuids]
        with objc.autorelease_pool():
            if PhotoLibrary.multi_library_mode():
                fetch_object = NSString.stringWithString_("Album")
                if fetch_result := self._phphotolibrary.fetchPHObjectsForUUIDs_entityName_(
                    uuids, fetch_object
                ):
                    return [
                        Album(self, fetch_result.objectAtIndex_(idx))
                        for idx in range(fetch_result.count())
                    ]
                else:
                    raise PhotoKitFetchFailed(
                        f"Fetch did not return result for uuid_list {uuids}"
                    )

            # single library mode
            albums = self.albums()
            return [album for album in albums if album.uuid in uuids]

    def _assets_from_uuid_list(self, uuids: list[str]) -> list[Asset]:
        """Get assets from list of uuids

        Args:
            uuids: list of str (UUID of image assets to fetch)

        Returns: list of Asset objects

        Raises:
            PhotoKitFetchFailed if fetch failed
        """

        # uuids may be full local identifiers (e.g. "1F2A3B4C-5D6E-7F8A-9B0C-D1E2F3A4B5C6/L0/001")
        # if so, strip off the "/L0/001" part
        uuids = [uuid.split("/")[0] for uuid in uuids]

        with objc.autorelease_pool():
            if PhotoLibrary.multi_library_mode():
                fetch_object = NSString.stringWithString_("Asset")
                if fetch_result := self._phphotolibrary.fetchPHObjectsForUUIDs_entityName_(
                    uuids, fetch_object
                ):
                    return [
                        self._asset_factory(fetch_result.objectAtIndex_(idx))
                        for idx in range(fetch_result.count())
                    ]
                else:
                    raise PhotoKitFetchFailed(
                        f"Fetch did not return result for uuid_list {uuids}"
                    )

            fetch_options = Photos.PHFetchOptions.alloc().init()
            fetch_result = Photos.PHAsset.fetchAssetsWithLocalIdentifiers_options_(
                uuids, fetch_options
            )
            if fetch_result and fetch_result.count() >= 1:
                return [
                    self._asset_factory(fetch_result.objectAtIndex_(idx))
                    for idx in range(fetch_result.count())
                ]
            else:
                raise PhotoKitFetchFailed(
                    f"Fetch did not return result for uuid_list {uuids}"
                )

    def _keywords_from_title_list(self, titles: list[str]) -> list[Photos.PHKeyword]:
        """Fetch keywords from the library with given titles

        Args:
            titles: list of str (titles of keywords to fetch)

        Returns: list of PHKeyword objects
        """
        if PhotoLibrary.multi_library_mode():
            uuids = self._photosdb.get_keyword_uuids_for_keywords(titles)
            fetch_object = NSString.stringWithString_("Keyword")
            if fetch_result := self._phphotolibrary.fetchPHObjectsForUUIDs_entityName_(
                uuids, fetch_object
            ):
                return [
                    fetch_result.objectAtIndex_(idx)
                    for idx in range(fetch_result.count())
                ]
            else:
                raise PhotoKitFetchFailed(
                    f"Fetch did not return result for titles {titles}"
                )

        # single library mode
        return _fetch_keywords(titles)

    def _default_album(self):
        """Fetch the default Photos album"""
        if PhotoLibrary.multi_library_mode():
            raise NotImplementedError(
                "Fetching default album not implemented in multi-library mode"
            )

        # single library mode
        smart_albums = (
            Photos.PHAssetCollection.fetchAssetCollectionsWithType_subtype_options_(
                Photos.PHAssetCollectionTypeSmartAlbum,
                Photos.PHAssetCollectionSubtypeSmartAlbumUserLibrary,
                None,
            )
        )
        default_album = smart_albums.firstObject()
        return default_album

    def _asset_factory(self, phasset: Photos.PHAsset) -> Asset:
        """creates a PhotoAsset, VideoAsset, or LivePhotoAsset

        Args:
            phasset: PHAsset object

        Returns:
            PhotoAsset, VideoAsset, or LivePhotoAsset depending on type of PHAsset
        """

        if not isinstance(phasset, Photos.PHAsset):
            raise TypeError("phasset must be type PHAsset")

        media_type = phasset.mediaType()
        media_subtypes = phasset.mediaSubtypes()

        if media_subtypes & Photos.PHAssetMediaSubtypePhotoLive:
            return LivePhotoAsset(self, phasset)
        elif media_type == Photos.PHAssetMediaTypeImage:
            return PhotoAsset(self, phasset)
        elif media_type == Photos.PHAssetMediaTypeVideo:
            return VideoAsset(self, phasset)
        else:
            raise PhotoKitMediaTypeError(f"Unknown media type: {media_type}")

    def __len__(self):
        """Return number of assets in library"""
        return len(self.assets())

__init__(library_path=None)

Initialize ImageManager instance. Requests authorization to use the Photos library if authorization has not already been granted.

Parameters:

Name Type Description Default
library_path str | None

str, path to Photos library to use; if None, uses default shared library

None
Note

Access to the default shared library is provided via documented PhotoKit APIs. Access to other libraries via library_path is provided via undocumented private PhotoKit APIs. Thus this may break at any time.

Source code in photokit/photolibrary.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def __init__(self, library_path: str | None = None):
    """Initialize ImageManager instance.  Requests authorization to use the
    Photos library if authorization has not already been granted.

    Args:
        library_path: str, path to Photos library to use; if None, uses default shared library

    Raises:
        PhotoKitAuthError if unable to authorize access to PhotoKit
        PhotoKitError if attempt to call single-library mode API after multi-library mode API

    Note:
        Access to the default shared library is provided via documented PhotoKit APIs.
        Access to other libraries via library_path is provided via undocumented private PhotoKit APIs.
        Thus this may break at any time.
    """

    # check authorization status
    auth_status = PhotoLibrary.authorization_status()
    if True not in auth_status:
        raise PhotoKitAuthError(f"Unable to access Photos library: {auth_status}")

    # if library_path is None, use default shared library
    global _global_single_library_mode
    if not library_path:
        if not _global_single_library_mode:
            # cannot use single-library mode APIs again after using multi-library mode APIs
            raise PhotoKitError(
                "Cannot use single-library mode APIs after using multi-library mode APIs"
            )
        _global_single_library_mode = True
        self._phimagemanager = Photos.PHCachingImageManager.defaultManager()
        self._phphotolibrary = Photos.PHPhotoLibrary.sharedPhotoLibrary()
        self._photosdb = PhotosDB(PhotoLibrary.system_photo_library_path())
    else:
        # undocumented private API to get PHPhotoLibrary for a specific library
        Photos.PHPhotoLibrary.enableMultiLibraryMode()
        _global_single_library_mode = False
        self._phphotolibrary = (
            Photos.PHPhotoLibrary.alloc().initWithPhotoLibraryURL_type_(
                NSURL.fileURLWithPath_(library_path), 0
            )
        )
        self._phimagemanager = Photos.PHImageManager.alloc().init()
        self._photosdb = PhotosDB(library_path)

__len__()

Return number of assets in library

Source code in photokit/photolibrary.py
1071
1072
1073
def __len__(self):
    """Return number of assets in library"""
    return len(self.assets())

add_live_photo(photo_path, video_path)

Add a live photo/video pair to the Photos library

Parameters:

Name Type Description Default
photo_path str | Path | PathLike

path to image file to add

required
video_path str | Path | PathLike

path to paired video file to add

required

Returns:

Type Description
LivePhotoAsset

LivePhotoAsset object for added live photo/video pair

Source code in photokit/photolibrary.py
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
def add_live_photo(
    self,
    photo_path: str | pathlib.Path | os.PathLike,
    video_path: str | pathlib.Path | os.PathLike,
) -> LivePhotoAsset:
    """Add a live photo/video pair to the Photos library

    Args:
        photo_path: path to image file to add
        video_path: path to paired video file to add

    Returns:
        LivePhotoAsset object for added live photo/video pair

    Raises:
        FileNotFoundError if phto_path or video_path does not exist
        PhotoKitImportError if unable to import image
    """
    if not pathlib.Path(photo_path).is_file():
        raise FileNotFoundError(f"Could not find photo file {photo_path}")
    if not pathlib.Path(video_path).is_file():
        raise FileNotFoundError(f"Could not find video file {video_path}")

    return self._add_asset(
        photo_path,
        Photos.PHAssetResourceTypePhoto,
        video_path,
        Photos.PHAssetResourceTypePairedVideo,
    )

add_photo(photo_path)

Add a photo to the Photos library

Parameters:

Name Type Description Default
photo_path str | Path | PathLike

path to image file to add

required

Returns:

Type Description
PhotoAsset

PhotoAsset object for added photo

Source code in photokit/photolibrary.py
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
def add_photo(self, photo_path: str | pathlib.Path | os.PathLike) -> PhotoAsset:
    """Add a photo to the Photos library

    Args:
        photo_path: path to image file to add

    Returns:
        PhotoAsset object for added photo

    Raises:
        FileNotFoundError if photo_path does not exist
        PhotoKitImportError if unable to import image
    """
    if not pathlib.Path(photo_path).is_file():
        raise FileNotFoundError(f"Could not find image file {photo_path}")

    return self._add_asset(photo_path, Photos.PHAssetResourceTypePhoto)

add_raw_pair_photo(raw_path, jpeg_path)

Add a RAW+JPEG pair to the Photos library

Parameters:

Name Type Description Default
raw_path str | Path | PathLike

path to RAW image file to add

required
jpeg_path str | Path | PathLike

path to paired JPEG file to add

required

Returns:

Type Description
LivePhotoAsset

PhotoAsset object for added photo pair

Note

The JPEG image will be treated as the "Original" image in Photos and the paired RAW will be considered the alternate image. This is consistent with Photos default behavior.

Source code in photokit/photolibrary.py
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
def add_raw_pair_photo(
    self,
    raw_path: str | pathlib.Path | os.PathLike,
    jpeg_path: str | pathlib.Path | os.PathLike,
) -> LivePhotoAsset:
    """Add a RAW+JPEG pair to the Photos library

    Args:
        raw_path: path to RAW image file to add
        jpeg_path: path to paired JPEG file to add

    Returns:
        PhotoAsset object for added photo pair

    Raises:
        FileNotFoundError if phto_path or video_path does not exist
        PhotoKitImportError if unable to import image

    Note:
        The JPEG image will be treated as the "Original" image in Photos and
        the paired RAW will be considered the alternate image.
        This is consistent with Photos default behavior.
    """
    if not pathlib.Path(raw_path).is_file():
        raise FileNotFoundError(f"Could not find photo file {raw_path}")
    if not pathlib.Path(jpeg_path).is_file():
        raise FileNotFoundError(f"Could not find photo file {jpeg_path}")

    return self._add_asset(
        jpeg_path,
        Photos.PHAssetResourceTypePhoto,
        raw_path,
        Photos.PHAssetResourceTypeAlternatePhoto,
    )

add_video(video_path)

Add a video to the Photos library

Parameters:

Name Type Description Default
video_path str | Path | PathLike

path to video file to add

required

Returns:

Type Description
VideoAsset

VideoAsset object for added photo

Source code in photokit/photolibrary.py
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
def add_video(self, video_path: str | pathlib.Path | os.PathLike) -> VideoAsset:
    """Add a video to the Photos library

    Args:
        video_path: path to video file to add

    Returns:
        VideoAsset object for added photo

    Raises:
        FileNotFoundError if video_path does not exist
        PhotoKitImportError if unable to import image
    """
    if not pathlib.Path(video_path).is_file():
        raise FileNotFoundError(f"Could not find video file {video_path}")

    return self._add_asset(video_path, Photos.PHAssetResourceTypeVideo)

album(uuid=None, title=None)

Get Album by UUID or name

Parameters:

Name Type Description Default
uuid str | None

str | None; UUID of album to fetch

None
title str | None

str | None; title/name of album to fetch

None

Returns: Album object

Note: You must pass only one of uuid or title, not both. If more than one album has the same title, the behavior is undefined; one of the albums will be returned but no guarantee is made as to which one.

Source code in photokit/photolibrary.py
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
def album(self, uuid: str | None = None, title: str | None = None) -> Album:
    """Get Album by UUID or name

    Args:
        uuid: str | None; UUID of album to fetch
        title: str | None; title/name of album to fetch

    Returns: Album object

    Raises:
        PhotoKitFetchFailed if fetch failed (album not found)
        ValueError if both uuid and title are None or both are not None

    Note: You must pass only one of uuid or title, not both. If more than one album has the same title,
    the behavior is undefined; one of the albums will be returned but no guarantee is made as to which one.
    """

    if not (uuid or title) or (uuid and title):
        raise ValueError(
            f"Must pass either uuid or title but not both: {uuid=}, {title=}"
        )

    if uuid:
        try:
            result = self._albums_from_uuid_list([uuid])
            return result[0]
        except Exception as e:
            raise PhotoKitFetchFailed(
                f"Fetch did not return result for uuid {uuid}: {e}"
            )

    if title:
        albums = self.albums()
        for album in albums:
            if album.title == title:
                return album
        raise PhotoKitFetchFailed(f"Fetch did not return result for title {title}")

albums(top_level=False)

Return list of albums in the library

Parameters:

Name Type Description Default
top_level bool

if True, return only top level albums

False

Returns: list of Album objects

Source code in photokit/photolibrary.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
def albums(self, top_level: bool = False) -> list[Album]:
    """Return list of albums in the library

    Args:
        top_level: if True, return only top level albums

    Returns: list of Album objects
    """
    if PhotoLibrary.multi_library_mode():
        album_uuids = self._photosdb.get_album_uuids(top_level=top_level)
        return self._albums_from_uuid_list(album_uuids)

    with objc.autorelease_pool():
        # these are single library mode only
        # this gets all user albums
        if top_level:
            # this gets top level albums but also folders (PHCollectionList)
            albums = (
                Photos.PHCollectionList.fetchTopLevelUserCollectionsWithOptions_(
                    None
                )
            )
        else:
            albums = Photos.PHAssetCollection.fetchAssetCollectionsWithType_subtype_options_(
                Photos.PHAssetCollectionTypeAlbum,
                Photos.PHAssetCollectionSubtypeAny,
                None,
            )

        album_list = []
        for i in range(albums.count()):
            album = albums.objectAtIndex_(i)
            # filter out PHCollectionList (folders), PHCloudSharedAlbum (shared albums)
            if not isinstance(
                album, (Photos.PHCollectionList, Photos.PHCloudSharedAlbum)
            ):
                album_list.append(album)
        return [Album(self, album) for album in album_list]

asset(uuid)

Return Asset with uuid = uuid

Parameters:

Name Type Description Default
uuid str

str; UUID of image asset to fetch

required

Returns:

Type Description
Asset

PhotoAsset object

Note

uuid may be a UUID or the full local identifier of the requested asset

Source code in photokit/photolibrary.py
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
def asset(self, uuid: str) -> Asset:
    """Return Asset with uuid = uuid

    Args:
        uuid: str; UUID of image asset to fetch

    Returns:
        PhotoAsset object

    Raises:
        PhotoKitFetchFailed if fetch failed

    Note:
        uuid may be a UUID or the full local identifier of the requested asset
    """
    try:
        result = self._assets_from_uuid_list([uuid])
        return result[0]
    except Exception as e:
        raise PhotoKitFetchFailed(
            f"Fetch did not return result for uuid {uuid}: {e}"
        )

assets(uuids=None)

Return list of all assets in the library or subset filtered by UUID.

Parameters:

Name Type Description Default
uuids list[str] | None

(list[str]) UUID of image assets to fetch; if None, fetch all assets

None

Returns: list of Asset objects

Note: Does not currently return assets that are hidden or in trash nor non-selected burst assets

Source code in photokit/photolibrary.py
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def assets(self, uuids: list[str] | None = None) -> list[Asset]:
    """Return list of all assets in the library or subset filtered by UUID.

    Args:
        uuids: (list[str]) UUID of image assets to fetch; if None, fetch all assets

    Returns: list of Asset objects

    Note: Does not currently return assets that are hidden or in trash nor non-selected burst assets
    """
    if uuids:
        return self._assets_from_uuid_list(uuids)

    if PhotoLibrary.multi_library_mode():
        asset_uuids = self._photosdb.get_asset_uuids()
        return self._assets_from_uuid_list(asset_uuids)

    with objc.autorelease_pool():
        options = Photos.PHFetchOptions.alloc().init()
        # options.setIncludeHiddenAssets_(True)
        # TODO: to access hidden photos, Photos > Settings > General > Privacy > Use Touch ID or Password
        # must be turned off
        # print(options.includeHiddenAssets())
        assets = Photos.PHAsset.fetchAssetsWithOptions_(options)
        asset_list = [assets.objectAtIndex_(idx) for idx in range(assets.count())]
        return [self._asset_factory(asset) for asset in asset_list]

authorization_status() staticmethod

Get authorization status to use user's Photos Library

Returns: tuple of bool for (read_write, add_only) authorization status

Source code in photokit/photolibrary.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
@staticmethod
def authorization_status() -> tuple[bool, bool]:
    """Get authorization status to use user's Photos Library

    Returns: tuple of bool for (read_write, add_only) authorization status
    """

    (ver, major, _) = get_macos_version()
    if (int(ver), int(major)) < (10, 16):
        auth_status = Photos.PHPhotoLibrary.authorizationStatus()
        if auth_status == Photos.PHAuthorizationStatusAuthorized:
            return (True, True)
        return (False, False)

    # requestAuthorization deprecated in 10.16/11.0
    # use requestAuthorizationForAccessLevel instead
    # ref: https://developer.apple.com/documentation/photokit/phphotolibrary/3616052-authorizationstatusforaccessleve?language=objc
    read_write = Photos.PHPhotoLibrary.authorizationStatusForAccessLevel_(
        PHAccessLevelReadWrite
    )
    add_only = Photos.PHPhotoLibrary.authorizationStatusForAccessLevel_(
        PHAccessLevelAddOnly
    )
    return (
        read_write == Photos.PHAuthorizationStatusAuthorized,
        add_only == Photos.PHAuthorizationStatusAuthorized,
    )

create_album(title)

Create a new album in the library

Parameters:

Name Type Description Default
title str

str, title of new album

required

Returns: Album object for new album

Source code in photokit/photolibrary.py
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
def create_album(self, title: str) -> Album:
    """Create a new album in the library

    Args:
        title: str, title of new album

    Returns: Album object for new album

    Raises:
        PhotoKitAlbumCreateError if unable to create album
    """

    with objc.autorelease_pool():
        event = threading.Event()

        # Create a new album
        def completion_handler(success, error):
            if error:
                raise PhotoKitAlbumCreateError(
                    f"Error creating album {title}: {error}"
                )
            event.set()

        album_uuid = None

        def create_album_handler(title):
            nonlocal album_uuid

            creation_request = Photos.PHAssetCollectionChangeRequest.creationRequestForAssetCollectionWithTitle_(
                title
            )

            album_uuid = (
                creation_request.placeholderForCreatedAssetCollection().localIdentifier()
            )

        self._phphotolibrary.performChanges_completionHandler_(
            lambda: create_album_handler(title), completion_handler
        )

        event.wait()

        return self.album(album_uuid)

create_keyword(keyword)

Add a new keyword to the Photos library.

Parameters:

Name Type Description Default
keyword str

str, keyword to add

required

Returns: PHKeyword object for new keyword

Note: this does not add the keyword to any assets; it only creates the keyword in the library. Keywords must be created in the library before they can be added to assets.

In general you should be able to use Asset().keywords setter to add a keyword to an asset without calling this method directly.

Source code in photokit/photolibrary.py
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
def create_keyword(self, keyword: str) -> Photos.PHKeyword:
    """Add a new keyword to the Photos library.

    Args:
        keyword: str, keyword to add

    Returns: PHKeyword object for new keyword

    Raises:
        PhotoKitCreateKeywordError if unable to create keyword

    Note: this does not add the keyword to any assets; it only creates the keyword in the library.
    Keywords must be created in the library before they can be added to assets.

    In general you should be able to use Asset().keywords setter to add a keyword to an asset without
    calling this method directly.
    """

    with objc.autorelease_pool():
        event = threading.Event()

        # Create a new keyword in the library
        def completion_handler(success, error):
            if error:
                raise PhotoKitCreateKeywordError(f"Error creating keyword: {error}")
            event.set()

        keyword_uuid = None

        def create_keyword_change_handler(keyword):
            nonlocal keyword_uuid

            creation_request = (
                Photos.PHKeywordChangeRequest.creationRequestForKeyword()
            )
            creation_request.setTitle_(keyword)

            keyword_uuid = (
                creation_request.placeholderForCreatedKeyword().localIdentifier()
            )

        self._phphotolibrary.performChanges_completionHandler_(
            lambda: create_keyword_change_handler(keyword), completion_handler
        )

        event.wait()
        logger.debug(f"Created keyword {keyword} with uuid {keyword_uuid}")

        if keyword_object := self._keywords_from_title_list([keyword]):
            return keyword_object[0]
        else:
            raise PhotoKitCreateKeywordError(f"Error creating keyword {keyword}")

create_library(library_path) staticmethod

Create a new Photos library at library_path

Parameters:

Name Type Description Default
library_path str | Path | PathLike

str or pathlib.Path, path to new library

required

Returns: PhotoLibrary object for new library

Note

This only works in multi-library mode; multi-library mode will be enabled if not already enabled. This may file (after a long timeout) if a library with same name was recently created (even if it has since been deleted).

Source code in photokit/photolibrary.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
@staticmethod
def create_library(library_path: str | pathlib.Path | os.PathLike) -> PhotoLibrary:
    """Create a new Photos library at library_path

    Args:
        library_path: str or pathlib.Path, path to new library

    Returns: PhotoLibrary object for new library

    Raises:
        FileExistsError if library already exists at library_path
        PhotoKitCreateLibraryError if unable to create library

    Note:
        This only works in multi-library mode; multi-library mode will be enabled if not already enabled.
        This may file (after a long timeout) if a library with same name was recently created
        (even if it has since been deleted).
    """
    library_path = (
        str(library_path) if not isinstance(library_path, str) else library_path
    )
    if pathlib.Path(library_path).is_dir():
        raise FileExistsError(f"Library already exists at {library_path}")

    # This only works in multi-library mode
    PhotoLibrary.enable_multi_library_mode()

    # Sometimes this can generate error messages to stderr regarding CoreData XPC errors
    # I have not yet figured out what causes this
    # Suppress the errors with pipes() and raise error when it times out
    # Error appears to occur if a library with same name was recently created (even if it has since been deleted)
    with pipes() as (out, err):
        photo_library = Photos.PHPhotoLibrary.alloc().initWithPhotoLibraryURL_type_(
            NSURL.fileURLWithPath_(library_path), 0
        )
        if photo_library.createPhotoLibraryUsingOptions_error_(None, None):
            return PhotoLibrary(library_path)
        else:
            raise PhotoKitCreateLibraryError(
                f"Unable to create library at {library_path}"
            )

delete_album(album)

Delete album in the library

Parameters:

Name Type Description Default
album Album

Album object to delete

required
Source code in photokit/photolibrary.py
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
def delete_album(self, album: Album):
    """Delete album in the library

    Args:
        album: Album object to delete

    Raises:
        PhotoKitAlbumDeleteError if unable to create album
    """

    with objc.autorelease_pool():
        event = threading.Event()

        def completion_handler(success, error):
            if error:
                raise PhotoKitAlbumDeleteError(
                    f"Error deleting album {album}: {error}"
                )
            event.set()

        def delete_album_handler(album):
            deletion_request = (
                Photos.PHAssetCollectionChangeRequest.deleteAssetCollections_(
                    [album.collection]
                )
            )

        self._phphotolibrary.performChanges_completionHandler_(
            lambda: delete_album_handler(album), completion_handler
        )

        event.wait()

delete_assets(photoassets)

Delete assets.

Parameters:

Name Type Description Default
photoassets list[PhotoAsset]

list of PhotoAsset objects to delete

required

Note that this will prompt the user to confirm deletion of assets.

Source code in photokit/photolibrary.py
552
553
554
555
556
557
558
559
560
561
562
563
def delete_assets(self, photoassets: list[PhotoAsset]):
    """Delete assets.

    Args:
        photoassets: list of PhotoAsset objects to delete
    Note that this will prompt the user to confirm deletion of assets.
    """
    with objc.autorelease_pool():
        assets = [asset.phasset for asset in photoassets]
        self._phphotolibrary.performChangesAndWait_error_(
            lambda: Photos.PHAssetChangeRequest.deleteAssets_(assets), None
        )

enable_multi_library_mode() staticmethod

Enable multi-library mode. This is a no-op if already enabled.

Note

Some PhotoKit APIs only work in multi-library mode. Once enabled, it cannot be disabled and only single-library mode APIs will work. In practice, you should not need to use this and PhotoLibrary will manage this automatically.

Source code in photokit/photolibrary.py
140
141
142
143
144
145
146
147
148
149
150
151
@staticmethod
def enable_multi_library_mode():
    """Enable multi-library mode.  This is a no-op if already enabled.

    Note:
        Some PhotoKit APIs only work in multi-library mode.
        Once enabled, it cannot be disabled and only single-library mode APIs will work.
        In practice, you should not need to use this and PhotoLibrary will manage this automatically.
    """
    Photos.PHPhotoLibrary.enableMultiLibraryMode()
    global _global_single_library_mode
    _global_single_library_mode = False

fetch_burst_uuid(burstid, all=False)

fetch PhotoAssets with burst ID = burstid

Parameters:

Name Type Description Default
burstid

str, burst UUID

required
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)

False

Returns:

Type Description

list of PhotoAsset objects

Source code in photokit/photolibrary.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
def fetch_burst_uuid(self, burstid, all=False):
    """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)

    Returns:
        list of PhotoAsset objects

    Raises:
        PhotoKitFetchFailed if fetch failed
    """

    fetch_options = Photos.PHFetchOptions.alloc().init()
    fetch_options.setIncludeAllBurstAssets_(all)
    fetch_results = Photos.PHAsset.fetchAssetsWithBurstIdentifier_options_(
        burstid, fetch_options
    )
    if fetch_results and fetch_results.count() >= 1:
        return [
            self._asset_factory(fetch_results.objectAtIndex_(idx))
            for idx in range(fetch_results.count())
        ]
    else:
        raise PhotoKitFetchFailed(
            f"Fetch did not return result for burstid {burstid}"
        )

folders()

"Return list of folders in the library

Source code in photokit/photolibrary.py
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
def folders(self):
    """ "Return list of folders in the library"""
    with objc.autorelease_pool():
        # these are single library mode only
        # this gets all user albums
        # albums = (
        #     Photos.PHAssetCollection.fetchAssetCollectionsWithType_subtype_options_(
        #         Photos.PHAssetCollectionTypeAlbum,
        #         Photos.PHAssetCollectionSubtypeAny,
        #         None,
        #     )
        # )
        #
        # this gets top level albums but also folders (PHCollectionList)
        folders = (
            Photos.PHCollectionList.fetchCollectionListsWithType_subtype_options_(
                Photos.PHCollectionListTypeFolder,
                Photos.PHCollectionListSubtypeAny,
                None,
            )
        )
        for i in range(folders.count()):
            folder = folders.objectAtIndex_(i)
            print(folder)

library_path()

Return path to Photos library

Source code in photokit/photolibrary.py
290
291
292
def library_path(self) -> str:
    """Return path to Photos library"""
    return NSURL_to_path(self._phphotolibrary.photoLibraryURL())

multi_library_mode() staticmethod

Return True if multi-library mode is enabled, False otherwise

Source code in photokit/photolibrary.py
153
154
155
156
@staticmethod
def multi_library_mode() -> bool:
    """Return True if multi-library mode is enabled, False otherwise"""
    return not _global_single_library_mode

request_authorization(access_level=PHAccessLevelReadWrite) staticmethod

Request authorization to user's Photos Library

Parameters:

Name Type Description Default
access_level int

(int) PHAccessLevelAddOnly or PHAccessLevelReadWrite

PHAccessLevelReadWrite

Returns: True if authorization granted, False otherwise

In actual practice, the terminal process running the python code

will do the actual request. This method exists for use in bundled apps created with py2app, etc. It has not yet been well tested.

Source code in photokit/photolibrary.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
@staticmethod
def request_authorization(
    access_level: int = PHAccessLevelReadWrite,
):
    """Request authorization to user's Photos Library

    Args:
        access_level: (int) PHAccessLevelAddOnly or PHAccessLevelReadWrite

    Returns: True if authorization granted, False otherwise

    Note: In actual practice, the terminal process running the python code
        will do the actual request. This method exists for use in bundled apps
        created with py2app, etc.  It has not yet been well tested.
    """

    def handler(status):
        pass

    read_write, add_only = PhotoLibrary.authorization_status()
    if (
        access_level == PHAccessLevelReadWrite
        and read_write
        or access_level == PHAccessLevelAddOnly
        and add_only
    ):
        # already have access
        return True

    (ver, major, _) = get_macos_version()
    if (int(ver), int(major)) < (10, 16):
        # it seems the first try fails after Terminal prompts user for access so try again
        for _ in range(2):
            Photos.PHPhotoLibrary.requestAuthorization_(handler)
            auth_status = Photos.PHPhotoLibrary.authorizationStatus()
            if auth_status == Photos.PHAuthorizationStatusAuthorized:
                break
        return bool(auth_status)

    # requestAuthorization deprecated in 10.16/11.0
    # use requestAuthorizationForAccessLevel instead
    for _ in range(2):
        auth_status = (
            Photos.PHPhotoLibrary.requestAuthorizationForAccessLevel_handler_(
                access_level, handler
            )
        )
        read_write, add_only = PhotoLibrary.authorization_status()
        if (
            access_level == PHAccessLevelReadWrite
            and read_write
            or access_level == PHAccessLevelAddOnly
            and add_only
        ):
            return True
    return bool(auth_status)

smart_album(album_name=None, album_type=None, user=False)

Get smart album with given name

Parameters:

Name Type Description Default
album_name str | None

name of smart album to fetch

None
album_type PhotoLibrarySmartAlbumType | None

PhotoLibrarySmartAlbumType of smart album to fetch

None
user bool

if True, fetch user smart album instead of system smart album

False

Returns: Album object for smart album

Note: This only works in single library mode. If more than one album has the same name, the first one found will be returned but no guarantee is made as to which one.

Source code in photokit/photolibrary.py
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
def smart_album(
    self,
    album_name: str | None = None,
    album_type: PhotoLibrarySmartAlbumType | None = None,
    user: bool = False,
) -> Album:
    """Get smart album with given name

    Args:
        album_name: name of smart album to fetch
        album_type: PhotoLibrarySmartAlbumType of smart album to fetch
        user: if True, fetch user smart album instead of system smart album

    Returns: Album object for smart album

    Raises:
        PhotoKitFetchFailed if fetch failed (album not found)
        ValueError if both album_name and album_type are None or both are not None

    Note: This only works in single library mode. If more than one album has the same name,
    the first one found will be returned but no guarantee is made as to which one.
    """

    if PhotoLibrary.multi_library_mode():
        raise NotImplementedError(
            "Fetching smart albums not implemented in multi-library mode"
        )

    if (album_name and album_type) or (not album_name and not album_type):
        raise ValueError(
            f"Must pass one of album_name or album_type: {album_name=}, {album_type=}"
        )

    # single library mode
    subtype = album_type.value if album_type else 0
    with objc.autorelease_pool():
        options = Photos.PHFetchOptions.alloc().init()
        if user:
            options.setIncludeUserSmartAlbums_(True)
        albums = (
            Photos.PHAssetCollection.fetchAssetCollectionsWithType_subtype_options_(
                Photos.PHAssetCollectionTypeSmartAlbum,
                subtype,
                options,
            )
        )

        # album type
        if album_type:
            if not albums.count():
                raise PhotoKitFetchFailed(
                    f"Fetch did not return result for album_type {album_type}"
                )
            return Album(self, albums.objectAtIndex_(0))

        # album name
        for i in range(albums.count()):
            album = albums.objectAtIndex_(i)
            if album.localizedTitle() == album_name and (
                not user or album.isUserSmartAlbum()
            ):
                return Album(self, album)
        raise PhotoKitFetchFailed(
            f"Fetch did not return result for album {album_name}"
        )

smart_albums(user=False)

Get list of smart albums

Parameters:

Name Type Description Default
user bool

if True, fetch user smart albums instead of system smart albums

False

Returns: list of Album objects for smart albums

Note: This only works in single library mode

Source code in photokit/photolibrary.py
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
def smart_albums(self, user: bool = False) -> list[Album]:
    """Get list of smart albums

    Args:
        user: if True, fetch user smart albums instead of system smart albums

    Returns: list of Album objects for smart albums

    Raises:
        PhotoKitFetchFailed if fetch failed (album not found)

    Note: This only works in single library mode
    """

    if PhotoLibrary.multi_library_mode():
        raise NotImplementedError(
            "Fetching smart albums not implemented in multi-library mode"
        )

    # single library mode
    subtype = 0
    with objc.autorelease_pool():
        options = Photos.PHFetchOptions.alloc().init()
        if user:
            options.setIncludeUserSmartAlbums_(True)
        albums = (
            Photos.PHAssetCollection.fetchAssetCollectionsWithType_subtype_options_(
                Photos.PHAssetCollectionTypeSmartAlbum,
                subtype,
                options,
            )
        )

        album_list = []
        for i in range(albums.count()):
            album = albums.objectAtIndex_(i)
            if user and not album.isUserSmartAlbum():
                continue
            elif not user and album.isUserSmartAlbum():
                continue
            album_list.append(Album(self, album))

        if not album_list:
            raise PhotoKitFetchFailed(
                f"Fetch did not return result for smart albums"
            )

        return album_list

system_photo_library_path() staticmethod

Return path to system photo library

Source code in photokit/photolibrary.py
158
159
160
161
@staticmethod
def system_photo_library_path() -> str:
    """Return path to system photo library"""
    return NSURL_to_path(Photos.PHPhotoLibrary.systemPhotoLibraryURL())

PhotoLibrarySmartAlbumType

Bases: Enum

Smart album types. The following are supported:

  • Favorites
  • Hidden
  • Animated
  • Bursts
  • Cinematics
  • Portraits
  • Generic
  • LivePhotos
  • LongExposures
  • Panoramas
  • RAW
  • RecentlyAdded
  • Screenshots
  • Selfies
  • SlowMos
  • TimeLapses
  • UnableToUload
  • UserLibrary
  • Videos
Source code in photokit/photolibrary.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
class PhotoLibrarySmartAlbumType(enum.Enum):
    """Smart album types.  The following are supported:

    - Favorites
    - Hidden
    - Animated
    - Bursts
    - Cinematics
    - Portraits
    - Generic
    - LivePhotos
    - LongExposures
    - Panoramas
    - RAW
    - RecentlyAdded
    - Screenshots
    - Selfies
    - SlowMos
    - TimeLapses
    - UnableToUload
    - UserLibrary
    - Videos
    """

    # reference: https://developer.apple.com/documentation/photokit/phassetcollectionsubtype?language=objc
    Favorites = Photos.PHAssetCollectionSubtypeSmartAlbumFavorites
    Hidden = Photos.PHAssetCollectionSubtypeSmartAlbumAllHidden
    Animated = Photos.PHAssetCollectionSubtypeSmartAlbumAnimated
    Bursts = Photos.PHAssetCollectionSubtypeSmartAlbumBursts
    Cinematics = Photos.PHAssetCollectionSubtypeSmartAlbumCinematic
    Portraits = Photos.PHAssetCollectionSubtypeSmartAlbumDepthEffect
    Generic = Photos.PHAssetCollectionSubtypeSmartAlbumGeneric
    LivePhotos = Photos.PHAssetCollectionSubtypeSmartAlbumLivePhotos
    LongExposures = Photos.PHAssetCollectionSubtypeSmartAlbumLongExposures
    Panoramas = Photos.PHAssetCollectionSubtypeSmartAlbumPanoramas
    RAW = Photos.PHAssetCollectionSubtypeSmartAlbumRAW
    RecentlyAdded = Photos.PHAssetCollectionSubtypeSmartAlbumRecentlyAdded
    Screenshots = Photos.PHAssetCollectionSubtypeSmartAlbumScreenshots
    Selfies = Photos.PHAssetCollectionSubtypeSmartAlbumSelfPortraits
    SlowMos = Photos.PHAssetCollectionSubtypeSmartAlbumSlomoVideos
    TimeLapses = Photos.PHAssetCollectionSubtypeSmartAlbumTimelapses
    UnableToUload = Photos.PHAssetCollectionSubtypeSmartAlbumUnableToUpload
    UserLibrary = Photos.PHAssetCollectionSubtypeSmartAlbumUserLibrary
    Videos = Photos.PHAssetCollectionSubtypeSmartAlbumVideos

PhotoAsset

Bases: Asset

PhotoKit PHAsset representation

Source code in photokit/asset.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
class PhotoAsset(Asset):
    """PhotoKit PHAsset representation"""

    def __init__(self, library: PhotoLibrary, phasset: Photos.PHAsset):
        """Return a PhotoAsset object

        Args:
            library: a PhotoLibrary object
            phasset: a PHAsset object
        """
        self._library = library
        self._manager = self._library._phimagemanager
        self._phasset = phasset

    @property
    def phasset(self):
        """Return PHAsset instance"""
        return self._phasset

    @property
    def uuid(self):
        """Return UUID of PHAsset. This is the same as the local identifier minus the added path component."""
        return self._phasset.localIdentifier().split("/")[0]

    @property
    def local_identifier(self):
        """Return local identifier of PHAsset"""
        return self._phasset.localIdentifier()

    @property
    def isphoto(self):
        """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 self.media_type == Photos.PHAssetMediaTypeVideo

    @property
    def isaudio(self):
        """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"""
        resources = self._resources()
        for resource in resources:
            if (
                self.isphoto
                and resource.type() == Photos.PHAssetResourceTypePhoto
                or not self.isphoto
                and resource.type() == Photos.PHAssetResourceTypeVideo
            ):
                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"""

        # reference: https://developer.apple.com/documentation/photokit/phassetresource/1623988-assetresourcesforasset?language=objc

        adjustment_resources = Photos.PHAssetResource.assetResourcesForAsset_(
            self.phasset
        )
        return any(
            (
                adjustment_resources.objectAtIndex_(idx).type()
                == Photos.PHAssetResourceTypeAdjustmentData
            )
            for idx in range(adjustment_resources.count())
        )

    @property
    def media_type(self):
        """media type such as image or video"""
        return self.phasset.mediaType()

    @property
    def media_subtypes(self):
        """media subtype"""
        return self.phasset.mediaSubtypes()

    @property
    def panorama(self):
        """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 bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoHDR)

    @property
    def screenshot(self):
        """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 bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoLive)

    @property
    def streamed(self):
        """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 bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoHighFrameRate)

    @property
    def time_lapse(self):
        """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 bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoDepthEffect)

    @property
    def burstid(self):
        """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 bool(self.burstid)

    @property
    def source_type(self):
        """the means by which the asset entered the user's library"""
        return self.phasset.sourceType()

    @property
    def pixel_width(self):
        """width in pixels"""
        return self.phasset.pixelWidth()

    @property
    def pixel_height(self):
        """height in pixels"""
        return self.phasset.pixelHeight()

    @property
    def date(self) -> datetime.datetime:
        """date asset was created as a naive datetime.datetime"""
        return NSDate_to_datetime(self.phasset.creationDate())

    @date.setter
    def date(self, date: datetime.datetime):
        """Set date asset was created"""

        def change_request_handler(change_request: Photos.PHAssetChangeRequest):
            creation_date = datetime_to_NSDate(date)
            change_request.setCreationDate_(creation_date)

        self._perform_changes(change_request_handler)

    @property
    def date_modified(self) -> datetime.datetime:
        """date asset was modified as a naive datetime.datetime"""
        return NSDate_to_datetime(self.phasset.modificationDate())

    @date_modified.setter
    def date_modified(self, date: datetime.datetime):
        """Set date asset was modified"""

        def change_request_handler(change_request: Photos.PHAssetChangeRequest):
            modification_date = datetime_to_NSDate(date)
            change_request.setModificationDate_(modification_date)

        self._perform_changes(change_request_handler)

    @property
    def date_added(self) -> datetime.datetime:
        """date asset was added to the library as a naive datetime.datetime"""
        # as best as I can tell there is no property to retrieve the date added
        # so get it from the database
        return self._library._photosdb.get_date_added_for_uuid(self.uuid)

    @date_added.setter
    def date_added(self, date: datetime.datetime):
        """Set date asset was added to the library"""

        def change_request_handler(change_request: Photos.PHAssetChangeRequest):
            added_date = datetime_to_NSDate(date)
            change_request.setAddedDate_(added_date)

        self._perform_changes(change_request_handler)

    @property
    def timezone_offset(self) -> int:
        """Timezone offset (seconds from GMT) of the asset"""
        # no property that I can find to retrieve this directly
        # so query the database instead
        return self._library._photosdb.get_timezone_for_uuid(self.uuid)[0]

    @timezone_offset.setter
    def timezone_offset(self, tz_offset: int):
        """Set timezone offset from UTC (in seconds) for asset"""

        def change_request_handler(change_request: Photos.PHAssetChangeRequest):
            timezone = Foundation.NSTimeZone.timeZoneForSecondsFromGMT_(tz_offset)
            date = change_request.creationDate()
            change_request.setTimeZone_withDate_(timezone, date)

        self._perform_changes(change_request_handler)

    @property
    def timezone(self) -> str:
        """The named timezone of the asset"""
        return self._library._photosdb.get_timezone_for_uuid(self.uuid)[2]

    @timezone.setter
    def timezone(self, tz: str):
        """Set the named timzone of the asset"""

        with objc.autorelease_pool():
            timezone = Foundation.NSTimeZone.timeZoneWithName_(tz)
            if not timezone:
                raise ValueError(f"Invalid timezone: {tz}")

            def change_request_handler(change_request: Photos.PHAssetChangeRequest):
                date = change_request.creationDate()
                change_request.setTimeZone_withDate_(timezone, date)

            self._perform_changes(change_request_handler)

    @property
    def location(self) -> tuple[float, float] | None:
        """location of the asset as a tuple of (latitude, longitude) or None if no location"""
        self._refresh()
        cllocation = self.phasset.location()
        return cllocation.coordinate() if cllocation else None

    @location.setter
    def location(self, latlon: tuple[float, float] | None):
        """Set location of asset to lat, lon or None"""

        with objc.autorelease_pool():

            def change_request_handler(change_request: Photos.PHAssetChangeRequest):
                if latlon is None:
                    location = Foundation.CLLocation.alloc().init()
                else:
                    location = (
                        Foundation.CLLocation.alloc().initWithLatitude_longitude_(
                            latlon[0], latlon[1]
                        )
                    )
                change_request.setLocation_(location)

            self._perform_changes(change_request_handler)

    @property
    def duration(self) -> float:
        """duration of the asset in seconds"""
        return self.phasset.duration()

    @property
    def favorite(self) -> bool:
        """True if asset is favorite, otherwise False"""
        self._refresh()
        return self.phasset.isFavorite()

    @favorite.setter
    def favorite(self, value: bool):
        """Set or clear favorite status of asset"""

        def change_request_handler(change_request: Photos.PHAssetChangeRequest):
            change_request.setFavorite_(value)

        self._perform_changes(change_request_handler)

    @property
    def hidden(self):
        """True if asset is hidden, otherwise False"""
        return self.phasset.isHidden()

    @hidden.setter
    def hidden(self, value: bool):
        """Set or clear hidden status of asset; note that toggling hidden may requre user confirmation"""

        def change_request_handler(change_request: Photos.PHAssetChangeRequest):
            change_request.setHidden_(value)

        self._perform_changes(change_request_handler, refresh=False)

    @property
    def keywords(self) -> list[str]:
        """Keywords associated with asset"""
        keywords = Photos.PHKeyword.fetchKeywordsForAsset_options_(self.phasset, None)
        return [keywords.objectAtIndex_(idx).title() for idx in range(keywords.count())]

    @keywords.setter
    def keywords(self, keywords: list[str]):
        """Set keywords associated with asset"""
        with objc.autorelease_pool():
            # get PHKeyword objects for current keywords
            current_phkeywords = self._library._keywords_from_title_list(self.keywords)

            # get PHKeyword objects for new keywords
            try:
                new_phkeywords = self._library._keywords_from_title_list(keywords)
            except PhotoKitFetchFailed:
                new_phkeywords = []
            phkeywords_titles = [kw.title() for kw in new_phkeywords]

            # are there any new keywords that need to be created?
            new_keywords = [kw for kw in keywords if kw not in phkeywords_titles]
            for kw in new_keywords:
                new_phkeywords.append(self._library.create_keyword(kw))

            phkeywords_to_remove = [
                kw for kw in current_phkeywords if kw not in new_phkeywords
            ]

            def change_request_handler(change_request: Photos.PHAssetChangeRequest):
                change_request.addKeywords_(new_phkeywords)
                if phkeywords_to_remove:
                    change_request.removeKeywords_(phkeywords_to_remove)

            self._perform_changes(change_request_handler)

    # Not working yet
    # @property
    # def persons(self) -> list[str]:
    #     """Persons in the asset"""
    #     persons = Photos.PHPerson.fetchPersonsInAsset_options_(self.phasset, None)
    #     person_list = []
    #     for idx in range(persons.count()):
    #         print(persons.objectAtIndex_(idx))
    #         person_list.append(persons.objectAtIndex_(idx).displayName())
    #     return person_list

    def metadata(self, version=PHImageRequestOptionsVersionCurrent):
        """Return dict of asset metadata

        Args:
            version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
        """
        imagedata = self._request_image_data(version=version)
        return imagedata.metadata

    def uti(self, version=PHImageRequestOptionsVersionCurrent):
        """Return UTI of asset

        Args:
            version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
        """
        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=PHImageRequestOptionsVersionCurrent):
        """Return URL of asset

        Args:
            version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
        """
        imagedata = self._request_image_data(version=version)
        return str(imagedata.info["PHImageFileURLKey"])

    def path(self, version=PHImageRequestOptionsVersionCurrent):
        """Return path of asset

        Args:
            version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
        """
        imagedata = self._request_image_data(version=version)
        url = imagedata.info["PHImageFileURLKey"]
        return url.fileSystemRepresentation().decode("utf-8")

    def orientation(self, version=PHImageRequestOptionsVersionCurrent):
        """Return orientation of asset

        Args:
            version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
        """
        imagedata = self._request_image_data(version=version)
        return imagedata.orientation

    @property
    def degraded(self, version=PHImageRequestOptionsVersionCurrent):
        """Return True if asset is degraded version

        Args:
            version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
        """
        imagedata = self._request_image_data(version=version)
        return imagedata.info["PHImageResultIsDegradedKey"]

    def _refresh(self):
        """Reload the asset from the library"""
        # this shouldn't be necessary but sometimes after creating a change (for example, toggling favorite)
        # the properties do not refresh
        self._phasset = self._library.asset(self.uuid)._phasset

    def _perform_changes(
        self,
        change_request_handler: Callable[[Photos.PHAssetChangeRequest], None],
        refresh: bool = True,
    ):
        """Perform changes on a PHAsset

        Args:
            change_request_handler: a callable that will be passed the PHAssetChangeRequest to perform changes
            refresh: if True, refresh the asset from the library after performing changes (default is True)
        """

        with objc.autorelease_pool():
            event = threading.Event()

            def completion_handler(success, error):
                if error:
                    raise PhotoKitChangeError(f"Error changing asset: {error}")
                event.set()

            def _change_request_handler():
                change_request = Photos.PHAssetChangeRequest.changeRequestForAsset_(
                    self.phasset
                )
                change_request_handler(change_request)

            self._library._phphotolibrary.performChanges_completionHandler_(
                lambda: _change_request_handler(), completion_handler
            )

            event.wait()

            if refresh:
                self._refresh()

    def export(
        self,
        dest,
        filename=None,
        version=PHImageRequestOptionsVersionCurrent,
        overwrite=False,
        raw=False,
        **kwargs,
    ):
        """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 (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
            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
            **kwargs: used only to avoid issues with each asset type having slightly different export arguments

        Returns:
            List of path to exported image(s)

        Raises:
            ValueError if dest is not a valid directory
        """

        with objc.autorelease_pool():
            with pipes() as (out, err):
                filename = (
                    pathlib.Path(filename)
                    if filename
                    else pathlib.Path(self.original_filename)
                )

                dest = pathlib.Path(dest)
                if not dest.is_dir():
                    raise ValueError("dest must be a valid directory: {dest}")

                output_file = None
                if self.isphoto:
                    # 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)
                                suffix = pathlib.Path(self.raw_filename).suffix
                                ext = suffix[1:] if suffix else ""
                                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}"

                    if not overwrite:
                        output_file = pathlib.Path(increment_filename(output_file))

                    with open(output_file, "wb") as fd:
                        fd.write(data)

                    if imagedata:
                        del imagedata
                elif self.ismovie:
                    videodata = self._request_video_data(version=version)
                    if videodata.asset is None:
                        raise PhotoKitExportError("Could not get video for asset")

                    url = videodata.asset.URL()
                    path = pathlib.Path(NSURL_to_path(url))
                    if not path.is_file():
                        raise FileNotFoundError("Could not get path to video file")
                    ext = path.suffix
                    output_file = dest / f"{filename.stem}{ext}"

                    if not overwrite:
                        output_file = pathlib.Path(increment_filename(output_file))

                    FileUtil.copy(path, output_file)

                return [str(output_file)]

    def _request_image_data(self, version=PHImageRequestOptionsVersionOriginal):
        """Request image data and metadata for self._phasset

        Args:
            version: which version to request
                     PHImageRequestOptionsVersionOriginal (default), request original highest fidelity version
                     PHImageRequestOptionsVersionCurrent, request current version with all edits
                     PHImageRequestOptionsVersionUnadjusted, request highest quality unadjusted version

        Returns:
            ImageData instance

        Raises:
            ValueError if passed invalid value for version
        """

        # reference: https://developer.apple.com/documentation/photokit/phimagemanager/3237282-requestimagedataandorientationfo?language=objc

        with objc.autorelease_pool():
            if version not in [
                PHImageRequestOptionsVersionCurrent,
                PHImageRequestOptionsVersionOriginal,
                PHImageRequestOptionsVersionUnadjusted,
            ]:
                raise ValueError("Invalid value for version")

            options_request = Photos.PHImageRequestOptions.alloc().init()
            options_request.setNetworkAccessAllowed_(True)
            options_request.setSynchronous_(True)
            options_request.setVersion_(version)
            options_request.setDeliveryMode_(
                Photos.PHImageRequestOptionsDeliveryModeHighQualityFormat
            )
            requestdata = ImageData()
            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)
                """

                nonlocal requestdata

                options = {Quartz.kCGImageSourceShouldCache: Foundation.kCFBooleanFalse}
                imgSrc = Quartz.CGImageSourceCreateWithData(imageData, options)
                requestdata.metadata = Quartz.CGImageSourceCopyPropertiesAtIndex(
                    imgSrc, 0, options
                )
                requestdata.uti = dataUTI
                requestdata.orientation = orientation
                requestdata.info = info
                requestdata.image_data = imageData

                event.set()

            self._manager.requestImageDataAndOrientationForAsset_options_resultHandler_(
                self.phasset, options_request, handler
            )
            event.wait()
            # options_request.dealloc()

            # not sure why this is needed -- some weird ref count thing maybe
            # if I don't do this, memory leaks
            data = copy.copy(requestdata)
            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"""

        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)
            """

            nonlocal data

            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

            event.set()

        return handler, event

    def _resources(self):
        """Return list of PHAssetResource for object"""
        resources = Photos.PHAssetResource.assetResourcesForAsset_(self.phasset)
        return [resources.objectAtIndex_(idx) for idx in range(resources.count())]

burst property

return True if image is burst otherwise False

burstid property

return burstIdentifier of image if image is burst photo otherwise None

date: datetime.datetime property writable

date asset was created as a naive datetime.datetime

date_added: datetime.datetime property writable

date asset was added to the library as a naive datetime.datetime

date_modified: datetime.datetime property writable

date asset was modified as a naive datetime.datetime

degraded property

Return True if asset is degraded version

Parameters:

Name Type Description Default
version

which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)

required

duration: float property

duration of the asset in seconds

favorite: bool property writable

True if asset is favorite, otherwise False

hasadjustments property

Check to see if a PHAsset has adjustment data associated with it Returns False if no adjustments, True if any adjustments

hdr property

return True if asset is HDR, otherwise False

hidden property writable

True if asset is hidden, otherwise False

isaudio property

Return True if asset is audio, otherwise False

ismovie property

Return True if asset is movie (video), otherwise False

isphoto property

Return True if asset is photo (image), otherwise False

keywords: list[str] property writable

Keywords associated with asset

live property

return True if asset is live, otherwise False

local_identifier property

Return local identifier of PHAsset

location: tuple[float, float] | None property writable

location of the asset as a tuple of (latitude, longitude) or None if no location

media_subtypes property

media subtype

media_type property

media type such as image or video

original_filename property

Return original filename asset was imported with

panorama property

return True if asset is panorama, otherwise False

phasset property

Return PHAsset instance

pixel_height property

height in pixels

pixel_width property

width in pixels

portrait property

return True if asset is portrait (depth effect), otherwise False

raw_filename property

Return RAW filename for RAW+JPEG photos or None if no RAW asset

screenshot property

return True if asset is screenshot, otherwise False

slow_mo property

return True if asset is slow motion (high frame rate) video, otherwise False

source_type property

the means by which the asset entered the user's library

streamed property

return True if asset is streamed video, otherwise False

time_lapse property

return True if asset is time lapse video, otherwise False

timezone: str property writable

The named timezone of the asset

timezone_offset: int property writable

Timezone offset (seconds from GMT) of the asset

uuid property

Return UUID of PHAsset. This is the same as the local identifier minus the added path component.

__init__(library, phasset)

Return a PhotoAsset object

Parameters:

Name Type Description Default
library PhotoLibrary

a PhotoLibrary object

required
phasset PHAsset

a PHAsset object

required
Source code in photokit/asset.py
128
129
130
131
132
133
134
135
136
137
def __init__(self, library: PhotoLibrary, phasset: Photos.PHAsset):
    """Return a PhotoAsset object

    Args:
        library: a PhotoLibrary object
        phasset: a PHAsset object
    """
    self._library = library
    self._manager = self._library._phimagemanager
    self._phasset = phasset

export(dest, filename=None, version=PHImageRequestOptionsVersionCurrent, overwrite=False, raw=False, **kwargs)

Export image to path

Parameters:

Name Type Description Default
dest

str, path to destination directory

required
filename

str, optional name of exported file; if not provided, defaults to asset's original filename

None
version

which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)

PHImageRequestOptionsVersionCurrent
overwrite

bool, if True, overwrites destination file if it already exists; default is False

False
raw

bool, if True, export RAW component of RAW+JPEG pair, default is False

False
**kwargs

used only to avoid issues with each asset type having slightly different export arguments

{}

Returns:

Type Description

List of path to exported image(s)

Source code in photokit/asset.py
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
def export(
    self,
    dest,
    filename=None,
    version=PHImageRequestOptionsVersionCurrent,
    overwrite=False,
    raw=False,
    **kwargs,
):
    """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 (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
        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
        **kwargs: used only to avoid issues with each asset type having slightly different export arguments

    Returns:
        List of path to exported image(s)

    Raises:
        ValueError if dest is not a valid directory
    """

    with objc.autorelease_pool():
        with pipes() as (out, err):
            filename = (
                pathlib.Path(filename)
                if filename
                else pathlib.Path(self.original_filename)
            )

            dest = pathlib.Path(dest)
            if not dest.is_dir():
                raise ValueError("dest must be a valid directory: {dest}")

            output_file = None
            if self.isphoto:
                # 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)
                            suffix = pathlib.Path(self.raw_filename).suffix
                            ext = suffix[1:] if suffix else ""
                            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}"

                if not overwrite:
                    output_file = pathlib.Path(increment_filename(output_file))

                with open(output_file, "wb") as fd:
                    fd.write(data)

                if imagedata:
                    del imagedata
            elif self.ismovie:
                videodata = self._request_video_data(version=version)
                if videodata.asset is None:
                    raise PhotoKitExportError("Could not get video for asset")

                url = videodata.asset.URL()
                path = pathlib.Path(NSURL_to_path(url))
                if not path.is_file():
                    raise FileNotFoundError("Could not get path to video file")
                ext = path.suffix
                output_file = dest / f"{filename.stem}{ext}"

                if not overwrite:
                    output_file = pathlib.Path(increment_filename(output_file))

                FileUtil.copy(path, output_file)

            return [str(output_file)]

metadata(version=PHImageRequestOptionsVersionCurrent)

Return dict of asset metadata

Parameters:

Name Type Description Default
version

which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)

PHImageRequestOptionsVersionCurrent
Source code in photokit/asset.py
480
481
482
483
484
485
486
487
def metadata(self, version=PHImageRequestOptionsVersionCurrent):
    """Return dict of asset metadata

    Args:
        version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
    """
    imagedata = self._request_image_data(version=version)
    return imagedata.metadata

orientation(version=PHImageRequestOptionsVersionCurrent)

Return orientation of asset

Parameters:

Name Type Description Default
version

which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)

PHImageRequestOptionsVersionCurrent
Source code in photokit/asset.py
528
529
530
531
532
533
534
535
def orientation(self, version=PHImageRequestOptionsVersionCurrent):
    """Return orientation of asset

    Args:
        version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
    """
    imagedata = self._request_image_data(version=version)
    return imagedata.orientation

path(version=PHImageRequestOptionsVersionCurrent)

Return path of asset

Parameters:

Name Type Description Default
version

which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)

PHImageRequestOptionsVersionCurrent
Source code in photokit/asset.py
518
519
520
521
522
523
524
525
526
def path(self, version=PHImageRequestOptionsVersionCurrent):
    """Return path of asset

    Args:
        version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
    """
    imagedata = self._request_image_data(version=version)
    url = imagedata.info["PHImageFileURLKey"]
    return url.fileSystemRepresentation().decode("utf-8")

url(version=PHImageRequestOptionsVersionCurrent)

Return URL of asset

Parameters:

Name Type Description Default
version

which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)

PHImageRequestOptionsVersionCurrent
Source code in photokit/asset.py
509
510
511
512
513
514
515
516
def url(self, version=PHImageRequestOptionsVersionCurrent):
    """Return URL of asset

    Args:
        version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
    """
    imagedata = self._request_image_data(version=version)
    return str(imagedata.info["PHImageFileURLKey"])

uti(version=PHImageRequestOptionsVersionCurrent)

Return UTI of asset

Parameters:

Name Type Description Default
version

which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)

PHImageRequestOptionsVersionCurrent
Source code in photokit/asset.py
489
490
491
492
493
494
495
496
def uti(self, version=PHImageRequestOptionsVersionCurrent):
    """Return UTI of asset

    Args:
        version: which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
    """
    imagedata = self._request_image_data(version=version)
    return imagedata.uti

uti_raw()

Return UTI of RAW component of RAW+JPEG pair

Source code in photokit/asset.py
498
499
500
501
502
503
504
505
506
507
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

LivePhotoAsset

Bases: PhotoAsset

Represents a live photo

Source code in photokit/asset.py
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
class LivePhotoAsset(PhotoAsset):
    """Represents a live photo"""

    def export(
        self,
        dest,
        filename=None,
        version=PHImageRequestOptionsVersionCurrent,
        overwrite=False,
        photo=True,
        video=True,
        **kwargs,
    ):
        """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 (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
            overwrite: bool, if True, overwrites destination file if it already exists; default is False
            photo: bool, if True, export photo component of live photo
            video: bool, if True, export live video component of live photo
            **kwargs: used only to avoid issues with each asset type having slightly different export arguments

        Returns:
            list of [path to exported image and/or video]

        Raises:
            ValueError if dest is not a valid directory
            PhotoKitExportError if error during export
        """

        with objc.autorelease_pool():
            with pipes() as (out, err):
                filename = (
                    pathlib.Path(filename)
                    if filename
                    else pathlib.Path(self.original_filename)
                )

                dest = pathlib.Path(dest)
                if not dest.is_dir():
                    raise ValueError("dest must be a valid directory: {dest}")

                request = _LivePhotoRequest.alloc().initWithManager_Asset_(
                    self._manager, self.phasset
                )
                resources = request.requestLivePhotoResources(version=version)

                video_resource = None
                photo_resource = None
                for resource in resources:
                    if resource.type() == Photos.PHAssetResourceTypePairedVideo:
                        video_resource = resource
                    elif resource.type() == Photos.PHAssetMediaTypeImage:
                        photo_resource = resource

                if not video_resource or not photo_resource:
                    raise PhotoKitExportError(
                        "Did not find photo/video resources for live photo"
                    )

                photo_ext = get_preferred_uti_extension(
                    photo_resource.uniformTypeIdentifier()
                )
                photo_output_file = dest / f"{filename.stem}.{photo_ext}"
                video_ext = get_preferred_uti_extension(
                    video_resource.uniformTypeIdentifier()
                )
                video_output_file = dest / f"{filename.stem}.{video_ext}"

                if not overwrite:
                    photo_output_file = pathlib.Path(
                        increment_filename(photo_output_file)
                    )
                    video_output_file = pathlib.Path(
                        increment_filename(video_output_file)
                    )

                exported = []
                if photo:
                    data = self._request_resource_data(photo_resource)
                    # image_data = self.request_image_data(version=version)
                    with open(photo_output_file, "wb") as fd:
                        fd.write(data)
                    exported.append(str(photo_output_file))
                    del data
                if video:
                    data = self._request_resource_data(video_resource)
                    with open(video_output_file, "wb") as fd:
                        fd.write(data)
                    exported.append(str(video_output_file))
                    del data

                request.dealloc()
                return exported

export(dest, filename=None, version=PHImageRequestOptionsVersionCurrent, overwrite=False, photo=True, video=True, **kwargs)

Export image to path

Parameters:

Name Type Description Default
dest

str, path to destination directory

required
filename

str, optional name of exported file; if not provided, defaults to asset's original filename

None
version

which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)

PHImageRequestOptionsVersionCurrent
overwrite

bool, if True, overwrites destination file if it already exists; default is False

False
photo

bool, if True, export photo component of live photo

True
video

bool, if True, export live video component of live photo

True
**kwargs

used only to avoid issues with each asset type having slightly different export arguments

{}

Returns:

Type Description

list of [path to exported image and/or video]

Source code in photokit/asset.py
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
def export(
    self,
    dest,
    filename=None,
    version=PHImageRequestOptionsVersionCurrent,
    overwrite=False,
    photo=True,
    video=True,
    **kwargs,
):
    """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 (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
        overwrite: bool, if True, overwrites destination file if it already exists; default is False
        photo: bool, if True, export photo component of live photo
        video: bool, if True, export live video component of live photo
        **kwargs: used only to avoid issues with each asset type having slightly different export arguments

    Returns:
        list of [path to exported image and/or video]

    Raises:
        ValueError if dest is not a valid directory
        PhotoKitExportError if error during export
    """

    with objc.autorelease_pool():
        with pipes() as (out, err):
            filename = (
                pathlib.Path(filename)
                if filename
                else pathlib.Path(self.original_filename)
            )

            dest = pathlib.Path(dest)
            if not dest.is_dir():
                raise ValueError("dest must be a valid directory: {dest}")

            request = _LivePhotoRequest.alloc().initWithManager_Asset_(
                self._manager, self.phasset
            )
            resources = request.requestLivePhotoResources(version=version)

            video_resource = None
            photo_resource = None
            for resource in resources:
                if resource.type() == Photos.PHAssetResourceTypePairedVideo:
                    video_resource = resource
                elif resource.type() == Photos.PHAssetMediaTypeImage:
                    photo_resource = resource

            if not video_resource or not photo_resource:
                raise PhotoKitExportError(
                    "Did not find photo/video resources for live photo"
                )

            photo_ext = get_preferred_uti_extension(
                photo_resource.uniformTypeIdentifier()
            )
            photo_output_file = dest / f"{filename.stem}.{photo_ext}"
            video_ext = get_preferred_uti_extension(
                video_resource.uniformTypeIdentifier()
            )
            video_output_file = dest / f"{filename.stem}.{video_ext}"

            if not overwrite:
                photo_output_file = pathlib.Path(
                    increment_filename(photo_output_file)
                )
                video_output_file = pathlib.Path(
                    increment_filename(video_output_file)
                )

            exported = []
            if photo:
                data = self._request_resource_data(photo_resource)
                # image_data = self.request_image_data(version=version)
                with open(photo_output_file, "wb") as fd:
                    fd.write(data)
                exported.append(str(photo_output_file))
                del data
            if video:
                data = self._request_resource_data(video_resource)
                with open(video_output_file, "wb") as fd:
                    fd.write(data)
                exported.append(str(video_output_file))
                del data

            request.dealloc()
            return exported

VideoAsset

Bases: PhotoAsset

PhotoKit PHAsset representation of video asset

Source code in photokit/asset.py
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
class VideoAsset(PhotoAsset):
    """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
    # https://developer.apple.com/documentation/photokit/phimagemanager/1616935-requestavassetforvideo?language=objc
    # https://developer.apple.com/documentation/photokit/phimagemanager/1616981-requestexportsessionforvideo?language=objc
    # above 10.15 only
    def export(
        self,
        dest,
        filename=None,
        version=PHImageRequestOptionsVersionCurrent,
        overwrite=False,
        **kwargs,
    ):
        """Export video 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 (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
            overwrite: bool, if True, overwrites destination file if it already exists; default is False
            **kwargs: used only to avoid issues with each asset type having slightly different export arguments

        Returns:
            List of path to exported image(s)

        Raises:
            ValueError if dest is not a valid directory
        """

        with objc.autorelease_pool():
            with pipes() as (out, err):
                if self.slow_mo and version == PHImageRequestOptionsVersionCurrent:
                    return [
                        self._export_slow_mo(
                            dest,
                            filename=filename,
                            version=version,
                            overwrite=overwrite,
                        )
                    ]

                filename = (
                    pathlib.Path(filename)
                    if filename
                    else pathlib.Path(self.original_filename)
                )

                dest = pathlib.Path(dest)
                if not dest.is_dir():
                    raise ValueError("dest must be a valid directory: {dest}")

                output_file = None
                videodata = self._request_video_data(version=version)
                if videodata.asset is None:
                    raise PhotoKitExportError("Could not get video for asset")

                url = videodata.asset.URL()
                path = pathlib.Path(NSURL_to_path(url))
                del videodata
                if not path.is_file():
                    raise FileNotFoundError("Could not get path to video file")
                ext = path.suffix
                output_file = dest / f"{filename.stem}{ext}"

                if not overwrite:
                    output_file = pathlib.Path(increment_filename(output_file))

                FileUtil.copy(path, output_file)

                return [str(output_file)]

    def _export_slow_mo(
        self,
        dest,
        filename=None,
        version=PHImageRequestOptionsVersionCurrent,
        overwrite=False,
    ):
        """Export slow-motion video 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 (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
            overwrite: bool, if True, overwrites destination file if it already exists; default is False

        Returns:
            Path to exported image

        Raises:
            ValueError if dest is not a valid directory
        """
        with objc.autorelease_pool():
            if not self.slow_mo:
                raise PhotoKitMediaTypeError("Not a slow-mo video")

            videodata = self._request_video_data(version=version)
            if (
                not isinstance(videodata.asset, AVFoundation.AVComposition)
                or len(videodata.asset.tracks()) != 2
            ):
                raise PhotoKitMediaTypeError("Does not appear to be slow-mo video")

            filename = (
                pathlib.Path(filename)
                if filename
                else pathlib.Path(self.original_filename)
            )

            dest = pathlib.Path(dest)
            if not dest.is_dir():
                raise ValueError("dest must be a valid directory: {dest}")

            output_file = dest / f"{filename.stem}.mov"

            if not overwrite:
                output_file = pathlib.Path(increment_filename(output_file))

            exporter = _SlowMoVideoExporter.alloc().initWithAVAsset_path_(
                videodata.asset, output_file
            )
            video = exporter.exportSlowMoVideo()
            # exporter.dealloc()
            return video

    # todo: rewrite this with NotificationCenter and App event loop?
    def _request_video_data(self, version=PHImageRequestOptionsVersionOriginal):
        """Request video data for self._phasset

        Args:
            version: which version to request
                     PHImageRequestOptionsVersionOriginal (default), request original highest fidelity version
                     PHImageRequestOptionsVersionCurrent, request current version with all edits
                     PHImageRequestOptionsVersionUnadjusted, request highest quality unadjusted version

        Raises:
            ValueError if passed invalid value for version
        """
        with objc.autorelease_pool():
            if version not in [
                PHImageRequestOptionsVersionCurrent,
                PHImageRequestOptionsVersionOriginal,
                PHImageRequestOptionsVersionUnadjusted,
            ]:
                raise ValueError("Invalid value for version")

            options_request = Photos.PHVideoRequestOptions.alloc().init()
            options_request.setNetworkAccessAllowed_(True)
            options_request.setVersion_(version)
            options_request.setDeliveryMode_(
                Photos.PHVideoRequestOptionsDeliveryModeHighQualityFormat
            )
            requestdata = AVAssetData()
            event = threading.Event()

            def handler(asset, audiomix, info):
                """result handler for requestAVAssetForVideo:asset options:options resultHandler"""
                nonlocal requestdata

                requestdata.asset = asset
                requestdata.audiomix = audiomix
                requestdata.info = info

                event.set()

            self._manager.requestAVAssetForVideo_options_resultHandler_(
                self.phasset, options_request, 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)
            del requestdata
            return data

export(dest, filename=None, version=PHImageRequestOptionsVersionCurrent, overwrite=False, **kwargs)

Export video to path

Parameters:

Name Type Description Default
dest

str, path to destination directory

required
filename

str, optional name of exported file; if not provided, defaults to asset's original filename

None
version

which version of image (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)

PHImageRequestOptionsVersionCurrent
overwrite

bool, if True, overwrites destination file if it already exists; default is False

False
**kwargs

used only to avoid issues with each asset type having slightly different export arguments

{}

Returns:

Type Description

List of path to exported image(s)

Source code in photokit/asset.py
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
def export(
    self,
    dest,
    filename=None,
    version=PHImageRequestOptionsVersionCurrent,
    overwrite=False,
    **kwargs,
):
    """Export video 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 (PHImageRequestOptionsVersionOriginal or PHImageRequestOptionsVersionCurrent)
        overwrite: bool, if True, overwrites destination file if it already exists; default is False
        **kwargs: used only to avoid issues with each asset type having slightly different export arguments

    Returns:
        List of path to exported image(s)

    Raises:
        ValueError if dest is not a valid directory
    """

    with objc.autorelease_pool():
        with pipes() as (out, err):
            if self.slow_mo and version == PHImageRequestOptionsVersionCurrent:
                return [
                    self._export_slow_mo(
                        dest,
                        filename=filename,
                        version=version,
                        overwrite=overwrite,
                    )
                ]

            filename = (
                pathlib.Path(filename)
                if filename
                else pathlib.Path(self.original_filename)
            )

            dest = pathlib.Path(dest)
            if not dest.is_dir():
                raise ValueError("dest must be a valid directory: {dest}")

            output_file = None
            videodata = self._request_video_data(version=version)
            if videodata.asset is None:
                raise PhotoKitExportError("Could not get video for asset")

            url = videodata.asset.URL()
            path = pathlib.Path(NSURL_to_path(url))
            del videodata
            if not path.is_file():
                raise FileNotFoundError("Could not get path to video file")
            ext = path.suffix
            output_file = dest / f"{filename.stem}{ext}"

            if not overwrite:
                output_file = pathlib.Path(increment_filename(output_file))

            FileUtil.copy(path, output_file)

            return [str(output_file)]

Album

Represents a PHAssetCollection

Source code in photokit/album.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
class Album:
    """Represents a PHAssetCollection"""

    def __init__(
        self, library: PhotoLibrary, collection: Photos.PHAssetCollection
    ) -> None:
        """Initialize Album object with a PHAssetCollection"""
        self._library = library
        self._collection = collection

    @property
    def collection(self) -> Photos.PHAssetCollection:
        """Return the underlying PHAssetCollection"""
        return self._collection

    @property
    def local_identifier(self) -> str:
        """Return the local identifier of the underlying PHAssetCollection"""
        return self._collection.localIdentifier()

    @property
    def uuid(self) -> str:
        """ "Return the UUID of the underlying PHAssetCollection"""
        return self._collection.localIdentifier().split("/")[0]

    @property
    def title(self) -> str:
        """Return the localized title of the underlying PHAssetCollection"""
        return self._collection.localizedTitle()

    @property
    def estimated_count(self) -> int:
        """Return the estimated number of assets in the underlying PHAssetCollection"""
        return self._collection.estimatedAssetCount()

    @property
    def start_date(self) -> datetime.datetime | None:
        """Return the start date of the underlying PHAssetCollection as a naive datetime.datetime or None if no start date"""
        start_date = self._collection.startDate()
        return NSDate_to_datetime(start_date) if start_date else None

    @property
    def end_date(self) -> datetime.datetime | None:
        """Return the end date of the underlying PHAssetCollection as a naive datetime.datetime or None if no end date"""
        end_date = self._collection.endDate()
        return NSDate_to_datetime(end_date) if end_date else None

    @property
    def approximate_location(self) -> Photos.CLLocation:
        """Return the approximate location of the underlying PHAssetCollection"""
        return self._collection.approximateLocation()

    @property
    def location_names(self) -> list[str]:
        """Return the location names of the underlying PHAssetCollection"""
        return self._collection.localizedLocationNames()

    def assets(self) -> list[Photos.PHAsset]:
        """Return a list of PHAssets in the underlying PHAssetCollection"""
        assets = Photos.PHAsset.fetchAssetsInAssetCollection_options_(
            self._collection, None
        )
        asset_list = []
        for idx in range(assets.count()):
            asset_list.append(self._library._asset_factory(assets.objectAtIndex_(idx)))
        return asset_list

    def add_assets(self, assets: list[Asset]):
        """Add assets to the underlying album

        Args:
            assets: list of Asset objects to add to the album
        """

        with objc.autorelease_pool():
            event = threading.Event()

            def completion_handler(success, error):
                if error:
                    raise PhotoKitAlbumAddAssetError(
                        f"Error adding asset assets to album {self}: {error}"
                    )
                event.set()

            def album_add_assets_handler(assets):
                creation_request = Photos.PHAssetCollectionChangeRequest.changeRequestForAssetCollection_(
                    self.collection
                )
                phassets = [a.phasset for a in assets]
                creation_request.addAssets_(phassets)

            self._library._phphotolibrary.performChanges_completionHandler_(
                lambda: album_add_assets_handler(assets), completion_handler
            )

            event.wait()

    def remove_assets(self, assets: list[Asset]):
        """Remove assets from the underlying album

        Args:
            assets: list of Asset objects to remove from the album
        """

        with objc.autorelease_pool():
            event = threading.Event()

            def completion_handler(success, error):
                if error:
                    raise PhotoKitAlbumAddAssetError(
                        f"Error adding asset assets to album {self}: {error}"
                    )
                event.set()

            def album_remove_assets_handler(assets):
                creation_request = Photos.PHAssetCollectionChangeRequest.changeRequestForAssetCollection_(
                    self.collection
                )
                phassets = [a.phasset for a in assets]
                creation_request.removeAssets_(phassets)

            self._library._phphotolibrary.performChanges_completionHandler_(
                lambda: album_remove_assets_handler(assets), completion_handler
            )

            event.wait()

    def __repr__(self) -> str:
        """Return string representation of Album object"""
        return f"Album('{self._collection.localizedTitle()}')"

    def __str__(self) -> str:
        """Return string representation of Album object"""
        return f"Album('{self._collection.localizedTitle()}')"

    def __len__(self) -> int:
        """Return number of assets in the album"""
        return len(self.assets())

approximate_location: Photos.CLLocation property

Return the approximate location of the underlying PHAssetCollection

collection: Photos.PHAssetCollection property

Return the underlying PHAssetCollection

end_date: datetime.datetime | None property

Return the end date of the underlying PHAssetCollection as a naive datetime.datetime or None if no end date

estimated_count: int property

Return the estimated number of assets in the underlying PHAssetCollection

local_identifier: str property

Return the local identifier of the underlying PHAssetCollection

location_names: list[str] property

Return the location names of the underlying PHAssetCollection

start_date: datetime.datetime | None property

Return the start date of the underlying PHAssetCollection as a naive datetime.datetime or None if no start date

title: str property

Return the localized title of the underlying PHAssetCollection

uuid: str property

"Return the UUID of the underlying PHAssetCollection

__init__(library, collection)

Initialize Album object with a PHAssetCollection

Source code in photokit/album.py
23
24
25
26
27
28
def __init__(
    self, library: PhotoLibrary, collection: Photos.PHAssetCollection
) -> None:
    """Initialize Album object with a PHAssetCollection"""
    self._library = library
    self._collection = collection

__len__()

Return number of assets in the album

Source code in photokit/album.py
155
156
157
def __len__(self) -> int:
    """Return number of assets in the album"""
    return len(self.assets())

__repr__()

Return string representation of Album object

Source code in photokit/album.py
147
148
149
def __repr__(self) -> str:
    """Return string representation of Album object"""
    return f"Album('{self._collection.localizedTitle()}')"

__str__()

Return string representation of Album object

Source code in photokit/album.py
151
152
153
def __str__(self) -> str:
    """Return string representation of Album object"""
    return f"Album('{self._collection.localizedTitle()}')"

add_assets(assets)

Add assets to the underlying album

Parameters:

Name Type Description Default
assets list[Asset]

list of Asset objects to add to the album

required
Source code in photokit/album.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def add_assets(self, assets: list[Asset]):
    """Add assets to the underlying album

    Args:
        assets: list of Asset objects to add to the album
    """

    with objc.autorelease_pool():
        event = threading.Event()

        def completion_handler(success, error):
            if error:
                raise PhotoKitAlbumAddAssetError(
                    f"Error adding asset assets to album {self}: {error}"
                )
            event.set()

        def album_add_assets_handler(assets):
            creation_request = Photos.PHAssetCollectionChangeRequest.changeRequestForAssetCollection_(
                self.collection
            )
            phassets = [a.phasset for a in assets]
            creation_request.addAssets_(phassets)

        self._library._phphotolibrary.performChanges_completionHandler_(
            lambda: album_add_assets_handler(assets), completion_handler
        )

        event.wait()

assets()

Return a list of PHAssets in the underlying PHAssetCollection

Source code in photokit/album.py
77
78
79
80
81
82
83
84
85
def assets(self) -> list[Photos.PHAsset]:
    """Return a list of PHAssets in the underlying PHAssetCollection"""
    assets = Photos.PHAsset.fetchAssetsInAssetCollection_options_(
        self._collection, None
    )
    asset_list = []
    for idx in range(assets.count()):
        asset_list.append(self._library._asset_factory(assets.objectAtIndex_(idx)))
    return asset_list

remove_assets(assets)

Remove assets from the underlying album

Parameters:

Name Type Description Default
assets list[Asset]

list of Asset objects to remove from the album

required
Source code in photokit/album.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def remove_assets(self, assets: list[Asset]):
    """Remove assets from the underlying album

    Args:
        assets: list of Asset objects to remove from the album
    """

    with objc.autorelease_pool():
        event = threading.Event()

        def completion_handler(success, error):
            if error:
                raise PhotoKitAlbumAddAssetError(
                    f"Error adding asset assets to album {self}: {error}"
                )
            event.set()

        def album_remove_assets_handler(assets):
            creation_request = Photos.PHAssetCollectionChangeRequest.changeRequestForAssetCollection_(
                self.collection
            )
            phassets = [a.phasset for a in assets]
            creation_request.removeAssets_(phassets)

        self._library._phphotolibrary.performChanges_completionHandler_(
            lambda: album_remove_assets_handler(assets), completion_handler
        )

        event.wait()