From c6afd449e74ebb20ebc8d3390355219fccaf2178 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:53:42 +0000 Subject: [PATCH 1/8] chore: sync repo --- .coveragerc | 6 - .devcontainer/Dockerfile | 9 + .devcontainer/devcontainer.json | 43 + .editorconfig | 17 - .github/workflows/ci.yml | 98 + .github/workflows/publish.yml | 57 - .github/workflows/test.yml | 36 - .gitignore | 63 +- .python-version | 1 + .stats.yml | 4 + .vscode/settings.json | 4 + Brewfile | 2 + CONTRIBUTING.md | 128 + DEVELOPMENT.md | 22 - LICENSE | 222 +- README.md | 1827 ++++------ SECURITY.md | 27 + api.md | 266 ++ bin/publish-pypi | 6 + examples/.keep | 4 + imagekitio/__init__.py | 1 - imagekitio/client.py | 252 -- imagekitio/constants/defaults.py | 19 - imagekitio/constants/errors.py | 93 - imagekitio/constants/files.py | 35 - imagekitio/constants/supported_transform.py | 33 - imagekitio/constants/url.py | 4 - imagekitio/exceptions/BadRequestException.py | 16 - imagekitio/exceptions/ConflictException.py | 11 - imagekitio/exceptions/ForbiddenException.py | 11 - .../exceptions/InternalServerException.py | 11 - imagekitio/exceptions/NotFoundException.py | 11 - .../exceptions/PartialSuccessException.py | 11 - .../exceptions/TooManyRequestsException.py | 11 - .../exceptions/UnauthorizedException.py | 11 - imagekitio/exceptions/UnknownException.py | 11 - imagekitio/exceptions/__init__.py | 0 imagekitio/file.py | 829 ----- imagekitio/models/CopyFileRequestOptions.py | 13 - imagekitio/models/CopyFolderRequestOptions.py | 13 - ...reateCustomMetadataFieldsRequestOptions.py | 36 - .../models/CreateFolderRequestOptions.py | 6 - imagekitio/models/CustomMetaDataTypeEnum.py | 11 - .../models/CustomMetadataFieldsSchema.py | 31 - .../models/DeleteFolderRequestOptions.py | 4 - .../models/ListAndSearchFileRequestOptions.py | 31 - imagekitio/models/MoveFileRequestOptions.py | 6 - imagekitio/models/MoveFolderRequestOptions.py | 6 - imagekitio/models/RenameFileRequestOptions.py | 10 - ...pdateCustomMetadataFieldsRequestOptions.py | 30 - imagekitio/models/UpdateFileRequestOptions.py | 29 - imagekitio/models/UploadFileRequestOptions.py | 56 - imagekitio/models/__init__.py | 0 imagekitio/models/results/AITags.py | 10 - .../models/results/BulkDeleteFileResult.py | 20 - .../results/CustomMetadataFieldsResult.py | 23 - ...etadataFieldsResultWithResponseMetadata.py | 18 - .../models/results/CustomMetadataSchema.py | 27 - imagekitio/models/results/EmbeddedMetadata.py | 15 - imagekitio/models/results/FileResult.py | 66 - .../results/FileResultWithResponseMetadata.py | 61 - imagekitio/models/results/FolderResult.py | 20 - .../models/results/GetBulkJobStatusResult.py | 22 - .../models/results/GetMetadataResult.py | 51 - .../results/ListCustomMetadataFieldsResult.py | 18 - imagekitio/models/results/ListFileResult.py | 17 - imagekitio/models/results/MetadataExif.py | 105 - imagekitio/models/results/MetadataExifExif.py | 65 - imagekitio/models/results/MetadataExifGPS.py | 14 - .../models/results/MetadataExifImage.py | 31 - .../results/MetadataExifInteroperability.py | 9 - .../models/results/MetadataExifThumbnail.py | 21 - imagekitio/models/results/PurgeCacheResult.py | 15 - .../models/results/PurgeCacheStatusResult.py | 15 - imagekitio/models/results/RenameFileResult.py | 15 - imagekitio/models/results/ResponseMetadata.py | 9 - .../models/results/ResponseMetadataResult.py | 14 - imagekitio/models/results/TagsResult.py | 19 - imagekitio/models/results/UploadFileResult.py | 76 - imagekitio/models/results/VersionInfo.py | 13 - imagekitio/models/results/__init__.py | 0 imagekitio/resource.py | 90 - imagekitio/url.py | 231 -- imagekitio/utils/__init__.py | 0 imagekitio/utils/calculation.py | 40 - imagekitio/utils/formatter.py | 46 - imagekitio/utils/utils.py | 104 - noxfile.py | 9 + pyproject.toml | 270 ++ requirements-dev.lock | 162 + requirements.lock | 92 + requirements/requirements.txt | 8 - requirements/test.txt | 23 - scripts/bootstrap | 27 + scripts/format | 8 + scripts/lint | 11 + scripts/mock | 41 + scripts/test | 61 + scripts/utils/ruffen-docs.py | 167 + scripts/utils/upload-artifact.sh | 27 + setup.py | 24 - src/imagekit/lib/.keep | 4 + src/imagekitio/__init__.py | 104 + src/imagekitio/_base_client.py | 1995 +++++++++++ src/imagekitio/_client.py | 555 +++ src/imagekitio/_compat.py | 219 ++ src/imagekitio/_constants.py | 14 + src/imagekitio/_exceptions.py | 112 + src/imagekitio/_files.py | 123 + src/imagekitio/_models.py | 857 +++++ src/imagekitio/_qs.py | 150 + src/imagekitio/_resource.py | 43 + src/imagekitio/_response.py | 832 +++++ src/imagekitio/_streaming.py | 333 ++ src/imagekitio/_types.py | 261 ++ src/imagekitio/_utils/__init__.py | 64 + src/imagekitio/_utils/_compat.py | 45 + src/imagekitio/_utils/_datetime_parse.py | 136 + src/imagekitio/_utils/_logs.py | 25 + src/imagekitio/_utils/_proxy.py | 65 + src/imagekitio/_utils/_reflection.py | 42 + src/imagekitio/_utils/_resources_proxy.py | 24 + src/imagekitio/_utils/_streams.py | 12 + src/imagekitio/_utils/_sync.py | 58 + src/imagekitio/_utils/_transform.py | 457 +++ src/imagekitio/_utils/_typing.py | 156 + src/imagekitio/_utils/_utils.py | 421 +++ src/imagekitio/_version.py | 4 + src/imagekitio/lib/.keep | 4 + src/imagekitio/lib/__init__.py | 11 + src/imagekitio/lib/helper.py | 808 +++++ src/imagekitio/lib/serialization_utils.py | 47 + .../__init__.py => src/imagekitio/py.typed | 0 src/imagekitio/resources/__init__.py | 126 + src/imagekitio/resources/accounts/__init__.py | 61 + src/imagekitio/resources/accounts/accounts.py | 166 + src/imagekitio/resources/accounts/origins.py | 2233 ++++++++++++ .../resources/accounts/url_endpoints.py | 594 ++++ src/imagekitio/resources/accounts/usage.py | 206 ++ src/imagekitio/resources/assets.py | 325 ++ src/imagekitio/resources/beta/__init__.py | 33 + src/imagekitio/resources/beta/beta.py | 102 + src/imagekitio/resources/beta/v2/__init__.py | 33 + src/imagekitio/resources/beta/v2/files.py | 580 +++ src/imagekitio/resources/beta/v2/v2.py | 102 + src/imagekitio/resources/cache/__init__.py | 33 + src/imagekitio/resources/cache/cache.py | 102 + .../resources/cache/invalidation.py | 252 ++ .../resources/custom_metadata_fields.py | 535 +++ src/imagekitio/resources/dummy.py | 345 ++ src/imagekitio/resources/files/__init__.py | 61 + src/imagekitio/resources/files/bulk.py | 488 +++ src/imagekitio/resources/files/files.py | 1574 +++++++++ src/imagekitio/resources/files/metadata.py | 263 ++ src/imagekitio/resources/files/versions.py | 425 +++ src/imagekitio/resources/folders/__init__.py | 33 + src/imagekitio/resources/folders/folders.py | 713 ++++ src/imagekitio/resources/folders/job.py | 163 + src/imagekitio/resources/webhooks.py | 93 + src/imagekitio/types/__init__.py | 83 + src/imagekitio/types/accounts/__init__.py | 15 + .../types/accounts/origin_create_params.py | 208 ++ .../types/accounts/origin_list_response.py | 10 + .../types/accounts/origin_request_param.py | 208 ++ .../types/accounts/origin_response.py | 224 ++ .../types/accounts/origin_update_params.py | 208 ++ .../accounts/url_endpoint_create_params.py | 50 + .../accounts/url_endpoint_list_response.py | 10 + .../types/accounts/url_endpoint_response.py | 61 + .../accounts/url_endpoint_update_params.py | 50 + .../types/accounts/usage_get_params.py | 27 + .../types/accounts/usage_get_response.py | 26 + src/imagekitio/types/asset_list_params.py | 81 + src/imagekitio/types/asset_list_response.py | 13 + src/imagekitio/types/base_webhook_event.py | 13 + src/imagekitio/types/beta/__init__.py | 3 + src/imagekitio/types/beta/v2/__init__.py | 6 + .../types/beta/v2/file_upload_params.py | 273 ++ .../types/beta/v2/file_upload_response.py | 257 ++ src/imagekitio/types/cache/__init__.py | 7 + .../types/cache/invalidation_create_params.py | 12 + .../cache/invalidation_create_response.py | 17 + .../types/cache/invalidation_get_response.py | 13 + src/imagekitio/types/custom_metadata_field.py | 77 + .../custom_metadata_field_create_params.py | 85 + .../custom_metadata_field_delete_response.py | 9 + .../custom_metadata_field_list_params.py | 22 + .../custom_metadata_field_list_response.py | 10 + .../custom_metadata_field_update_params.py | 88 + src/imagekitio/types/dummy_create_params.py | 124 + src/imagekitio/types/file.py | 192 + src/imagekitio/types/file_copy_params.py | 24 + src/imagekitio/types/file_copy_response.py | 9 + src/imagekitio/types/file_move_params.py | 17 + src/imagekitio/types/file_move_response.py | 9 + src/imagekitio/types/file_rename_params.py | 42 + src/imagekitio/types/file_rename_response.py | 17 + src/imagekitio/types/file_update_params.py | 85 + src/imagekitio/types/file_update_response.py | 33 + src/imagekitio/types/file_upload_params.py | 305 ++ src/imagekitio/types/file_upload_response.py | 257 ++ src/imagekitio/types/files/__init__.py | 15 + .../types/files/bulk_add_tags_params.py | 18 + .../types/files/bulk_add_tags_response.py | 14 + .../types/files/bulk_delete_params.py | 15 + .../types/files/bulk_delete_response.py | 14 + .../types/files/bulk_remove_ai_tags_params.py | 18 + .../files/bulk_remove_ai_tags_response.py | 14 + .../types/files/bulk_remove_tags_params.py | 18 + .../types/files/bulk_remove_tags_response.py | 14 + .../files/metadata_get_from_url_params.py | 15 + .../types/files/version_delete_response.py | 9 + .../types/files/version_list_response.py | 10 + src/imagekitio/types/folder.py | 42 + src/imagekitio/types/folder_copy_params.py | 27 + src/imagekitio/types/folder_copy_response.py | 17 + src/imagekitio/types/folder_create_params.py | 30 + .../types/folder_create_response.py | 9 + src/imagekitio/types/folder_delete_params.py | 14 + .../types/folder_delete_response.py | 9 + src/imagekitio/types/folder_move_params.py | 20 + src/imagekitio/types/folder_move_response.py | 17 + src/imagekitio/types/folder_rename_params.py | 40 + .../types/folder_rename_response.py | 17 + src/imagekitio/types/folders/__init__.py | 5 + .../types/folders/job_get_response.py | 28 + src/imagekitio/types/metadata.py | 185 + src/imagekitio/types/shared/__init__.py | 21 + src/imagekitio/types/shared/base_overlay.py | 15 + src/imagekitio/types/shared/extensions.py | 78 + .../shared/get_image_attributes_options.py | 59 + src/imagekitio/types/shared/image_overlay.py | 38 + src/imagekitio/types/shared/overlay.py | 31 + .../types/shared/overlay_position.py | 36 + src/imagekitio/types/shared/overlay_timing.py | 34 + .../shared/responsive_image_attributes.py | 34 + .../types/shared/solid_color_overlay.py | 30 + .../solid_color_overlay_transformation.py | 53 + src/imagekitio/types/shared/src_options.py | 82 + .../types/shared/streaming_resolution.py | 7 + .../types/shared/subtitle_overlay.py | 32 + .../shared/subtitle_overlay_transformation.py | 80 + src/imagekitio/types/shared/text_overlay.py | 35 + .../shared/text_overlay_transformation.py | 99 + src/imagekitio/types/shared/transformation.py | 434 +++ .../types/shared/transformation_position.py | 7 + src/imagekitio/types/shared/video_overlay.py | 36 + .../types/shared_params/__init__.py | 21 + .../types/shared_params/base_overlay.py | 16 + .../types/shared_params/extensions.py | 76 + .../get_image_attributes_options.py | 59 + .../types/shared_params/image_overlay.py | 38 + src/imagekitio/types/shared_params/overlay.py | 23 + .../types/shared_params/overlay_position.py | 34 + .../types/shared_params/overlay_timing.py | 35 + .../responsive_image_attributes.py | 34 + .../shared_params/solid_color_overlay.py | 32 + .../solid_color_overlay_transformation.py | 53 + .../types/shared_params/src_options.py | 81 + .../shared_params/streaming_resolution.py | 9 + .../types/shared_params/subtitle_overlay.py | 34 + .../subtitle_overlay_transformation.py | 79 + .../types/shared_params/text_overlay.py | 37 + .../text_overlay_transformation.py | 99 + .../types/shared_params/transformation.py | 432 +++ .../shared_params/transformation_position.py | 9 + .../types/shared_params/video_overlay.py | 36 + .../types/unsafe_unwrap_webhook_event.py | 28 + src/imagekitio/types/unwrap_webhook_event.py | 28 + .../types/update_file_request_param.py | 85 + .../upload_post_transform_error_event.py | 78 + .../upload_post_transform_success_event.py | 62 + .../types/upload_pre_transform_error_event.py | 58 + .../upload_pre_transform_success_event.py | 294 ++ .../video_transformation_accepted_event.py | 103 + .../types/video_transformation_error_event.py | 116 + .../types/video_transformation_ready_event.py | 147 + tests/__init__.py | 1 + tests/api_resources/__init__.py | 1 + tests/api_resources/accounts/__init__.py | 1 + tests/api_resources/accounts/test_origins.py | 2432 +++++++++++++ .../accounts/test_url_endpoints.py | 469 +++ tests/api_resources/accounts/test_usage.py | 99 + tests/api_resources/beta/__init__.py | 1 + tests/api_resources/beta/v2/__init__.py | 1 + tests/api_resources/beta/v2/test_files.py | 216 ++ tests/api_resources/cache/__init__.py | 1 + .../api_resources/cache/test_invalidation.py | 176 + tests/api_resources/files/__init__.py | 1 + tests/api_resources/files/test_bulk.py | 319 ++ tests/api_resources/files/test_metadata.py | 176 + tests/api_resources/files/test_versions.py | 421 +++ tests/api_resources/folders/__init__.py | 1 + tests/api_resources/folders/test_job.py | 108 + tests/api_resources/test_assets.py | 108 + .../test_custom_metadata_fields.py | 424 +++ tests/api_resources/test_dummy.py | 1444 ++++++++ tests/api_resources/test_files.py | 913 +++++ tests/api_resources/test_folders.py | 434 +++ tests/api_resources/test_webhooks.py | 79 + tests/conftest.py | 91 + tests/custom/__init__.py | 2 + tests/custom/test_helper_authentication.py | 114 + tests/custom/test_serialization_utils.py | 228 ++ tests/custom/url_generation/__init__.py | 1 + .../test_advanced_url_generation.py | 281 ++ .../test_basic_url_generation.py | 261 ++ .../test_build_transformation_string.py | 76 + tests/custom/url_generation/test_overlay.py | 405 +++ tests/custom/url_generation/test_signing.py | 168 + tests/dummy_data/__init__.py | 3 - tests/dummy_data/file.py | 60 - tests/dummy_data/image.png | Bin 1128 -> 0 bytes tests/dummy_data/urls.py | 4 - tests/helpers.py | 63 - tests/sample.jpg | Bin 102117 -> 0 bytes tests/sample_file.txt | 1 + tests/test_client.py | 1907 +++++++++- tests/test_custom_metadata_fields_ops.py | 990 ------ tests/test_deepcopy.py | 58 + tests/test_extract_files.py | 64 + tests/test_files.py | 51 + tests/test_files_ops.py | 3120 ----------------- tests/test_folder_ops.py | 576 --- tests/test_generate_url.py | 512 --- tests/test_models.py | 963 +++++ tests/test_models_results.py | 257 -- tests/test_qs.py | 78 + tests/test_required_args.py | 111 + tests/test_response.py | 277 ++ tests/test_streaming.py | 248 ++ tests/test_tags_ops.py | 337 -- tests/test_transform.py | 460 +++ tests/test_utils/test_datetime_parse.py | 110 + tests/test_utils/test_proxy.py | 34 + tests/test_utils/test_typing.py | 73 + tests/test_utils_calculation.py | 18 - tests/test_utils_formatter.py | 21 - tests/test_utils_utils.py | 48 - tests/utils.py | 167 + tox.ini | 10 - 341 files changed, 41332 insertions(+), 10479 deletions(-) delete mode 100644 .coveragerc create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json delete mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/publish.yml delete mode 100644 .github/workflows/test.yml create mode 100644 .python-version create mode 100644 .stats.yml create mode 100644 .vscode/settings.json create mode 100644 Brewfile create mode 100644 CONTRIBUTING.md delete mode 100644 DEVELOPMENT.md create mode 100644 SECURITY.md create mode 100644 api.md create mode 100644 bin/publish-pypi create mode 100644 examples/.keep delete mode 100644 imagekitio/__init__.py delete mode 100644 imagekitio/client.py delete mode 100644 imagekitio/constants/defaults.py delete mode 100644 imagekitio/constants/errors.py delete mode 100644 imagekitio/constants/files.py delete mode 100644 imagekitio/constants/supported_transform.py delete mode 100644 imagekitio/constants/url.py delete mode 100644 imagekitio/exceptions/BadRequestException.py delete mode 100644 imagekitio/exceptions/ConflictException.py delete mode 100644 imagekitio/exceptions/ForbiddenException.py delete mode 100644 imagekitio/exceptions/InternalServerException.py delete mode 100644 imagekitio/exceptions/NotFoundException.py delete mode 100644 imagekitio/exceptions/PartialSuccessException.py delete mode 100644 imagekitio/exceptions/TooManyRequestsException.py delete mode 100644 imagekitio/exceptions/UnauthorizedException.py delete mode 100644 imagekitio/exceptions/UnknownException.py delete mode 100644 imagekitio/exceptions/__init__.py delete mode 100644 imagekitio/file.py delete mode 100644 imagekitio/models/CopyFileRequestOptions.py delete mode 100644 imagekitio/models/CopyFolderRequestOptions.py delete mode 100644 imagekitio/models/CreateCustomMetadataFieldsRequestOptions.py delete mode 100644 imagekitio/models/CreateFolderRequestOptions.py delete mode 100644 imagekitio/models/CustomMetaDataTypeEnum.py delete mode 100644 imagekitio/models/CustomMetadataFieldsSchema.py delete mode 100644 imagekitio/models/DeleteFolderRequestOptions.py delete mode 100644 imagekitio/models/ListAndSearchFileRequestOptions.py delete mode 100644 imagekitio/models/MoveFileRequestOptions.py delete mode 100644 imagekitio/models/MoveFolderRequestOptions.py delete mode 100644 imagekitio/models/RenameFileRequestOptions.py delete mode 100644 imagekitio/models/UpdateCustomMetadataFieldsRequestOptions.py delete mode 100644 imagekitio/models/UpdateFileRequestOptions.py delete mode 100644 imagekitio/models/UploadFileRequestOptions.py delete mode 100644 imagekitio/models/__init__.py delete mode 100644 imagekitio/models/results/AITags.py delete mode 100644 imagekitio/models/results/BulkDeleteFileResult.py delete mode 100644 imagekitio/models/results/CustomMetadataFieldsResult.py delete mode 100644 imagekitio/models/results/CustomMetadataFieldsResultWithResponseMetadata.py delete mode 100644 imagekitio/models/results/CustomMetadataSchema.py delete mode 100644 imagekitio/models/results/EmbeddedMetadata.py delete mode 100644 imagekitio/models/results/FileResult.py delete mode 100644 imagekitio/models/results/FileResultWithResponseMetadata.py delete mode 100644 imagekitio/models/results/FolderResult.py delete mode 100644 imagekitio/models/results/GetBulkJobStatusResult.py delete mode 100644 imagekitio/models/results/GetMetadataResult.py delete mode 100644 imagekitio/models/results/ListCustomMetadataFieldsResult.py delete mode 100644 imagekitio/models/results/ListFileResult.py delete mode 100644 imagekitio/models/results/MetadataExif.py delete mode 100644 imagekitio/models/results/MetadataExifExif.py delete mode 100644 imagekitio/models/results/MetadataExifGPS.py delete mode 100644 imagekitio/models/results/MetadataExifImage.py delete mode 100644 imagekitio/models/results/MetadataExifInteroperability.py delete mode 100644 imagekitio/models/results/MetadataExifThumbnail.py delete mode 100644 imagekitio/models/results/PurgeCacheResult.py delete mode 100644 imagekitio/models/results/PurgeCacheStatusResult.py delete mode 100644 imagekitio/models/results/RenameFileResult.py delete mode 100644 imagekitio/models/results/ResponseMetadata.py delete mode 100644 imagekitio/models/results/ResponseMetadataResult.py delete mode 100644 imagekitio/models/results/TagsResult.py delete mode 100644 imagekitio/models/results/UploadFileResult.py delete mode 100644 imagekitio/models/results/VersionInfo.py delete mode 100644 imagekitio/models/results/__init__.py delete mode 100644 imagekitio/resource.py delete mode 100644 imagekitio/url.py delete mode 100644 imagekitio/utils/__init__.py delete mode 100644 imagekitio/utils/calculation.py delete mode 100644 imagekitio/utils/formatter.py delete mode 100644 imagekitio/utils/utils.py create mode 100644 noxfile.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.lock create mode 100644 requirements.lock delete mode 100644 requirements/requirements.txt delete mode 100644 requirements/test.txt create mode 100755 scripts/bootstrap create mode 100755 scripts/format create mode 100755 scripts/lint create mode 100755 scripts/mock create mode 100755 scripts/test create mode 100644 scripts/utils/ruffen-docs.py create mode 100755 scripts/utils/upload-artifact.sh delete mode 100644 setup.py create mode 100644 src/imagekit/lib/.keep create mode 100644 src/imagekitio/__init__.py create mode 100644 src/imagekitio/_base_client.py create mode 100644 src/imagekitio/_client.py create mode 100644 src/imagekitio/_compat.py create mode 100644 src/imagekitio/_constants.py create mode 100644 src/imagekitio/_exceptions.py create mode 100644 src/imagekitio/_files.py create mode 100644 src/imagekitio/_models.py create mode 100644 src/imagekitio/_qs.py create mode 100644 src/imagekitio/_resource.py create mode 100644 src/imagekitio/_response.py create mode 100644 src/imagekitio/_streaming.py create mode 100644 src/imagekitio/_types.py create mode 100644 src/imagekitio/_utils/__init__.py create mode 100644 src/imagekitio/_utils/_compat.py create mode 100644 src/imagekitio/_utils/_datetime_parse.py create mode 100644 src/imagekitio/_utils/_logs.py create mode 100644 src/imagekitio/_utils/_proxy.py create mode 100644 src/imagekitio/_utils/_reflection.py create mode 100644 src/imagekitio/_utils/_resources_proxy.py create mode 100644 src/imagekitio/_utils/_streams.py create mode 100644 src/imagekitio/_utils/_sync.py create mode 100644 src/imagekitio/_utils/_transform.py create mode 100644 src/imagekitio/_utils/_typing.py create mode 100644 src/imagekitio/_utils/_utils.py create mode 100644 src/imagekitio/_version.py create mode 100644 src/imagekitio/lib/.keep create mode 100644 src/imagekitio/lib/__init__.py create mode 100644 src/imagekitio/lib/helper.py create mode 100644 src/imagekitio/lib/serialization_utils.py rename imagekitio/constants/__init__.py => src/imagekitio/py.typed (100%) create mode 100644 src/imagekitio/resources/__init__.py create mode 100644 src/imagekitio/resources/accounts/__init__.py create mode 100644 src/imagekitio/resources/accounts/accounts.py create mode 100644 src/imagekitio/resources/accounts/origins.py create mode 100644 src/imagekitio/resources/accounts/url_endpoints.py create mode 100644 src/imagekitio/resources/accounts/usage.py create mode 100644 src/imagekitio/resources/assets.py create mode 100644 src/imagekitio/resources/beta/__init__.py create mode 100644 src/imagekitio/resources/beta/beta.py create mode 100644 src/imagekitio/resources/beta/v2/__init__.py create mode 100644 src/imagekitio/resources/beta/v2/files.py create mode 100644 src/imagekitio/resources/beta/v2/v2.py create mode 100644 src/imagekitio/resources/cache/__init__.py create mode 100644 src/imagekitio/resources/cache/cache.py create mode 100644 src/imagekitio/resources/cache/invalidation.py create mode 100644 src/imagekitio/resources/custom_metadata_fields.py create mode 100644 src/imagekitio/resources/dummy.py create mode 100644 src/imagekitio/resources/files/__init__.py create mode 100644 src/imagekitio/resources/files/bulk.py create mode 100644 src/imagekitio/resources/files/files.py create mode 100644 src/imagekitio/resources/files/metadata.py create mode 100644 src/imagekitio/resources/files/versions.py create mode 100644 src/imagekitio/resources/folders/__init__.py create mode 100644 src/imagekitio/resources/folders/folders.py create mode 100644 src/imagekitio/resources/folders/job.py create mode 100644 src/imagekitio/resources/webhooks.py create mode 100644 src/imagekitio/types/__init__.py create mode 100644 src/imagekitio/types/accounts/__init__.py create mode 100644 src/imagekitio/types/accounts/origin_create_params.py create mode 100644 src/imagekitio/types/accounts/origin_list_response.py create mode 100644 src/imagekitio/types/accounts/origin_request_param.py create mode 100644 src/imagekitio/types/accounts/origin_response.py create mode 100644 src/imagekitio/types/accounts/origin_update_params.py create mode 100644 src/imagekitio/types/accounts/url_endpoint_create_params.py create mode 100644 src/imagekitio/types/accounts/url_endpoint_list_response.py create mode 100644 src/imagekitio/types/accounts/url_endpoint_response.py create mode 100644 src/imagekitio/types/accounts/url_endpoint_update_params.py create mode 100644 src/imagekitio/types/accounts/usage_get_params.py create mode 100644 src/imagekitio/types/accounts/usage_get_response.py create mode 100644 src/imagekitio/types/asset_list_params.py create mode 100644 src/imagekitio/types/asset_list_response.py create mode 100644 src/imagekitio/types/base_webhook_event.py create mode 100644 src/imagekitio/types/beta/__init__.py create mode 100644 src/imagekitio/types/beta/v2/__init__.py create mode 100644 src/imagekitio/types/beta/v2/file_upload_params.py create mode 100644 src/imagekitio/types/beta/v2/file_upload_response.py create mode 100644 src/imagekitio/types/cache/__init__.py create mode 100644 src/imagekitio/types/cache/invalidation_create_params.py create mode 100644 src/imagekitio/types/cache/invalidation_create_response.py create mode 100644 src/imagekitio/types/cache/invalidation_get_response.py create mode 100644 src/imagekitio/types/custom_metadata_field.py create mode 100644 src/imagekitio/types/custom_metadata_field_create_params.py create mode 100644 src/imagekitio/types/custom_metadata_field_delete_response.py create mode 100644 src/imagekitio/types/custom_metadata_field_list_params.py create mode 100644 src/imagekitio/types/custom_metadata_field_list_response.py create mode 100644 src/imagekitio/types/custom_metadata_field_update_params.py create mode 100644 src/imagekitio/types/dummy_create_params.py create mode 100644 src/imagekitio/types/file.py create mode 100644 src/imagekitio/types/file_copy_params.py create mode 100644 src/imagekitio/types/file_copy_response.py create mode 100644 src/imagekitio/types/file_move_params.py create mode 100644 src/imagekitio/types/file_move_response.py create mode 100644 src/imagekitio/types/file_rename_params.py create mode 100644 src/imagekitio/types/file_rename_response.py create mode 100644 src/imagekitio/types/file_update_params.py create mode 100644 src/imagekitio/types/file_update_response.py create mode 100644 src/imagekitio/types/file_upload_params.py create mode 100644 src/imagekitio/types/file_upload_response.py create mode 100644 src/imagekitio/types/files/__init__.py create mode 100644 src/imagekitio/types/files/bulk_add_tags_params.py create mode 100644 src/imagekitio/types/files/bulk_add_tags_response.py create mode 100644 src/imagekitio/types/files/bulk_delete_params.py create mode 100644 src/imagekitio/types/files/bulk_delete_response.py create mode 100644 src/imagekitio/types/files/bulk_remove_ai_tags_params.py create mode 100644 src/imagekitio/types/files/bulk_remove_ai_tags_response.py create mode 100644 src/imagekitio/types/files/bulk_remove_tags_params.py create mode 100644 src/imagekitio/types/files/bulk_remove_tags_response.py create mode 100644 src/imagekitio/types/files/metadata_get_from_url_params.py create mode 100644 src/imagekitio/types/files/version_delete_response.py create mode 100644 src/imagekitio/types/files/version_list_response.py create mode 100644 src/imagekitio/types/folder.py create mode 100644 src/imagekitio/types/folder_copy_params.py create mode 100644 src/imagekitio/types/folder_copy_response.py create mode 100644 src/imagekitio/types/folder_create_params.py create mode 100644 src/imagekitio/types/folder_create_response.py create mode 100644 src/imagekitio/types/folder_delete_params.py create mode 100644 src/imagekitio/types/folder_delete_response.py create mode 100644 src/imagekitio/types/folder_move_params.py create mode 100644 src/imagekitio/types/folder_move_response.py create mode 100644 src/imagekitio/types/folder_rename_params.py create mode 100644 src/imagekitio/types/folder_rename_response.py create mode 100644 src/imagekitio/types/folders/__init__.py create mode 100644 src/imagekitio/types/folders/job_get_response.py create mode 100644 src/imagekitio/types/metadata.py create mode 100644 src/imagekitio/types/shared/__init__.py create mode 100644 src/imagekitio/types/shared/base_overlay.py create mode 100644 src/imagekitio/types/shared/extensions.py create mode 100644 src/imagekitio/types/shared/get_image_attributes_options.py create mode 100644 src/imagekitio/types/shared/image_overlay.py create mode 100644 src/imagekitio/types/shared/overlay.py create mode 100644 src/imagekitio/types/shared/overlay_position.py create mode 100644 src/imagekitio/types/shared/overlay_timing.py create mode 100644 src/imagekitio/types/shared/responsive_image_attributes.py create mode 100644 src/imagekitio/types/shared/solid_color_overlay.py create mode 100644 src/imagekitio/types/shared/solid_color_overlay_transformation.py create mode 100644 src/imagekitio/types/shared/src_options.py create mode 100644 src/imagekitio/types/shared/streaming_resolution.py create mode 100644 src/imagekitio/types/shared/subtitle_overlay.py create mode 100644 src/imagekitio/types/shared/subtitle_overlay_transformation.py create mode 100644 src/imagekitio/types/shared/text_overlay.py create mode 100644 src/imagekitio/types/shared/text_overlay_transformation.py create mode 100644 src/imagekitio/types/shared/transformation.py create mode 100644 src/imagekitio/types/shared/transformation_position.py create mode 100644 src/imagekitio/types/shared/video_overlay.py create mode 100644 src/imagekitio/types/shared_params/__init__.py create mode 100644 src/imagekitio/types/shared_params/base_overlay.py create mode 100644 src/imagekitio/types/shared_params/extensions.py create mode 100644 src/imagekitio/types/shared_params/get_image_attributes_options.py create mode 100644 src/imagekitio/types/shared_params/image_overlay.py create mode 100644 src/imagekitio/types/shared_params/overlay.py create mode 100644 src/imagekitio/types/shared_params/overlay_position.py create mode 100644 src/imagekitio/types/shared_params/overlay_timing.py create mode 100644 src/imagekitio/types/shared_params/responsive_image_attributes.py create mode 100644 src/imagekitio/types/shared_params/solid_color_overlay.py create mode 100644 src/imagekitio/types/shared_params/solid_color_overlay_transformation.py create mode 100644 src/imagekitio/types/shared_params/src_options.py create mode 100644 src/imagekitio/types/shared_params/streaming_resolution.py create mode 100644 src/imagekitio/types/shared_params/subtitle_overlay.py create mode 100644 src/imagekitio/types/shared_params/subtitle_overlay_transformation.py create mode 100644 src/imagekitio/types/shared_params/text_overlay.py create mode 100644 src/imagekitio/types/shared_params/text_overlay_transformation.py create mode 100644 src/imagekitio/types/shared_params/transformation.py create mode 100644 src/imagekitio/types/shared_params/transformation_position.py create mode 100644 src/imagekitio/types/shared_params/video_overlay.py create mode 100644 src/imagekitio/types/unsafe_unwrap_webhook_event.py create mode 100644 src/imagekitio/types/unwrap_webhook_event.py create mode 100644 src/imagekitio/types/update_file_request_param.py create mode 100644 src/imagekitio/types/upload_post_transform_error_event.py create mode 100644 src/imagekitio/types/upload_post_transform_success_event.py create mode 100644 src/imagekitio/types/upload_pre_transform_error_event.py create mode 100644 src/imagekitio/types/upload_pre_transform_success_event.py create mode 100644 src/imagekitio/types/video_transformation_accepted_event.py create mode 100644 src/imagekitio/types/video_transformation_error_event.py create mode 100644 src/imagekitio/types/video_transformation_ready_event.py create mode 100644 tests/api_resources/__init__.py create mode 100644 tests/api_resources/accounts/__init__.py create mode 100644 tests/api_resources/accounts/test_origins.py create mode 100644 tests/api_resources/accounts/test_url_endpoints.py create mode 100644 tests/api_resources/accounts/test_usage.py create mode 100644 tests/api_resources/beta/__init__.py create mode 100644 tests/api_resources/beta/v2/__init__.py create mode 100644 tests/api_resources/beta/v2/test_files.py create mode 100644 tests/api_resources/cache/__init__.py create mode 100644 tests/api_resources/cache/test_invalidation.py create mode 100644 tests/api_resources/files/__init__.py create mode 100644 tests/api_resources/files/test_bulk.py create mode 100644 tests/api_resources/files/test_metadata.py create mode 100644 tests/api_resources/files/test_versions.py create mode 100644 tests/api_resources/folders/__init__.py create mode 100644 tests/api_resources/folders/test_job.py create mode 100644 tests/api_resources/test_assets.py create mode 100644 tests/api_resources/test_custom_metadata_fields.py create mode 100644 tests/api_resources/test_dummy.py create mode 100644 tests/api_resources/test_files.py create mode 100644 tests/api_resources/test_folders.py create mode 100644 tests/api_resources/test_webhooks.py create mode 100644 tests/conftest.py create mode 100644 tests/custom/__init__.py create mode 100644 tests/custom/test_helper_authentication.py create mode 100644 tests/custom/test_serialization_utils.py create mode 100644 tests/custom/url_generation/__init__.py create mode 100644 tests/custom/url_generation/test_advanced_url_generation.py create mode 100644 tests/custom/url_generation/test_basic_url_generation.py create mode 100644 tests/custom/url_generation/test_build_transformation_string.py create mode 100644 tests/custom/url_generation/test_overlay.py create mode 100644 tests/custom/url_generation/test_signing.py delete mode 100644 tests/dummy_data/__init__.py delete mode 100644 tests/dummy_data/file.py delete mode 100644 tests/dummy_data/image.png delete mode 100644 tests/dummy_data/urls.py delete mode 100644 tests/helpers.py delete mode 100644 tests/sample.jpg create mode 100644 tests/sample_file.txt delete mode 100644 tests/test_custom_metadata_fields_ops.py create mode 100644 tests/test_deepcopy.py create mode 100644 tests/test_extract_files.py create mode 100644 tests/test_files.py delete mode 100644 tests/test_files_ops.py delete mode 100644 tests/test_folder_ops.py delete mode 100644 tests/test_generate_url.py create mode 100644 tests/test_models.py delete mode 100644 tests/test_models_results.py create mode 100644 tests/test_qs.py create mode 100644 tests/test_required_args.py create mode 100644 tests/test_response.py create mode 100644 tests/test_streaming.py delete mode 100644 tests/test_tags_ops.py create mode 100644 tests/test_transform.py create mode 100644 tests/test_utils/test_datetime_parse.py create mode 100644 tests/test_utils/test_proxy.py create mode 100644 tests/test_utils/test_typing.py delete mode 100644 tests/test_utils_calculation.py delete mode 100644 tests/test_utils_formatter.py delete mode 100644 tests/test_utils_utils.py create mode 100644 tests/utils.py delete mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 9ba0ad9e..00000000 --- a/.coveragerc +++ /dev/null @@ -1,6 +0,0 @@ -[run] -omit = - */site-packages/* - */distutils/* - tests/* - .tox/* diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..ff261bad --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +ARG VARIANT="3.9" +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +USER vscode + +RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.44.0" RYE_INSTALL_OPTION="--yes" bash +ENV PATH=/home/vscode/.rye/shims:$PATH + +RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..c17fdc16 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,43 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/debian +{ + "name": "Debian", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + + "postStartCommand": "rye sync --all-features", + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python" + ], + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": ".venv/bin/python", + "python.defaultInterpreterPath": ".venv/bin/python", + "python.typeChecking": "basic", + "terminal.integrated.env.linux": { + "PATH": "/home/vscode/.rye/shims:${env:PATH}" + } + } + } + }, + "features": { + "ghcr.io/devcontainers/features/node:1": {} + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index d265f239..00000000 --- a/.editorconfig +++ /dev/null @@ -1,17 +0,0 @@ -; https://editorconfig.org/ - -root = true - -[*] -indent_style = space -indent_size = 4 -insert_final_newline = true -trim_trailing_whitespace = true -end_of_line = lf -charset = utf-8 - -[*.{cfg, ini, json, toml, yml}] -indent_size = 2 - -[Makefile] -indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..76587286 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI +on: + push: + branches-ignore: + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' + +jobs: + lint: + timeout-minutes: 10 + name: lint + runs-on: ${{ github.repository == 'stainless-sdks/imagekit-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run lints + run: ./scripts/lint + + build: + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + timeout-minutes: 10 + name: build + permissions: + contents: read + id-token: write + runs-on: ${{ github.repository == 'stainless-sdks/imagekit-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run build + run: rye build + + - name: Get GitHub OIDC Token + if: github.repository == 'stainless-sdks/imagekit-python' + id: github-oidc + uses: actions/github-script@v6 + with: + script: core.setOutput('github_token', await core.getIDToken()); + + - name: Upload tarball + if: github.repository == 'stainless-sdks/imagekit-python' + env: + URL: https://pkg.stainless.com/s + AUTH: ${{ steps.github-oidc.outputs.github_token }} + SHA: ${{ github.sha }} + run: ./scripts/utils/upload-artifact.sh + + test: + timeout-minutes: 10 + name: test + runs-on: ${{ github.repository == 'stainless-sdks/imagekit-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Run tests + run: ./scripts/test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 31b31c7d..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Upload Python Package - -on: - release: - types: [published] - -jobs: - build: - runs-on: ubuntu-22.04 - strategy: - matrix: - python: [3.7, 3.8, 3.9, '3.10', '3.11', '3.12'] - urllib3-line: ["lt2", "ge2"] - exclude: - - python: "3.7" - urllib3-line: "ge2" - - python: "3.8" - urllib3-line: "ge2" - steps: - - uses: actions/checkout@v5 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} - - name: Install deps - run: | - pip install -r requirements/requirements.txt - if [ "${{ matrix.urllib3-line }}" = "lt2" ]; then - pip install "urllib3<2" - else - pip install "urllib3!=2.0.0,<3" - fi - pip install -e . - - name: Install Tox and any other packages - run: | - python -m pip install --upgrade pip - pip install -r requirements/test.txt - - name: Run Tox - run: tox -e py - release: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v5 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.12 - - name: Install build dependencies - run: | - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 773ed9fb..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Python CI - -on: [push, pull_request] - -jobs: - build: - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - python: [3.7, 3.8, 3.9, '3.10', '3.11', '3.12', '3.13'] - urllib3-line: ["lt2", "ge2"] - exclude: - - python: "3.7" - urllib3-line: "ge2" - - python: "3.8" - urllib3-line: "ge2" - steps: - - uses: actions/checkout@v5 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} - - name: Install deps - run: | - pip install -r requirements/test.txt - if [ "${{ matrix.urllib3-line }}" = "lt2" ]; then - pip install "urllib3<2" - else - pip install "urllib3!=2.0.0,<3" - fi - pip install -e . - - name: Run Tox - run: tox -e py - - name: Upload Coverage to codecov - run: bash <(curl -s https://codecov.io/bash) diff --git a/.gitignore b/.gitignore index 8992bc19..f382096f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,58 +1,17 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +.prism.log +_dev -# C extensions -*.so +__pycache__ +.mypy_cache -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -sample/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST +dist -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# pyenv -.python-version +.venv +.idea -# Environments .env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +.envrc +codegen.log +Brewfile.lock.json -# Visual Studio Code -.vscode/ -htmlcov/ +examples/temp \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..43077b24 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9.18 diff --git a/.stats.yml b/.stats.yml new file mode 100644 index 00000000..a0c1a4ea --- /dev/null +++ b/.stats.yml @@ -0,0 +1,4 @@ +configured_endpoints: 43 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/imagekit-inc%2Fimagekit-9d184cb502ab32a85db2889c796cdfebe812f2a55a604df79c85dd4b5e7e2add.yml +openapi_spec_hash: a9aa620376fce66532c84f9364209b0b +config_hash: fd112bd17c0c8e9f81a50d0e15ea70d6 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..c3a26d68 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.analysis.importFormat": "relative", + "python.analysis.typeCheckingMode": "basic" +} diff --git a/Brewfile b/Brewfile new file mode 100644 index 00000000..492ca37b --- /dev/null +++ b/Brewfile @@ -0,0 +1,2 @@ +brew "rye" + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..edd1f2b1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,128 @@ +## Setting up the environment + +### With Rye + +We use [Rye](https://rye.astral.sh/) to manage dependencies because it will automatically provision a Python environment with the expected Python version. To set it up, run: + +```sh +$ ./scripts/bootstrap +``` + +Or [install Rye manually](https://rye.astral.sh/guide/installation/) and run: + +```sh +$ rye sync --all-features +``` + +You can then run scripts using `rye run python script.py` or by activating the virtual environment: + +```sh +# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work +$ source .venv/bin/activate + +# now you can omit the `rye run` prefix +$ python script.py +``` + +### Without Rye + +Alternatively if you don't want to install `Rye`, you can stick with the standard `pip` setup by ensuring you have the Python version specified in `.python-version`, create a virtual environment however you desire and then install dependencies using this command: + +```sh +$ pip install -r requirements-dev.lock +``` + +## Modifying/Adding code + +Most of the SDK is generated code. Modifications to code will be persisted between generations, but may +result in merge conflicts between manual patches and changes from the generator. The generator will never +modify the contents of the `src/imagekitio/lib/` and `examples/` directories. + +## Adding and running examples + +All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. + +```py +# add an example to examples/.py + +#!/usr/bin/env -S rye run python +… +``` + +```sh +$ chmod +x examples/.py +# run the example against your api +$ ./examples/.py +``` + +## Using the repository from source + +If you’d like to use the repository from source, you can either install from git or link to a cloned repository: + +To install via git: + +```sh +$ pip install git+ssh://git@github.com/stainless-sdks/imagekit-python.git +``` + +Alternatively, you can build from source and install the wheel file: + +Building this package will create two files in the `dist/` directory, a `.tar.gz` containing the source files and a `.whl` that can be used to install the package efficiently. + +To create a distributable version of the library, all you have to do is run this command: + +```sh +$ rye build +# or +$ python -m build +``` + +Then to install: + +```sh +$ pip install ./path-to-wheel-file.whl +``` + +## Running tests + +Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. + +```sh +# you will need npm installed +$ npx prism mock path/to/your/openapi.yml +``` + +```sh +$ ./scripts/test +``` + +## Linting and formatting + +This repository uses [ruff](https://github.com/astral-sh/ruff) and +[black](https://github.com/psf/black) to format the code in the repository. + +To lint: + +```sh +$ ./scripts/lint +``` + +To format and fix all ruff issues automatically: + +```sh +$ ./scripts/format +``` + +## Publishing and releases + +Changes made to this repository via the automated release PR pipeline should publish to PyPI automatically. If +the changes aren't made through the automated pipeline, you may want to make releases manually. + +### Publish with a GitHub workflow + +You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/stainless-sdks/imagekit-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. + +### Publish manually + +If you need to manually release a package, you can run the `bin/publish-pypi` script with a `PYPI_TOKEN` set on +the environment. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 2f1ff7b2..00000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,22 +0,0 @@ -# Development Guide - -**1. Setup dependencies** - -```shell -pip install -r requirements/requirements.txt -``` - -**2. Run test cases** - -```shell -pip install -r requirements/test.txt -tox -e py -``` - -**3. Running the sample app** - -```shell -pip install -r sample/requirements.txt -cd sample -python sample.py -``` diff --git a/LICENSE b/LICENSE index ec2f41ca..e7a4d160 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License - -Copyright (c) 2019 Imagekit - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Image Kit + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 8e6acb52..c10409fa 100644 --- a/README.md +++ b/README.md @@ -1,1445 +1,860 @@ -[ImageKit.io](https://imagekit.io) - # ImageKit.io Python SDK -[![Python CI](https://github.com/imagekit-developer/imagekit-python/workflows/Python%20CI/badge.svg)](https://github.com/imagekit-developer/imagekit-python/) -[![imagekitio](https://img.shields.io/pypi/v/imagekitio.svg)](https://pypi.org/project/imagekitio) -[![codecov](https://codecov.io/gh/imagekit-developer/imagekit-python/branch/master/graph/badge.svg?token=CwKWqBIlCu)](https://codecov.io/gh/imagekit-developer/imagekit-python) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Twitter Follow](https://img.shields.io/twitter/follow/imagekitio?label=Follow&style=social)](https://twitter.com/ImagekitIo) - -Python SDK for [ImageKit](https://imagekit.io/) implements the new APIs and interface for different file operations. - -ImageKit is complete media storage, optimization, and transformation solution that comes with an [image and video CDN](https://imagekit.io/features/imagekit-infrastructure). It can be integrated with your existing infrastructure - storage like AWS S3, web servers, your CDN, and custom domain names, allowing you to deliver optimized images in minutes with minimal code changes. - -Supported Python Versions: >=3.6 - -Table of contents - - -- [Installation](#installation) -- [Initialization](#initialization) -- [Change Log](#change-log) -- [Usage](#usage) - - [URL Generation](#url-generation) - - [File Upload](#file-upload) - - [File Management](#file-management) - - [Utility Functions](#utility-functions) -- [Handling errors](#handling-errors) -- [Development](#development) - - [Tests](#tests) - - [Sample](#sample) -- [Support](#support) -- [Links](#links) + +[![PyPI version](https://img.shields.io/pypi/v/imagekitio.svg?label=pypi%20(stable))](https://pypi.org/project/imagekitio/) + +The ImageKit Python SDK provides convenient access to the ImageKit REST API from any Python 3.9+ application. It offers powerful tools for URL generation and transformation, signed URLs for secure content delivery, webhook verification, file uploads, and more. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). + +The REST API documentation can be found on [imagekit.io](https://imagekit.io/docs/api-reference). The full API of this library can be found in [api.md](api.md). + +## Table of Contents + +- [Installation](#installation) +- [Requirements](#requirements) +- [Usage](#usage) + - [Using types](#using-types) + - [Nested params](#nested-params) + - [Async usage](#async-usage) +- [URL generation](#url-generation) + - [Basic URL generation](#basic-url-generation) + - [URL generation with transformations](#url-generation-with-transformations) + - [URL generation with image overlay](#url-generation-with-image-overlay) + - [URL generation with text overlay](#url-generation-with-text-overlay) + - [URL generation with multiple overlays](#url-generation-with-multiple-overlays) + - [Signed URLs for secure delivery](#signed-urls-for-secure-delivery) + - [Using Raw transformations for undocumented features](#using-raw-transformations-for-undocumented-features) +- [Authentication parameters for client-side uploads](#authentication-parameters-for-client-side-uploads) +- [Webhook verification](#webhook-verification) +- [Advanced Usage](#advanced-usage) + - [File uploads](#file-uploads) + - [Handling errors](#handling-errors) + - [Retries](#retries) + - [Timeouts](#timeouts) + - [Logging](#logging) + - [Accessing raw response data](#accessing-raw-response-data-eg-headers) + - [Making custom/undocumented requests](#making-customundocumented-requests) + - [Configuring the HTTP client](#configuring-the-http-client) + - [Managing HTTP resources](#managing-http-resources) +- [Versioning](#versioning) +- [Contributing](#contributing) ## Installation -Go to your terminal and type the following command. - -```bash -pip install imagekitio -``` - -## Initialization - -```python -from imagekitio import ImageKit - -imagekit = ImageKit( - private_key='your_private_key', - public_key='your_public_key', - url_endpoint='your_url_endpoint' -) +```sh +# install from this staging repo +pip install git+ssh://git@github.com/stainless-sdks/imagekit-python.git ``` -## Change log - -This document presents a list of changes that break the existing functionality of previous versions. We try to minimize these disruptions, but they are sometimes unavoidable, especially in significant updates. Therefore, versions are marked semantically and tagged as major upgrades whenever such breaking changes occur. - -### Breaking History: - -Changes from `3.2.0 -> 4.0.0` are listed below - -1. Overlay syntax update - -* In version 4.0.0, we've removed the old overlay syntax parameters for transformations, such as `oi`, `ot`, `obg`, and [more](https://docs.imagekit.io/features/image-transformations/overlay). These parameters are deprecated and will start returning errors when used in URLs. Please migrate to the new layers syntax that supports overlay nesting, provides better positional control, and allows more transformations at the layer level. You can start with [examples](https://docs.imagekit.io/features/image-transformations/overlay-using-layers#examples) to learn quickly. -* You can migrate to the new layers syntax using the `raw` transformation parameter. - - -Changes from `2.2.8 -> 3.0.0` are listed below - -1. Throw an Error: +> [!NOTE] +> Once this package is [published to PyPI](https://www.stainless.com/docs/guides/publish), this will become: `pip install imagekitio` -**What changed** +## Usage -- Before the upgrade, an `error` dict was coming in the return object of any function call. Now, SDK throws an exception in case of an error. - -**Who is affected?** - -- This affects any development in your software that calls APIs from ImageKit IO and handles errors based on what's returned. - -**How should I update my code?** - -- To avoid failures in an application, you could handle errors as [documented here](#handling-errors) - -# Usage - -You can use this Python SDK for three different kinds of methods: - -- [URL Generation](#url-generation) -- [File Upload](#file-upload) -- [File Management](#file-management) -- [Utility Functions](#utility-functions) - -## URL Generation - -**1. Using Image path and endpoint (hostname)** - -This method allows you to create a URL using the relative file path where the image exists and the URL -endpoint(url_endpoint) you want to use to access the image. You can refer to the documentation -[here](https://docs.imagekit.io/integration/url-endpoints) to read more about URL endpoints -in ImageKit and the section about [image origins](https://docs.imagekit.io/integration/configure-origin) to understand -about paths with different kinds of origins. - -The file can be an image, video, or any other static file supported by ImageKit. +The full API of this library can be found in [api.md](api.md). ```python -imagekit_url = imagekit.url({ - "path": "/default-image.jpg", - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [{ - "height": "300", - "width": "400", - "raw": "ar-4-3,q-40" - }], -}) -``` - -Sample Result URL - - -``` -https://ik.imagekit.io/your_imagekit_id/endpoint/tr:h-300,w-400,ar-4-3,q-40/default-image.jpg -``` - -**2. Using full image URL** - -This method allows you to add transformation parameters to an absolute URL using the `src` parameter. This method should be -used if you have the complete image URL stored in your database. +import os +from imagekitio import ImageKit -```python -image_url = imagekit.url({ - "src": "https://ik.imagekit.io/your_imagekit_id/endpoint/default-image.jpg", - "transformation": [{ - "height": "300", - "width": "400", - "raw": "ar-4-3,q-40" - }] -}) -``` +client = ImageKit( + private_key=os.environ.get("IMAGEKIT_PRIVATE_KEY"), # This is the default and can be omitted +) -Sample Result URL - +# Upload a file +with open("/path/to/your/image.jpg", "rb") as f: + file_data = f.read() -``` -https://ik.imagekit.io/your_imagekit_id/endpoint/default-image.jpg?tr=h-300%2Cw-400%2Car-4-3%2Cq-40 +response = client.files.upload( + file=file_data, + file_name="uploaded-image.jpg", +) +print(response.file_id) +print(response.url) ``` -The `.url()` method accepts the following parameters. +While you can provide a `private_key` keyword argument, +we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) +to add `IMAGEKIT_PRIVATE_KEY="My Private Key"` to your `.env` file +so that your Private Key is not stored in source control. -| Option | Description | -| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| url_endpoint | Optional. The prepended base URL before the path of the image. If not specified, the URL Endpoint specified during SDK initialization gets used. For example, https://ik.imagekit.io/your_imagekit_id/endpoint/ | -| path | Conditional. A path at which the image exists. For example, `/path/to/image.jpg`. Specify a `path` or `src` parameter for URL generation. | -| src | Conditional. Complete URL of an image already mapped to ImageKit. For example, `https://ik.imagekit.io/your_imagekit_id/endpoint/path/to/image.jpg`. Specify a `path` or `src` parameter for URL generation. | -| transformation | Optional. Specify an array of objects with name and the value in key-value pair to apply transformation params in the URL. Append different steps of a [chained transformation](https://docs.imagekit.io/features/image-transformations/chained-transformations) as different objects of the array. This document includes a complete list of supported transformations in the SDK with some examples. If one uses an unspecified transformation name, it gets applied as it is in the URL. | -| transformation_position | Optional. The default value is `path`, which places the transformation string as a path parameter in the URL. One can also specify it as a query, which adds the transformation string as the query parameter `tr` in the URL. Suppose one uses the `src` parameter to create the URL. In that case, the transformation string is always a query parameter. | -| query_parameters | Optional. These are the other query parameters that one wants to add to the final URL. These can be any query parameters and are not necessarily related to ImageKit. Especially useful if one wants to add some versioning parameter to their URLs. | -| signed | Optional. Boolean. The default is `false`. If set to `true`, the SDK generates a signed image URL adding the image signature to the image URL. One can only use this if they create the URL with the `url_endpoint` and `path` parameters, not the `src` parameter. | -| expire_seconds | Optional. Integer. Used along with the `signed` parameter to specify the time in seconds from `now` when the URL should expire. If specified, the URL contains the expiry timestamp, and the image signature is modified accordingly. | -## Examples of generating URLs +### Using types -**1. Chained Transformations as a query parameter** +Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: -```python -image_url = imagekit.url({ - "path": "/default-image.jpg", - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [ - { - "height": "300", - "width": "400" - }, - { - "rotation": 90 - } - ], - "transformation_position": "query" -}) -``` +- Serializing back into JSON, `model.to_json()` +- Converting to a dictionary, `model.to_dict()` -Sample Result URL - +Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. -``` -https://ik.imagekit.io/your_imagekit_id/endpoint/default-image.jpg?tr=h-300%2Cw-400%3Art-90 -``` - -**2. Sharpening, contrast transform and progressive JPG image** +### Nested params -Add transformations like [Sharpening](https://docs.imagekit.io/features/image-transformations/image-enhancement-and-color-manipulation) to the URL with or without any other value. To use such transforms without specifying a value, set it as "-" in the transformation object. Otherwise, use the value that one wants to add to this transformation. +Nested parameters are dictionaries, typed using `TypedDict`, for example: ```python -image_url = imagekit.url({ - "src": "https://ik.imagekit.io/your_imagekit_id/endpoint/default-image.jpg", - "transformation": [{ - "format": "jpg", - "progressive": "true", - "effect_sharpen": "-", - "effect_contrast": "1" - }] -}) -``` - -Sample Result URL - - -``` -# Note that because the `src` parameter is in effect, the transformation string gets added as a query parameter `tr` - -https://ik.imagekit.io/your_imagekit_id/endpoint/default-image.jpg?tr=f-jpg%2Cpr-true%2Ce-sharpen%2Ce-contrast-1 -``` - -**3. Signed URL that expires in 300 seconds with the default URL endpoint and other query parameters** +from imagekitio import ImageKit -```python -image_url = imagekit.url({ - "path": "/default-image.jpg", - "query_parameters": { - "p1": "123", - "p2": "345" +client = ImageKit() + +# Read file into memory and upload +with open("/path/to/file.jpg", "rb") as f: + file_data = f.read() + +response = client.files.upload( + file=file_data, + file_name="fileName", + transformation={ + "post": [ + { + "type": "thumbnail", + "value": "w-150,h-150", + }, + { + "protocol": "dash", + "type": "abs", + "value": "sr-240_360_480_720_1080", + }, + ] }, - "transformation": [{ - "height": "300", - "width": "400" - }], - "signed": True, - "expire_seconds": 300 -}) -``` - -Sample Result URL - - -``` -https://ik.imagekit.io/your_imagekit_id/tr:h-300,w-400/default-image.jpg?p1=123&p2=345&ik-t=1658899345&ik-s=8f03aca28432d4e87f697a48143efb4497bbed9e +) +print(response.file_id) ``` -**4. Adding overlays** +### Async usage -ImageKit.io enables you to apply overlays to [images](https://docs.imagekit.io/features/image-transformations/overlay-using-layers) and [videos](https://docs.imagekit.io/features/video-transformation/overlay) using the raw parameter with the concept of [layers](https://docs.imagekit.io/features/image-transformations/overlay-using-layers#layers). The raw parameter facilitates incorporating transformations directly in the URL. A layer is a distinct type of transformation that allows you to define an asset to serve as an overlay, along with its positioning and additional transformations. - -**Text as overlays** - -You can add any text string over a base video or image using a text layer (l-text). - -For example: +Simply import `AsyncImageKit` instead of `ImageKit` and use `await` with each API call: ```python -image_url = imagekit.url({ - "path": "/default-image", - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [{ - "height": "300", - "width": "400", - "raw": "l-text,i-Imagekit,fs-50,l-end" - }], -}) -``` -**Sample Result URL** -``` -https://ik.imagekit.io/your_imagekit_id/tr:h-300,w-400,l-text,i-Imagekit,fs-50,l-end/default-image.jpg -``` - -**Image as overlays** - -You can add an image over a base video or image using an image layer (l-image). +import os +import asyncio +from imagekitio import AsyncImageKit -For example: - -```python -image_url = imagekit.url({ - "path": "/default-image", - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [{ - "height": "300", - "width": "400", - "raw": "l-image,i-default-image.jpg,w-100,b-10_CDDC39,l-end" - }], -}) -``` -**Sample Result URL** -``` -https://ik.imagekit.io/your_imagekit_id/tr:h-300,w-400,l-image,i-default-image.jpg,w-100,b-10_CDDC39,l-end/default-image.jpg -``` +client = AsyncImageKit( + private_key=os.environ.get("IMAGEKIT_PRIVATE_KEY"), # This is the default and can be omitted +) -**Solid color blocks as overlays** -You can add solid color blocks over a base video or image using an image layer (l-image). +async def main() -> None: + # Read file into memory and upload + with open("/path/to/your/image.jpg", "rb") as f: + file_data = f.read() + + response = await client.files.upload( + file=file_data, + file_name="file-name.jpg", + ) + print(response.file_id) + print(response.url) -For example: -```python -image_url = imagekit.url({ - "path": "/img/sample-video", - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [{ - "height": "300", - "width": "400", - "raw": "l-image,i-ik_canvas,bg-FF0000,w-300,h-100,l-end" - }], -}) -``` -**Sample Result URL** -``` -https://ik.imagekit.io/your_imagekit_id/tr:h-300,w-400,l-image,i-ik_canvas,bg-FF0000,w-300,h-100,l-end/img/sample-video.mp4 +asyncio.run(main()) ``` -**5. Arithmetic expressions in transformations** +Functionality between the synchronous and asynchronous clients is otherwise identical. -ImageKit allows use of [arithmetic expressions](https://docs.imagekit.io/features/arithmetic-expressions-in-transformations) in certain dimension and position-related parameters, making media transformations more flexible and dynamic. +#### With aiohttp -For example: +By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. -```python -image_url = imagekit.url({ - "path": "/default-image.jpg", - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [{ - "height": "ih_div_2", - "width": "iw_div_4", - "border": "cw_mul_0.05_yellow" - }], -}) -``` +You can enable this by installing `aiohttp`: -**Sample Result URL** -``` -https://ik.imagekit.io/your_imagekit_id/default-image.jpg?tr=w-iw_div_4,h-ih_div_2,b-cw_mul_0.05_yellow +```sh +# install from this staging repo +pip install 'imagekitio[aiohttp] @ git+ssh://git@github.com/stainless-sdks/imagekit-python.git' ``` -**List of transformations** - -The complete list of transformations supported and their usage in ImageKit is available [here](https://docs.imagekit.io/features/image-transformations/resize-crop-and-other-transformations). -The SDK gives a name to each transformation parameter, making the code simpler, more straightforward, and readable. If a transformation is supported in ImageKit, though it cannot be found in the table below, then use the transformation code from ImageKit docs as the name when using the `URL` function. - -If you want to generate transformations in your application and add them to the URL as it is, use the raw parameter. - -| Supported Transformation Name | Translates to parameter | -| ----------------------------- | ------------------------------- | -| height | h | -| width | w | -| aspect_ratio | ar | -| quality | q | -| crop | c | -| crop_mode | cm | -| x | x | -| y | y | -| focus | fo | -| format | f | -| radius | r | -| background | bg | -| border | b | -| rotation | rt | -| blur | bl | -| named | n | -| progressive | pr | -| lossless | lo | -| trim | t | -| metadata | md | -| color_profile | cp | -| default_image | di | -| dpr | dpr | -| effect_sharpen | e-sharpen | -| effect_usm | e-usm | -| effect_contrast | e-contrast | -| effect_gray | e-grayscale | -| effect_shadow | e-shadow | -| effect_gradient | e-gradient | -| original | orig | -| raw | replaced by the parameter value | - -## File Upload - -The SDK provides a simple interface using the `.upload_file()` method to upload files to the ImageKit Media library. It -accepts all the parameters supported by -the [ImageKit Upload API](https://docs.imagekit.io/api-reference/upload-file-api/server-side-file-upload). - -The `upload_file()` method requires at least the `file` as (URL/Base64/Binary) and the `file_name` parameter to upload a -file. The method returns a dict data in case of success, or it will throw a custom exception in case of failure. -Use the `options` parameter to pass other parameters supported by -the [ImageKit Upload API](https://docs.imagekit.io/api-reference/upload-file-api/server-side-file-upload). Use the same -parameter name as specified in the upload API documentation. - -Simple usage +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python -from imagekitio.models.UploadFileRequestOptions import UploadFileRequestOptions - -extensions = [ - { - 'name': 'remove-bg', - 'options': { - 'add_shadow': True, - 'bg_color': 'pink' - } - }, - { - 'name': 'google-auto-tagging', - 'minConfidence': 80, - 'maxTags': 10 - } -] - -transformation = { - 'pre': 'l-text,i-Imagekit,fs-50,l-end', - 'post': [ - { - 'type': 'transformation', - 'value': 'w-100' - } - ] -} - -options = UploadFileRequestOptions( - use_unique_file_name=False, - tags=['abc', 'def'], - folder='/testing-python-folder/', - is_private_file=False, - custom_coordinates='10,10,20,20', - response_fields=['tags', 'custom_coordinates', 'is_private_file', - 'embedded_metadata', 'custom_metadata'], - extensions=extensions, - webhook_url='https://webhook.site/c78d617f-33bc-40d9-9e61-608999721e2e', - overwrite_file=True, - overwrite_ai_tags=False, - overwrite_tags=False, - overwrite_custom_metadata=True, - custom_metadata={'testss': 12}, - transformation=transformation, - checks="'request.folder' : '/testing-python-folder'", # To run server side checks before uploading files. Notice the quotes around request.folder and /testing-python-folder. - is_published=True -) +import os +import asyncio +from imagekitio import DefaultAioHttpClient +from imagekitio import AsyncImageKit -result = imagekit.upload_file(file='', # required - file_name='my_file_name.jpg', # required - options=options) -# Final Result -print(result) +async def main() -> None: + async with AsyncImageKit( + private_key=os.environ.get( + "IMAGEKIT_PRIVATE_KEY" + ), # This is the default and can be omitted + http_client=DefaultAioHttpClient(), + ) as client: + # Read file into memory and upload + with open("/path/to/your/image.jpg", "rb") as f: + file_data = f.read() + + response = await client.files.upload( + file=file_data, + file_name="file-name.jpg", + ) + print(response.file_id) + print(response.url) -# Raw Response -print(result.response_metadata.raw) -# print that uploaded file's ID -print(result.file_id) +asyncio.run(main()) ``` -If the upload succeeds, the `result` will be the `UploadFileResult` class. - -If the upload fails, the custom exception will be thrown with: +## URL generation -- `response_help` for any kind of help -- `response_metadata` with `raw`, `http_status_code` and `headers` -- `message` can be called to get the error message received from ImageKit's servers. +The ImageKit SDK provides a powerful `helper.build_url()` method for generating optimized image and video URLs with transformations. Here are examples ranging from simple URLs to complex transformations with overlays and signed URLs. -## File Management +### Basic URL generation -The SDK provides a simple interface for all -the [media APIs mentioned here](https://docs.imagekit.io/api-reference/media-api) -to manage your files. This also returns `result`. +Generate a simple URL without any transformations: -**1. List & Search Files** - -Accepts an object specifying the parameters used to list and search files. All parameters specified -in -the [documentation here](https://docs.imagekit.io/api-reference/media-api/list-and-search-files#list-and-search-file-api) -can be passed with the correct values to get the results. - -#### Applying Filters -Filter out the files by specifying the parameters. - -```Python -from imagekitio.models.ListAndSearchFileRequestOptions import ListAndSearchFileRequestOptions +```python +import os +from imagekitio import ImageKit -options = ListAndSearchFileRequestOptions( - type='file', - sort='ASC_CREATED', - path='/', - file_type='all', - limit=5, - skip=0, - tags='Software, Developer, Engineer', +client = ImageKit( + private_key=os.environ.get("IMAGEKIT_PRIVATE_KEY"), ) -result = imagekit.list_files(options=options) - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print the first file's ID -print(result.list[0].file_id) -``` - -#### Advance Search -In addition, you can fine-tune your query by specifying various filters by generating a query string in a Lucene-like syntax and providing this generated string as the value of the `search_query`. - -```Python -from imagekitio.models.ListAndSearchFileRequestOptions import ListAndSearchFileRequestOptions - -options = ListAndSearchFileRequestOptions( - search_query="createdAt >= '2d' OR size < '2mb' OR format='png'", +# Basic URL without transformations +url = client.helper.build_url( + url_endpoint="https://ik.imagekit.io/your_imagekit_id", + src="/path/to/image.jpg", ) - -result = imagekit.list_files(options=options) - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print the first file's ID -print(result.list[0].file_id) +print(url) +# Result: https://ik.imagekit.io/your_imagekit_id/path/to/image.jpg ``` -Detailed documentation can be found here for [advance search queries](https://docs.imagekit.io/api-reference/media-api/list-and-search-files#advanced-search-queries). -**2. Get File Details** +### URL generation with transformations -Accepts the file ID and fetches the details as per -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/get-file-details) +Apply common transformations like resizing, cropping, and format conversion: ```python -file_id = "your_file_id" -result = imagekit.get_file_details(file_id=file_id) # file_id required - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print that file's id -print(result.file_id) -``` - -**3. Get File Versions** - -Accepts the file ID and fetches the details as per -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/get-file-versions) - -```python -file_id = "your_file_id" -result = imagekit.get_file_versions(file_id=file_id) # file_id required - - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print that file's version id -print(result.list[0].version_info.id) -``` - -**4. Get File Version details** - -Accepts the `file_id` and `version_id` and fetches the details as per -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/get-file-version-details) +import os +from imagekitio import ImageKit -```python -result = imagekit.get_file_version_details( - file_id='file_id', - version_id='version_id' +client = ImageKit( + private_key=os.environ.get("IMAGEKIT_PRIVATE_KEY"), ) -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print that file's id -print(result.file_id) - -# print that file's version id -print(result.version_info.id) -``` - -**5. Update File Details** - -Accepts all the parameters as per -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/update-file-details). -The first argument to the `update_file_details()` method is the file ID, and a second argument is an object with the -parameters to be -updated. - -```python -from imagekitio.models.UpdateFileRequestOptions import UpdateFileRequestOptions - -extensions = [ - { - 'name': 'remove-bg', - 'options': { - 'add_shadow': True, - 'bg_color': 'red' +# URL with basic transformations +url = client.helper.build_url( + url_endpoint="https://ik.imagekit.io/your_imagekit_id", + src="/path/to/image.jpg", + transformation=[ + { + "width": 400, + "height": 300, + "crop": "maintain_ratio", + "quality": 80, + "format": "webp", } - }, - { - 'name': 'google-auto-tagging', - 'minConfidence': 80, - 'maxTags': 10 - } -] - -options = UpdateFileRequestOptions( - remove_ai_tags=['remove-ai-tag-1', 'remove-ai-tag-2'], - webhook_url='url', - extensions=extensions, - tags=['tag-1', 'tag-2'], - custom_coordinates='10,10,100,100', - custom_metadata={'test': 11}, + ], ) - -result = imagekit.update_file_details(file_id='62cfd39819ca454d82a07182' - , options=options) # required - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print that file's id -print(result.file_id) +print(url) +# Result: https://ik.imagekit.io/your_imagekit_id/path/to/image.jpg?tr=w-400,h-300,c-maintain_ratio,q-80,f-webp ``` -**Update publish status** +### URL generation with image overlay -If `publish` is included in the update options, no other parameters are allowed. If any are present, an error will be returned: `Your request cannot contain any other parameters when publish is present`. +Add image overlays to your base image: ```python -from imagekitio.models.UpdateFileRequestOptions import UpdateFileRequestOptions +import os +from imagekitio import ImageKit -options = UpdateFileRequestOptions( - publish={ - "isPublished": True, - "includeFileVersions": True - } +client = ImageKit( + private_key=os.environ.get("IMAGEKIT_PRIVATE_KEY"), ) -result = imagekit.update_file_details(file_id='62cfd39819ca454d82a07182' - , options=options) # required - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print that file's id -print(result.file_id) -``` - -**6. Add tags** - -Accepts a list of `file_ids` and `tags` as a parameter to be used to add tags. All parameters specified in -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/add-tags-bulk) can be passed to -the `.add_tags()` functions to get the results. - -```python -result = imagekit.add_tags(file_ids=['file-id-1', 'file-id-2'], tags=['add-tag-1', 'add-tag-2']) - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# list successfully updated file ids -print(result.successfully_updated_file_ids) - -# print the first file's id -print(result.successfully_updated_file_ids[0]) -``` - -**7. Remove tags** - -Accepts a list of `file_ids` and `tags` as a parameter to be used to remove tags. All parameters specified in -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/remove-tags-bulk) can be passed to -the `.remove_tags()` functions to get the results. - -```python -result = imagekit.remove_tags(file_ids=['file-id-1', 'file-id-2'], tags=['remove-tag-1', 'remove-tag-2']) - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# list successfully updated file ids -print(result.successfully_updated_file_ids) - -# print the first file's id -print(result.successfully_updated_file_ids[0]) -``` - -**8. Remove AI tags** - -Accepts a list of `file_ids` and `ai_tags` as a parameter to remove AI tags. All parameters specified in -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/remove-aitags-bulk) can be passed to -the `.remove_ai_tags()` functions to get the results. - -```python -result = imagekit.remove_ai_tags(file_ids=['file-id-1', 'file-id-2'], ai_tags=['remove-ai-tag-1', 'remove-ai-tag-2']) - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# list successfully updated file ids -print(result.successfully_updated_file_ids) - -# print the first file's id -print(result.successfully_updated_file_ids[0]) +# URL with image overlay +url = client.helper.build_url( + url_endpoint="https://ik.imagekit.io/your_imagekit_id", + src="/path/to/base-image.jpg", + transformation=[ + { + "width": 500, + "height": 400, + "overlay": { + "type": "image", + "input": "/path/to/overlay-logo.png", + "position": { + "x": 10, + "y": 10, + }, + "transformation": [ + { + "width": 100, + "height": 50, + } + ], + }, + } + ], +) +print(url) +# Result: URL with image overlay positioned at x:10, y:10 ``` -**9. Delete File** +### URL generation with text overlay -Delete a file according to the [API documentation here](https://docs.imagekit.io/api-reference/media-api/delete-file). It accepts the file ID of the File that has to be -deleted. +Add customized text overlays: ```python -file_id = "file_id" -result = imagekit.delete_file(file_id=file_id) +import os +from imagekitio import ImageKit -# Final Result -print(result) +client = ImageKit( + private_key=os.environ.get("IMAGEKIT_PRIVATE_KEY"), +) -# Raw Response -print(result.response_metadata.raw) +# URL with text overlay +url = client.helper.build_url( + url_endpoint="https://ik.imagekit.io/your_imagekit_id", + src="/path/to/base-image.jpg", + transformation=[ + { + "width": 600, + "height": 400, + "overlay": { + "type": "text", + "text": "Sample Text Overlay", + "position": { + "x": 50, + "y": 50, + "focus": "center", + }, + "transformation": [ + { + "font_size": 40, + "font_family": "Arial", + "font_color": "FFFFFF", + "typography": "b", # bold + } + ], + }, + } + ], +) +print(url) +# Result: URL with bold white Arial text overlay at center position ``` -**10. Delete FileVersion** +### URL generation with multiple overlays -Delete a file version as per -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/delete-file-version). -The method accepts the `file_id` and particular version id of the file that has to be deleted. +Combine multiple overlays for complex compositions: ```python -result = imagekit.delete_file_version(file_id="file_id", version_id="version_id") +import os +from imagekitio import ImageKit -# Final Result -print(result) +client = ImageKit( + private_key=os.environ.get("IMAGEKIT_PRIVATE_KEY"), +) -# Raw Response -print(result.response_metadata.raw) +# URL with multiple overlays (text + image) +url = client.helper.build_url( + url_endpoint="https://ik.imagekit.io/your_imagekit_id", + src="/path/to/base-image.jpg", + transformation=[ + { + "width": 800, + "height": 600, + "overlay": { + "type": "text", + "text": "Header Text", + "position": { + "x": 20, + "y": 20, + }, + "transformation": [ + { + "font_size": 30, + "font_color": "000000", + } + ], + }, + }, + { + "overlay": { + "type": "image", + "input": "/watermark.png", + "position": { + "focus": "bottom_right", + }, + "transformation": [ + { + "width": 100, + "opacity": 70, + } + ], + }, + }, + ], +) +print(url) +# Result: URL with text overlay at top-left and semi-transparent watermark at bottom-right ``` -**11. Bulk File Delete by IDs** +### Signed URLs for secure delivery -Delete a file as per the [API documentation here](https://docs.imagekit.io/api-reference/media-api/delete-files-bulk). -The method accepts a list of file IDs that have to be deleted. +Generate signed URLs that expire after a specified time for secure content delivery: ```python -result = imagekit.bulk_file_delete(file_ids=["file_id1", "file_id2"]) - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) +import os +from imagekitio import ImageKit -# list successfully deleted file ids -print(result.successfully_deleted_file_ids) +client = ImageKit( + private_key=os.environ.get("IMAGEKIT_PRIVATE_KEY"), +) -# print the first file's id -print(result.successfully_deleted_file_ids[0]) +# Generate a signed URL that expires in 1 hour (3600 seconds) +url = client.helper.build_url( + url_endpoint="https://ik.imagekit.io/your_imagekit_id", + src="/private/secure-image.jpg", + transformation=[ + { + "width": 400, + "height": 300, + "quality": 90, + } + ], + signed=True, + expires_in=3600, # URL expires in 1 hour +) +print(url) +# Result: URL with signature parameters (?ik-t=timestamp&ik-s=signature) + +# Generate a signed URL that doesn't expire +permanent_signed_url = client.helper.build_url( + url_endpoint="https://ik.imagekit.io/your_imagekit_id", + src="/private/secure-image.jpg", + signed=True, + # No expires_in means the URL won't expire +) +print(permanent_signed_url) +# Result: URL with signature parameter (?ik-s=signature) ``` -**12. Copy file** +### Using Raw transformations for undocumented features -Copy a file according to the [API documentation here](https://docs.imagekit.io/api-reference/media-api/copy-file). -The method accepts `source_file_path`, `destination_path`, and `include_file_versions` of the file that has to be copied. +ImageKit frequently adds new transformation parameters that might not yet be documented in the SDK. You can use the `raw` parameter to access these features or create custom transformation strings: ```python -from imagekitio.models.CopyFileRequestOptions import CopyFileRequestOptions - -options = \ - CopyFileRequestOptions(source_file_path='/source_file_path.jpg', - destination_path='/destination_path', - include_file_versions=True) -result = imagekit.copy_file(options=options) +import os +from imagekitio import ImageKit -# Final Result -print(result) +client = ImageKit( + private_key=os.environ.get("IMAGEKIT_PRIVATE_KEY"), +) -# Raw Response -print(result.response_metadata.raw) +# Using Raw transformation for undocumented or new parameters +url = client.helper.build_url( + url_endpoint="https://ik.imagekit.io/your_imagekit_id", + src="/path/to/image.jpg", + transformation=[ + { + # Combine documented transformations with raw parameters + "width": 400, + "height": 300, + }, + { + # Use raw for undocumented transformations or complex parameters + "raw": "something-new", + }, + ], +) +print(url) +# Result: https://ik.imagekit.io/your_imagekit_id/path/to/image.jpg?tr=w-400,h-300:something-new ``` -**13. Move File** +## Authentication parameters for client-side uploads -Move a file as per the [API documentation here](https://docs.imagekit.io/api-reference/media-api/move-file). -The method accepts `source_file_path` and `destination_path` of the file that has to be moved. +Generate authentication parameters for secure client-side file uploads: ```python -from imagekitio.models.MoveFileRequestOptions import MoveFileRequestOptions +import os +from imagekitio import ImageKit -options = \ - MoveFileRequestOptions(source_file_path='/source_file_path.jpg', - destination_path='/destination_path') -result = imagekit.move_file(options=options) +client = ImageKit( + private_key=os.environ.get("IMAGEKIT_PRIVATE_KEY"), +) -# Final Result -print(result) +# Generate authentication parameters for client-side uploads +auth_params = client.helper.get_authentication_parameters() +print(auth_params) +# Result: {'expire': , 'signature': '', 'token': ''} -# Raw Response -print(result.response_metadata.raw) +# Generate with custom token and expiry +custom_auth_params = client.helper.get_authentication_parameters( + token="my-custom-token", + expire=1800 +) +print(custom_auth_params) +# Result: {'expire': 1800, 'signature': '', 'token': 'my-custom-token'} ``` -**14. Rename File** - -Rename a file per the [API documentation here](https://docs.imagekit.io/api-reference/media-api/rename-file). -The method accepts the `file_path`, `new_file_name`, and `purge_cache` boolean that has to be renamed. +These authentication parameters can be used in client-side upload forms to securely upload files without exposing your private API key. -```python -from imagekitio.models.RenameFileRequestOptions import RenameFileRequestOptions +## Webhook verification -options = RenameFileRequestOptions(file_path='/file_path.jpg', - new_file_name='new_file_name.jpg', - purge_cache=True) -result = imagekit.rename_file(options=options) +The ImageKit SDK provides utilities to verify webhook signatures for secure event handling. This ensures that webhook requests are actually coming from ImageKit and haven't been tampered with. -# Final Result -print(result) +For detailed information about webhook setup, signature verification, and handling different webhook events, refer to the [ImageKit webhook documentation](https://imagekit.io/docs/webhooks#verify-webhook-signature). -# Raw Response -print(result.response_metadata.raw) +## Advanced Usage -# print the purge request id -print(result.purge_request_id) -``` +### File uploads -**15. Restore file Version** +Request parameters that correspond to file uploads can be passed as `bytes`, a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance, an `IO[bytes]` file object, or a tuple of `(filename, contents, media type)`. -Restore a file as per -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/restore-file-version). -The method accepts the `file_id` and `version_id` of the file that has to be restored. +Here are common file upload patterns: ```python -result = imagekit.restore_file_version(file_id="file_id", version_id="version_id") - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print that file's id -print(result.file_id) -``` +from pathlib import Path +from imagekitio import ImageKit +import io -**16. Create Folder** +client = ImageKit() -Create a folder per the [API documentation here](https://docs.imagekit.io/api-reference/media-api/create-folder). -The method accepts `folder_name` and `parent_folder_path` as options that must be created. +# Method 1: Upload from bytes +# Read file into memory first, then upload +with open("/path/to/your/image.jpg", "rb") as f: + file_data = f.read() -```Python -from imagekitio.models.CreateFolderRequestOptions import CreateFolderRequestOptions +response = client.files.upload( + file=file_data, + file_name="uploaded-image.jpg", +) -options = CreateFolderRequestOptions(folder_name='test', - parent_folder_path='/') -result = imagekit.create_folder(options=options) +# Method 2: Upload from file stream (for large files) +# Pass file object directly - SDK reads it +with open("/path/to/your/image.jpg", "rb") as file_stream: + response = client.files.upload( + file=file_stream, + file_name="uploaded-image.jpg", + ) + +# Method 3: Upload using Path object (SDK reads automatically) +response = client.files.upload( + file=Path("/path/to/file.jpg"), + file_name="fileName.jpg", +) -# Final Result -print(result) +# Method 4: Upload from BytesIO (for programmatically generated content) +content = b"your binary data" +bytes_io = io.BytesIO(content) +response = client.files.upload( + file=bytes_io, + file_name="binary-upload.jpg", +) -# Raw Response -print(result.response_metadata.raw) +# Method 5: Upload with custom content type using tuple format +image_data = b"your binary data" +response = client.files.upload( + file=("custom.jpg", image_data, "image/jpeg"), + file_name="custom-upload.jpg", +) ``` -**17. Delete Folder** +The async client uses the exact same interface. If you pass a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance, the file contents will be read asynchronously automatically. -Delete a folder as per the [API documentation here](https://docs.imagekit.io/api-reference/media-api/delete-folder). -The method accepts `folder_path` as an option that must be deleted. +**Note:** URL strings (e.g., `"https://example.com/image.jpg"`) are not supported by the Python SDK. To upload from a URL, download the content first: ```python -from imagekitio.models.DeleteFolderRequestOptions import DeleteFolderRequestOptions - -options = DeleteFolderRequestOptions(folder_path='/test/demo') -result = imagekit.delete_folder(options=options) +import urllib.request -# Final Result -print(result) +# Download from URL and upload to ImageKit +url = "https://example.com/image.jpg" +with urllib.request.urlopen(url) as response: + url_content = response.read() -# Raw Response -print(result.response_metadata.raw) +# Upload the downloaded content +upload_response = client.files.upload( + file=url_content, + file_name="downloaded-image.jpg", +) ``` -**18. Copy Folder** - -Copy a folder as per the [API documentation here](https://docs.imagekit.io/api-reference/media-api/copy-folder). -The method accepts the `source_folder_path`, `destination_path`, and `include_file_versions` boolean as options that -have to be copied. +### Handling errors -```python -from imagekitio.models.CopyFolderRequestOptions import CopyFolderRequestOptions -options = \ - CopyFolderRequestOptions(source_folder_path='/source_folder_path', - destination_path='/destination/path', - include_file_versions=True) -result = imagekit.copy_folder(options=options) - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print the job's id -print(result.job_id) -``` +When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `imagekitio.APIConnectionError` is raised. -**19. Move Folder** +When the API returns a non-success status code (that is, 4xx or 5xx +response), a subclass of `imagekitio.APIStatusError` is raised, containing `status_code` and `response` properties. -Move a folder as per the [API documentation here](https://docs.imagekit.io/api-reference/media-api/move-folder). -The method accepts the `source_folder_path` and `destination_path` of a folder as options that must be moved. +All errors inherit from `imagekitio.APIError`. ```python -from imagekitio.models.MoveFolderRequestOptions import MoveFolderRequestOptions -options = \ - MoveFolderRequestOptions(source_folder_path='/source_folder_path', - destination_path='/destination_path') -result = imagekit.move_folder(options=options) -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print the job's id -print(result.job_id) -``` +import imagekitio +from imagekitio import ImageKit -**20. Get Bulk Job Status** +client = ImageKit() -Accepts the `job_id` to get bulk job status as per -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/copy-move-folder-status). -The method takes only jobId. +try: + # Read file into memory and upload + with open("/path/to/your/image.jpg", "rb") as f: + file_data = f.read() + + response = client.files.upload( + file=file_data, + file_name="file-name.jpg", + ) +except imagekitio.APIConnectionError as e: + print("The server could not be reached") + print(e.__cause__) # an underlying Exception, likely raised within httpx. +except imagekitio.RateLimitError as e: + print("A 429 status code was received; we should back off a bit.") +except imagekitio.APIStatusError as e: + print("Another non-200-range status code was received") + print(e.status_code) + print(e.response) +``` + +Error codes are as follows: + +| Status Code | Error Type | +| ----------- | -------------------------- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitError` | +| >=500 | `InternalServerError` | +| N/A | `APIConnectionError` | + +### Retries + +Certain errors are automatically retried 2 times by default, with a short exponential backoff. +Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, +429 Rate Limit, and >=500 Internal errors are all retried by default. + +You can use the `max_retries` option to configure or disable retry settings: ```python -result = imagekit.get_bulk_job_status(job_id="job_id") - -# Final Result -print(result) +from imagekitio import ImageKit -# Raw Response -print(result.response_metadata.raw) +# Configure the default for all requests: +client = ImageKit( + # default is 2 + max_retries=0, +) -# print the job's id -print(result.job_id) +# Or, configure per-request: +with open("/path/to/your/image.jpg", "rb") as f: + file_data = f.read() -# print the status -print(result.status) +client.with_options(max_retries=5).files.upload( + file=file_data, + file_name="file-name.jpg", +) ``` -**21. Purge Cache** +### Timeouts -Programmatically issue an explicit cache request as per -the [API documentation here](https://docs.imagekit.io/api-reference/media-api/purge-cache). -Accepts the full URL of the File for which the cache has to be cleared. +By default requests time out after 1 minute. You can configure this with a `timeout` option, +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: ```python -result = imagekit.purge_file_cache(file_url="full_url_of_file") - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print the purge file cache request id -print(result.request_id) -``` - -**22. Purge Cache Status** - -Get the purge cache request status using the `cache_request_id` returned when a purge cache request gets submitted as per the -[API documentation here](https://docs.imagekit.io/api-reference/media-api/purge-cache-status) +from imagekitio import ImageKit -```python -result = imagekit.get_purge_file_cache_status(purge_cache_id="cache_request_id") +# Configure the default for all requests: +client = ImageKit( + # 20 seconds (default is 1 minute) + timeout=20.0, +) -# Final Result -print(result) +# More granular control: +client = ImageKit( + timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), +) -# Raw Response -print(result.response_metadata.raw) +# Override per-request: +with open("/path/to/your/image.jpg", "rb") as f: + file_data = f.read() -# print the purge file cache status -print(result.status) +client.with_options(timeout=5.0).files.upload( + file=file_data, + file_name="file-name.jpg", +) ``` -**23. Get File Metadata** +On timeout, an `APITimeoutError` is thrown. -Accepts the `file_id` and fetches the metadata as per -the [API documentation here](https://docs.imagekit.io/api-reference/metadata-api/get-image-metadata-for-uploaded-media-files) +Note that requests that time out are [retried twice by default](#retries). -```python -result = imagekit.get_file_metadata(file_id="file_id") +### Logging -# Final Result -print(result) +We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. -# Raw Response -print(result.response_metadata.raw) +You can enable logging by setting the environment variable `IMAGE_KIT_LOG` to `info`. -# print the file metadata fields -print(result.width) -print(result.exif.image.x_resolution) +```shell +$ export IMAGE_KIT_LOG=info ``` -**24. Get File Metadata from remote URL** - -Accepts the `remote_file_url` and fetches the metadata as per -the [API documentation here](https://docs.imagekit.io/api-reference/metadata-api/get-image-metadata-from-remote-url) +Or to `debug` for more verbose logging. -```python -result = imagekit.get_remote_file_url_metadata(remote_file_url="remote_file_url") - -# Final Result -print(result) +### How to tell whether `None` means `null` or missing -# Raw Response -print(result.response_metadata.raw) +In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: -# print the file metadata fields -print(result.width) -print(result.exif.image.x_resolution) +```py +if response.my_field is None: + if 'my_field' not in response.model_fields_set: + print('Got json like {}, without a "my_field" key present at all.') + else: + print('Got json like {"my_field": null}.') ``` -**25. Create CustomMetaDataFields** - -Accepts an option specifying the parameters used to create custom metadata fields. All parameters specified in -the [API documentation here](https://docs.imagekit.io/api-reference/custom-metadata-fields-api/create-custom-metadata-field) -can be passed as it is with the correct values to get the results. +### Accessing raw response data (e.g. headers) -Check for the [allowed values in the schema](https://docs.imagekit.io/api-reference/custom-metadata-fields-api/create-custom-metadata-field#allowed-values-in-the-schema-object). - -**Example:** - -```python -# Example for the type number +The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., -from imagekitio.models.CreateCustomMetadataFieldsRequestOptions import CreateCustomMetadataFieldsRequestOptions -from imagekitio.models.CustomMetadataFieldsSchema import CustomMetadataFieldsSchema -from imagekitio.models.CustomMetaDataTypeEnum import CustomMetaDataTypeEnum -schema = CustomMetadataFieldsSchema(type=CustomMetaDataTypeEnum.Number, - min_value=100, - max_value=200) -options = CreateCustomMetadataFieldsRequestOptions(name='test', - label='test', - schema=schema) -result = imagekit.create_custom_metadata_fields(options=options) - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print the id of created custom metadata fields -print(result.id) +```py +from imagekitio import ImageKit -# print the schema's type of created custom metadata fields -print(result.schema.type) +client = ImageKit() -``` +# Read file into memory and upload +with open("/path/to/your/image.jpg", "rb") as f: + file_data = f.read() -```python -# MultiSelect type Example - -from imagekitio.models.CreateCustomMetadataFieldsRequestOptions import CreateCustomMetadataFieldsRequestOptions -from imagekitio.models.CustomMetadataFieldsSchema import CustomMetadataFieldsSchema -from imagekitio.models.CustomMetaDataTypeEnum import CustomMetaDataTypeEnum - -schema = \ - CustomMetadataFieldsSchema(type=CustomMetaDataTypeEnum.MultiSelect, - is_value_required=True, - default_value=['small', 30, True], - select_options=[ - 'small', - 'medium', - 'large', - 30, - 40, - True, - ]) -options = \ - CreateCustomMetadataFieldsRequestOptions(name='test-MultiSelect', - label='test-MultiSelect', schema=schema) -result = imagekit.create_custom_metadata_fields(options=options) - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print the name of created custom metadata fields -print(result.name) - -# print the schema's select options of created custom metadata fields -print(result.schema.select_options) +response = client.files.with_raw_response.upload( + file=file_data, + file_name="file-name.jpg", +) +print(response.headers.get('X-My-Header')) +file = response.parse() # get the object that `files.upload()` would have returned +print(file.file_id) ``` -```python -# Date type Example - -from imagekitio.models.CreateCustomMetadataFieldsRequestOptions import CreateCustomMetadataFieldsRequestOptions -from imagekitio.models.CustomMetadataFieldsSchema import CustomMetadataFieldsSchema -from imagekitio.models.CustomMetaDataTypeEnum import CustomMetaDataTypeEnum - -schema = CustomMetadataFieldsSchema(type=CustomMetaDataTypeEnum.Date, - min_value='2022-11-29T10:11:10+00:00', - max_value='2022-11-30T10:11:10+00:00') -options = CreateCustomMetadataFieldsRequestOptions(name='test-date', - label='test-date', - schema=schema) -result = imagekit.create_custom_metadata_fields(options=options) +These methods return an [`APIResponse`](https://github.com/stainless-sdks/imagekit-python/tree/main/src/imagekitio/_response.py) object. -# Final Result -print(result) +The async client returns an [`AsyncAPIResponse`](https://github.com/stainless-sdks/imagekit-python/tree/main/src/imagekitio/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. -# Raw Response -print(result.response_metadata.raw) +#### `.with_streaming_response` -# print the label of created custom metadata fields -print(result.label) +The above interface eagerly reads the full response body when you make the request, which may not always be what you want. -# print the schema's min value of created custom metadata fields -print(result.schema.min_value) - -``` - -**26. Get CustomMetaDataFields** - -Accepts the `include_deleted` boolean as the initial parameter and fetches the metadata as per -the [API documentation here](https://docs.imagekit.io/api-reference/custom-metadata-fields-api/get-custom-metadata-field) -. +To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. ```python -result = imagekit.get_custom_metadata_fields() # in this case, it will consider includeDeleted as a False - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) +# Read file into memory and upload +with open("/path/to/your/image.jpg", "rb") as f: + file_data = f.read() -# print the first customMetadataField's id -print(result.list[0].id) +with client.files.with_streaming_response.upload( + file=file_data, + file_name="file-name.jpg", +) as response: + print(response.headers.get("X-My-Header")) -# print the first customMetadataField schema's type -print(result.list[0].schema.type) + for line in response.iter_lines(): + print(line) ``` -```python -result = imagekit.get_custom_metadata_fields(include_deleted=True) - -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) +The context manager is required so that the response will reliably be closed. -# print the first customMetadataField's name -print(result.list[0].name) +### Making custom/undocumented requests -# print the first customMetadataField schema's default value -print(result.list[0].schema.default_value) -``` +This library is typed for convenient access to the documented API. -**27. Update CustomMetaDataFields** +If you need to access undocumented endpoints, params, or response properties, the library can still be used. -Accepts a `field_id` and options for specifying the parameters to be used to edit custom metadata fields -as per -the [API documentation here](https://docs.imagekit.io/api-reference/custom-metadata-fields-api/update-custom-metadata-field) -. +#### Undocumented endpoints -```python +To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other +http verbs. Options on the client will be respected (such as retries) when making this request. -from imagekitio.models.CustomMetadataFieldsSchema import CustomMetadataFieldsSchema -from imagekitio.models.UpdateCustomMetadataFieldsRequestOptions import UpdateCustomMetadataFieldsRequestOptions +```py +import httpx -schema = CustomMetadataFieldsSchema(min_value=100, max_value=200) -options = UpdateCustomMetadataFieldsRequestOptions( - label='test-update', - schema=schema -) -result = imagekit.update_custom_metadata_fields( - field_id='id_of_custom_metadata_field', - options=options +response = client.post( + "/foo", + cast_to=httpx.Response, + body={"my_param": True}, ) -# Final Result -print(result) - -# Raw Response -print(result.response_metadata.raw) - -# print the label of updated custom metadata fields -print(result.label) - -# print the schema's min value of updated custom metadata fields -print(result.schema.min_value) +print(response.headers.get("x-foo")) ``` -**28. Delete CustomMetaDataFields** +#### Undocumented request params -Accepts the id to delete the custom metadata fields as per -the [API documentation here](https://docs.imagekit.io/api-reference/custom-metadata-fields-api/delete-custom-metadata-field) -. +If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request +options. -```python -result = imagekit.delete_custom_metadata_field(field_id="id_of_custom_metadata_field") +#### Undocumented response properties -# Final Result -print(result) +To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You +can also get all the extra fields on the Pydantic model as a dict with +[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). -# Raw Response -print(result.response_metadata.raw) -``` +### Configuring the HTTP client -## Utility functions +You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: -We have included the following commonly used utility functions in this package. - -**Authentication parameter generation** - -Suppose one wants to implement client-side file upload. In that case, one will need a token, expiry timestamp, and a valid signature for that upload. The SDK provides a simple method that one can use in their code to generate these authentication parameters. - -Note: Any client-side code should never expose The Private API Key. One must always generate these authentications parameters on the server-side - -authentication - -`authentication_parameters = imagekit.get_authentication_parameters(token, expire)` - -Returns +- Support for [proxies](https://www.python-httpx.org/advanced/proxies/) +- Custom [transports](https://www.python-httpx.org/advanced/transports/) +- Additional [advanced](https://www.python-httpx.org/advanced/clients/) functionality ```python -{ - "token": "unique_token", - "expire": "valid_expiry_timestamp", - "signature": "generated_signature" -} -``` - -Both the `token` and `expire` parameters are optional. If not specified, the SDK uses the UUID to generate a random token and internally generates a valid expiry timestamp. The `token` and `expire` used to generate `signature` is part of a response returned by the server. - -**Distance calculation between two `pHash` values** - -Perceptual hashing allows you to construct a has value that uniquely identifies an input image based on the contents -of an image. [imagekit.io metadata API](https://docs.imagekit.io/api-reference/metadata-api) returns the `pHash` -value of an image in the response. You can use this value -to [find a duplicate or similar image](https://docs.imagekit.io/api-reference/metadata-api#using-phash-to-find-similar-or-duplicate-images) -by calculating the distance between the two images. +import httpx +from imagekitio import ImageKit, DefaultHttpxClient -This SDK exposes the `phash_distance` function to calculate the distance between two `pHash` values. It accepts two `pHash` -hexadecimal -strings and returns a numeric value indicative of the difference between the two images. - -```python -def calculate_distance(): - # fetch metadata of two uploaded image files - ... - # extract pHash strings from both: say 'first_hash' and 'second_hash' - ... - # calculate the distance between them: - - distance = imagekit.phash_distance(first_hash, second_hash) - return distance - -``` - -**Distance calculation examples** - -```Python -imagekit.phash_distance('f06830ca9f1e3e90', 'f06830ca9f1e3e90') -# output: 0 (same image) - -imagekit.phash_distance('2d5ad3936d2e015b', '2d6ed293db36a4fb') -# output: 17 (similar images) - -imagekit.phash_distance('a4a65595ac94518b', '7838873e791f8400') -# output: 37 (dissimilar images) -``` - -**HTTP response metadata of Internal API** - -HTTP response metadata of the internal API call can be accessed using the \_response_metadata on the Result object. -Example: - -```Python -result = imagekit.upload_file( - file="", - file_name="my_file_name.jpg", +client = ImageKit( + # Or use the `IMAGE_KIT_BASE_URL` env var + base_url="http://my.test.server.example.com:8083", + http_client=DefaultHttpxClient( + proxy="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), + ), ) - -# Final Result -print(result) -print(result.response_metadata.raw) -print(result.response_metadata.http_status_code) -print(result.response_metadata.headers) -``` - -### Sample Code Instruction - -To run `sample` code go to the code samples here are hosted on GitHub - https://github.com/imagekit-samples/quickstart/tree/master/python and run. - -```shell -python sample.py ``` -## Handling errors - -Catch and respond to invalid data, internal problems, and more. - -ImageKit Python SDK raises exceptions for many reasons, such as not found, invalid parameters, authentication, and -internal server errors. Therefore, we recommend writing code that gracefully handles all possible API exceptions. +You can also customize the client on a per-request basis by using `with_options()`: -#### Example: - -```Python -from imagekitio.exceptions.BadRequestException import BadRequestException -from imagekitio.exceptions.UnauthorizedException import UnauthorizedException -from imagekitio.exceptions.ForbiddenException import ForbiddenException -from imagekitio.exceptions.TooManyRequestsException import TooManyRequestsException -from imagekitio.exceptions.InternalServerException import InternalServerException -from imagekitio.exceptions.PartialSuccessException import PartialSuccessException -from imagekitio.exceptions.NotFoundException import NotFoundException -from imagekitio.exceptions.UnknownException import UnknownException - -try: - - # Use ImageKit's SDK to make requests... - print('Run image kit api') -except BadRequestException, e: - # Missing or Invalid parameters were supplied to Imagekit.io's API - print('Status is: ' + e.response_metadata.http_status_code) - print('Message is: ' + e.message) - print('Headers are: ' + e.response_metadata.headers) - print('Raw body is: ' + e.response_metadata.raw) -except UnauthorizedException, e: - print(e) -except ForbiddenException, e: - # No valid API key was provided. - print(e) -except TooManyRequestsException, e: - # Can be for the following reasons: - # ImageKit could not authenticate your account with the keys provided. - # An expired key (public or private) was used with the request. - # The account is disabled. - # If you use the upload API, the total storage limit (or upload limit) is exceeded. - print(e) -except InternalServerException, e: - # Too many requests made to the API too quickly - print(e) -except PartialSuccessException, e: - # Something went wrong with ImageKit.io API. - print(e) -except NotFoundException, e: - # Error cases on partial success. - print(e) -except UnknownException, e: - # If any of the field or parameter is not found in the data - print(e) - -# Something else happened, which can be unrelated to ImageKit; the reason will be indicated in the message field +```python +client.with_options(http_client=DefaultHttpxClient(...)) ``` -## Development - -### Tests +### Managing HTTP resources -Tests are powered by [Tox](https://tox.wiki/en/latest/). +By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. -```bash -$ git clone https://github.com/imagekit-developer/imagekit-python && cd imagekit-python -$ pip install tox -$ tox -``` - -### Sample +```py +from imagekitio import ImageKit -#### Get & Install local ImageKit Python SDK +with ImageKit() as client: + # make requests here + ... -```bash -$ git clone https://github.com/imagekit-developer/imagekit-python && cd imagekit-python -$ pip install -e . +# HTTP client is now closed ``` -#### Get samples +## Versioning -To integrate ImageKit Samples in the Python, the code samples covered here are hosted on GitHub - https://github.com/imagekit-samples/quickstart/tree/master/python. +This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: -Open the `python/sample.py` file and replace placeholder credentials with actual values. You can get the value of [URL-endpoint](https://imagekit.io/dashboard#url-endpoints) from your ImageKit dashboard. API keys can be obtained from the [developer](https://imagekit.io/dashboard/developer/api-keys) section in your ImageKit dashboard. +1. Changes that only affect static types, without breaking runtime behavior. +2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ +3. Changes that we do not expect to impact the vast majority of users in practice. -In the `python/sample.py` file, set the following parameters for authentication: +We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. -```python -from imagekitio import ImageKit -imagekit = ImageKit( - private_key='your private_key', - public_key='your public_key', - url_endpoint = 'your url_endpoint' -) -``` - -To install dependencies that are in the `python/requirements.txt` file, can fire this command to install them: - -```shell -pip install -r python/requirements.txt -``` +We are keen for your feedback; please open an [issue](https://www.github.com/stainless-sdks/imagekit-python/issues) with questions, bugs, or suggestions. -Now run `python/sample.py`. If you are using CLI Tool (Terminal/Command prompt), open the project in CLI and execute it. +### Determining the installed version -```shell -# if not installed already -pip install imagekitio +If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. -# if installing local sdk -pip install -e +You can determine the version that is being used at runtime with: -# to run sample.py file -python3 python/sample.py +```py +import imagekitio +print(imagekitio.__version__) ``` -## Support - -For any feedback or to report any issues or general implementation support, please reach out -to [support@imagekit.io](https://github.com/imagekit-developer/imagekit-python) - -## Links - -- [Documentation](https://docs.imagekit.io/) +## Requirements -- [Main Website](https://imagekit.io/) +Python 3.9 or higher. -## License +## Contributing -Released under the MIT license. +See [the contributing documentation](./CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8e64327a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Reporting Security Issues + +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. + +To report a security issue, please contact the Stainless team at security@stainless.com. + +## Responsible Disclosure + +We appreciate the efforts of security researchers and individuals who help us maintain the security of +SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible +disclosure practices by allowing us a reasonable amount of time to investigate and address the issue +before making any information public. + +## Reporting Non-SDK Related Security Issues + +If you encounter security issues that are not directly related to SDKs but pertain to the services +or products provided by Image Kit, please follow the respective company's security reporting guidelines. + +### Image Kit Terms and Policies + +Please contact developer@imagekit.io for any questions or concerns regarding the security of our services. + +--- + +Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/api.md b/api.md new file mode 100644 index 00000000..c88d631a --- /dev/null +++ b/api.md @@ -0,0 +1,266 @@ +# Shared Types + +```python +from imagekitio.types import ( + BaseOverlay, + Extensions, + GetImageAttributesOptions, + ImageOverlay, + Overlay, + OverlayPosition, + OverlayTiming, + ResponsiveImageAttributes, + SolidColorOverlay, + SolidColorOverlayTransformation, + SrcOptions, + StreamingResolution, + SubtitleOverlay, + SubtitleOverlayTransformation, + TextOverlay, + TextOverlayTransformation, + Transformation, + TransformationPosition, + VideoOverlay, +) +``` + +# Dummy + +Methods: + +- client.dummy.create(\*\*params) -> None + +# CustomMetadataFields + +Types: + +```python +from imagekitio.types import ( + CustomMetadataField, + CustomMetadataFieldListResponse, + CustomMetadataFieldDeleteResponse, +) +``` + +Methods: + +- client.custom_metadata_fields.create(\*\*params) -> CustomMetadataField +- client.custom_metadata_fields.update(id, \*\*params) -> CustomMetadataField +- client.custom_metadata_fields.list(\*\*params) -> CustomMetadataFieldListResponse +- client.custom_metadata_fields.delete(id) -> CustomMetadataFieldDeleteResponse + +# Files + +Types: + +```python +from imagekitio.types import ( + File, + Folder, + Metadata, + UpdateFileRequest, + FileUpdateResponse, + FileCopyResponse, + FileMoveResponse, + FileRenameResponse, + FileUploadResponse, +) +``` + +Methods: + +- client.files.update(file_id, \*\*params) -> FileUpdateResponse +- client.files.delete(file_id) -> None +- client.files.copy(\*\*params) -> FileCopyResponse +- client.files.get(file_id) -> File +- client.files.move(\*\*params) -> FileMoveResponse +- client.files.rename(\*\*params) -> FileRenameResponse +- client.files.upload(\*\*params) -> FileUploadResponse + +## Bulk + +Types: + +```python +from imagekitio.types.files import ( + BulkDeleteResponse, + BulkAddTagsResponse, + BulkRemoveAITagsResponse, + BulkRemoveTagsResponse, +) +``` + +Methods: + +- client.files.bulk.delete(\*\*params) -> BulkDeleteResponse +- client.files.bulk.add_tags(\*\*params) -> BulkAddTagsResponse +- client.files.bulk.remove_ai_tags(\*\*params) -> BulkRemoveAITagsResponse +- client.files.bulk.remove_tags(\*\*params) -> BulkRemoveTagsResponse + +## Versions + +Types: + +```python +from imagekitio.types.files import VersionListResponse, VersionDeleteResponse +``` + +Methods: + +- client.files.versions.list(file_id) -> VersionListResponse +- client.files.versions.delete(version_id, \*, file_id) -> VersionDeleteResponse +- client.files.versions.get(version_id, \*, file_id) -> File +- client.files.versions.restore(version_id, \*, file_id) -> File + +## Metadata + +Methods: + +- client.files.metadata.get(file_id) -> Metadata +- client.files.metadata.get_from_url(\*\*params) -> Metadata + +# Assets + +Types: + +```python +from imagekitio.types import AssetListResponse +``` + +Methods: + +- client.assets.list(\*\*params) -> AssetListResponse + +# Cache + +## Invalidation + +Types: + +```python +from imagekitio.types.cache import InvalidationCreateResponse, InvalidationGetResponse +``` + +Methods: + +- client.cache.invalidation.create(\*\*params) -> InvalidationCreateResponse +- client.cache.invalidation.get(request_id) -> InvalidationGetResponse + +# Folders + +Types: + +```python +from imagekitio.types import ( + FolderCreateResponse, + FolderDeleteResponse, + FolderCopyResponse, + FolderMoveResponse, + FolderRenameResponse, +) +``` + +Methods: + +- client.folders.create(\*\*params) -> FolderCreateResponse +- client.folders.delete(\*\*params) -> FolderDeleteResponse +- client.folders.copy(\*\*params) -> FolderCopyResponse +- client.folders.move(\*\*params) -> FolderMoveResponse +- client.folders.rename(\*\*params) -> FolderRenameResponse + +## Job + +Types: + +```python +from imagekitio.types.folders import JobGetResponse +``` + +Methods: + +- client.folders.job.get(job_id) -> JobGetResponse + +# Accounts + +## Usage + +Types: + +```python +from imagekitio.types.accounts import UsageGetResponse +``` + +Methods: + +- client.accounts.usage.get(\*\*params) -> UsageGetResponse + +## Origins + +Types: + +```python +from imagekitio.types.accounts import OriginRequest, OriginResponse, OriginListResponse +``` + +Methods: + +- client.accounts.origins.create(\*\*params) -> OriginResponse +- client.accounts.origins.update(id, \*\*params) -> OriginResponse +- client.accounts.origins.list() -> OriginListResponse +- client.accounts.origins.delete(id) -> None +- client.accounts.origins.get(id) -> OriginResponse + +## URLEndpoints + +Types: + +```python +from imagekitio.types.accounts import ( + URLEndpointRequest, + URLEndpointResponse, + URLEndpointListResponse, +) +``` + +Methods: + +- client.accounts.url_endpoints.create(\*\*params) -> URLEndpointResponse +- client.accounts.url_endpoints.update(id, \*\*params) -> URLEndpointResponse +- client.accounts.url_endpoints.list() -> URLEndpointListResponse +- client.accounts.url_endpoints.delete(id) -> None +- client.accounts.url_endpoints.get(id) -> URLEndpointResponse + +# Beta + +## V2 + +### Files + +Types: + +```python +from imagekitio.types.beta.v2 import FileUploadResponse +``` + +Methods: + +- client.beta.v2.files.upload(\*\*params) -> FileUploadResponse + +# Webhooks + +Types: + +```python +from imagekitio.types import ( + BaseWebhookEvent, + UploadPostTransformErrorEvent, + UploadPostTransformSuccessEvent, + UploadPreTransformErrorEvent, + UploadPreTransformSuccessEvent, + VideoTransformationAcceptedEvent, + VideoTransformationErrorEvent, + VideoTransformationReadyEvent, + UnsafeUnwrapWebhookEvent, + UnwrapWebhookEvent, +) +``` diff --git a/bin/publish-pypi b/bin/publish-pypi new file mode 100644 index 00000000..826054e9 --- /dev/null +++ b/bin/publish-pypi @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -eux +mkdir -p dist +rye build --clean +rye publish --yes --token=$PYPI_TOKEN diff --git a/examples/.keep b/examples/.keep new file mode 100644 index 00000000..d8c73e93 --- /dev/null +++ b/examples/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store example files demonstrating usage of this SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/imagekitio/__init__.py b/imagekitio/__init__.py deleted file mode 100644 index ba7a5020..00000000 --- a/imagekitio/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .client import ImageKit diff --git a/imagekitio/client.py b/imagekitio/client.py deleted file mode 100644 index c11a8df6..00000000 --- a/imagekitio/client.py +++ /dev/null @@ -1,252 +0,0 @@ -from typing import Any, Dict - -from .constants.errors import ERRORS -from .file import File -from .models.CopyFileRequestOptions import CopyFileRequestOptions -from .models.CopyFolderRequestOptions import CopyFolderRequestOptions -from .models.CreateCustomMetadataFieldsRequestOptions import ( - CreateCustomMetadataFieldsRequestOptions, -) -from .models.CreateFolderRequestOptions import CreateFolderRequestOptions -from .models.DeleteFolderRequestOptions import DeleteFolderRequestOptions -from .models.ListAndSearchFileRequestOptions import ListAndSearchFileRequestOptions -from .models.MoveFileRequestOptions import MoveFileRequestOptions -from .models.MoveFolderRequestOptions import MoveFolderRequestOptions -from .models.RenameFileRequestOptions import RenameFileRequestOptions -from .models.UpdateCustomMetadataFieldsRequestOptions import ( - UpdateCustomMetadataFieldsRequestOptions, -) -from .models.UpdateFileRequestOptions import UpdateFileRequestOptions -from .models.UploadFileRequestOptions import UploadFileRequestOptions -from .models.results.BulkDeleteFileResult import BulkDeleteFileResult -from .models.results.CustomMetadataFieldsResultWithResponseMetadata import ( - CustomMetadataFieldsResultWithResponseMetadata, -) -from .models.results.FileResultWithResponseMetadata import ( - FileResultWithResponseMetadata, -) -from .models.results.FolderResult import FolderResult -from .models.results.GetBulkJobStatusResult import GetBulkJobStatusResult -from .models.results.GetMetadataResult import GetMetadataResult -from .models.results.ListCustomMetadataFieldsResult import ( - ListCustomMetadataFieldsResult, -) -from .models.results.ListFileResult import ListFileResult -from .models.results.PurgeCacheResult import PurgeCacheResult -from .models.results.PurgeCacheStatusResult import PurgeCacheStatusResult -from .models.results.RenameFileResult import RenameFileResult -from .models.results.ResponseMetadataResult import ResponseMetadataResult -from .models.results.TagsResult import TagsResult -from .models.results.UploadFileResult import UploadFileResult -from .resource import ImageKitRequest -from .url import Url -from .utils.calculation import get_authenticated_params, hamming_distance - - -class ImageKit(object): - """ - Main Class What user will use by creating - instance - """ - - def __init__( - self, - public_key=None, - private_key=None, - url_endpoint=None, - transformation_position=None, - options=None, - ): - self.ik_request = ImageKitRequest( - private_key, public_key, url_endpoint, transformation_position, options - ) - self.file = File(self.ik_request) - self.url_obj = Url(self.ik_request) - - def upload(self, file=None, file_name=None, options=None) -> UploadFileResult: - """Provides upload functionality""" - return self.file.upload(file, file_name, options) - - def upload_file( - self, file=None, file_name=None, options: UploadFileRequestOptions = None - ) -> UploadFileResult: - """Provides upload functionality""" - return self.file.upload( - file, file_name, options if options is not None else None - ) - - def list_files( - self, options: ListAndSearchFileRequestOptions = None - ) -> ListFileResult: - """Get list(filtered if given param) of images of client""" - return self.file.list(options) - - def get_file_details(self, file_id: str = None) -> FileResultWithResponseMetadata: - """Get file_detail by file_id or file_url""" - return self.file.details(file_id) - - def get_file_versions(self, file_id: str = None) -> ListFileResult: - """Get file_version by file_id or file_url""" - return self.file.get_file_versions(file_id) - - def get_file_version_details( - self, file_id: str = None, version_id: str = None - ) -> FileResultWithResponseMetadata: - """Get file_version details by file_id and version_id""" - return self.file.get_file_version_details(file_id, version_id) - - def update_file_details( - self, file_id: str, options: UpdateFileRequestOptions = None - ) -> FileResultWithResponseMetadata: - """Update file details by file id and options""" - return self.file.update_file_details(file_id, options) - - def add_tags(self, file_ids, tags) -> TagsResult: - """Add tags by file ids and tags""" - return self.file.manage_tags(file_ids, tags, "addTags") - - def remove_tags(self, file_ids, tags) -> TagsResult: - """Remove tags by file ids and tags""" - return self.file.manage_tags(file_ids, tags, "removeTags") - - def remove_ai_tags(self, file_ids, ai_tags) -> TagsResult: - """Remove AI tags by file ids and AI tags""" - return self.file.remove_ai_tags(file_ids, ai_tags) - - def delete_file(self, file_id: str = None) -> ResponseMetadataResult: - """Delete file by file_id""" - return self.file.delete(file_id) - - def delete_file_version(self, file_id, version_id) -> ResponseMetadataResult: - """Delete file version by provided file id and version id""" - return self.file.delete_file_version(file_id, version_id) - - def bulk_delete(self, file_ids: list = None) -> BulkDeleteFileResult: - """Delete files in bulk by provided list of file ids""" - return self.file.batch_delete(file_ids) - - def bulk_file_delete(self, file_ids: list = None) -> BulkDeleteFileResult: - """Delete files in bulk by provided list of file ids""" - return self.file.batch_delete(file_ids) - - def copy_file( - self, options: CopyFileRequestOptions = None - ) -> ResponseMetadataResult: - """Copy file by provided sourceFilePath, destinationPath and includeFileVersions as an options""" - return self.file.copy_file(options) - - def move_file( - self, options: MoveFileRequestOptions = None - ) -> ResponseMetadataResult: - """Move file by provided sourceFilePath and destinationPath as an options""" - return self.file.move_file(options) - - def rename_file(self, options: RenameFileRequestOptions = None) -> RenameFileResult: - """Rename file by provided filePath, newFileName and purgeCache as an options""" - return self.file.rename_file(options) - - def restore_file_version( - self, file_id, version_id - ) -> FileResultWithResponseMetadata: - """Restore file version by provided file id and version id""" - return self.file.restore_file_version(file_id, version_id) - - def create_folder( - self, options: CreateFolderRequestOptions = None - ) -> ResponseMetadataResult: - """Create folder by provided folderName and parentFolderPath as an options""" - return self.file.create_folder(options) - - def delete_folder( - self, options: DeleteFolderRequestOptions = None - ) -> ResponseMetadataResult: - """Delete folder by provided folderPath as an options""" - return self.file.delete_folder(options) - - def copy_folder(self, options: CopyFolderRequestOptions = None) -> FolderResult: - """Copy folder by provided sourceFolderPath, destinationPath and includeFileVersions as an options""" - return self.file.copy_folder(options) - - def move_folder(self, options: MoveFolderRequestOptions = None) -> FolderResult: - """Move folder by provided sourceFolderPath and destinationPath as an options""" - return self.file.move_folder(options) - - def get_bulk_job_status(self, job_id) -> GetBulkJobStatusResult: - """Get bulk job status by provided only jobId""" - return self.file.get_bulk_job_status(job_id) - - def purge_cache(self, file_url: str = None) -> PurgeCacheResult: - """Purge Cache from server by file url""" - return self.file.purge_cache(file_url) - - def purge_file_cache(self, file_url: str = None) -> PurgeCacheResult: - """Purge Cache from server by file url""" - return self.file.purge_cache(file_url) - - def get_purge_cache_status( - self, purge_cache_id: str = "" - ) -> PurgeCacheStatusResult: - """Get Purge Cache status by purge cache request_id""" - return self.file.get_purge_cache_status(str(purge_cache_id)) - - def get_purge_file_cache_status( - self, purge_cache_id: str = "" - ) -> PurgeCacheStatusResult: - """Get Purge Cache status by purge cache request_id""" - return self.file.get_purge_cache_status(str(purge_cache_id)) - - def get_metadata(self, file_id: str = None) -> GetMetadataResult: - """Get Meta Data of a file by file id""" - return self.file.get_metadata(str(file_id)) - - def get_file_metadata(self, file_id: str = None) -> GetMetadataResult: - """Get Meta Data of a file by file id""" - return self.file.get_metadata(str(file_id)) - - def get_remote_url_metadata(self, remote_file_url: str = "") -> GetMetadataResult: - return self.file.get_metadata_from_remote_url(remote_file_url) - - def get_remote_file_url_metadata( - self, remote_file_url: str = "" - ) -> GetMetadataResult: - """Get remote metadata by provided remote_file_url""" - return self.file.get_metadata_from_remote_url(remote_file_url) - - def create_custom_metadata_fields( - self, options: CreateCustomMetadataFieldsRequestOptions = None - ) -> CustomMetadataFieldsResultWithResponseMetadata: - """creates custom metadata fields by passing name, label and schema as an options""" - return self.file.create_custom_metadata_fields(options) - - def get_custom_metadata_fields( - self, include_deleted: bool = False - ) -> ListCustomMetadataFieldsResult: - """get custom metadata fields""" - return self.file.get_custom_metadata_fields(include_deleted) - - def update_custom_metadata_fields( - self, field_id, options: UpdateCustomMetadataFieldsRequestOptions = None - ) -> CustomMetadataFieldsResultWithResponseMetadata: - """updates custom metadata fields by passing id of custom metadata field and params as an options""" - return self.file.update_custom_metadata_fields(field_id, options) - - def delete_custom_metadata_field( - self, field_id: str = "" - ) -> ResponseMetadataResult: - """Deletes custom metadata fields by passing field_id""" - return self.file.delete_custom_metadata_field(field_id) - - def url(self, options: Dict[str, Any]) -> str: - """Get generated Url from options parameter""" - return self.url_obj.generate_url(options) - - @staticmethod - def phash_distance(first, second): - """Get hamming distance between two phash(to check similarity)""" - if not (first and second): - raise TypeError(ERRORS.MISSING_PHASH_VALUE.value) - return hamming_distance(first, second) - - def get_authentication_parameters(self, token="", expire=0): - """Get Authentication parameters""" - return get_authenticated_params(token, expire, self.ik_request.private_key) diff --git a/imagekitio/constants/defaults.py b/imagekitio/constants/defaults.py deleted file mode 100644 index b5c00b93..00000000 --- a/imagekitio/constants/defaults.py +++ /dev/null @@ -1,19 +0,0 @@ -import enum - - -class Default(enum.Enum): - DEFAULT_TRANSFORMATION_POSITION = "path" - QUERY_TRANSFORMATION_POSITION = "query" - VALID_TRANSFORMATION_POSITION = [ - DEFAULT_TRANSFORMATION_POSITION, - QUERY_TRANSFORMATION_POSITION, - ] - DEFAULT_TIMESTAMP = 9999999999 - SDK_VERSION = "python-3.2.0" - TRANSFORMATION_PARAMETER = "tr" - CHAIN_TRANSFORM_DELIMITER = ":" - TRANSFORM_DELIMITER = "," - TRANSFORM_KEY_VALUE_DELIMITER = "-" - SIGNATURE_PARAMETER = "ik-s" - TIMESTAMP_PARAMETER = "ik-t" - IGNORE_CHARACTERS = '~@#$&()*!+=:;,?/\'' diff --git a/imagekitio/constants/errors.py b/imagekitio/constants/errors.py deleted file mode 100644 index 35e9cd79..00000000 --- a/imagekitio/constants/errors.py +++ /dev/null @@ -1,93 +0,0 @@ -import enum - - -class ERRORS(enum.Enum): - MANDATORY_INITIALIZATION_MISSING = { - "message": "Missing public_key or private_key or url_endpoint during ImageKit initialization", - "help": "", - } - INVALID_TRANSFORMATION_POSITION = { - "message": "Invalid transformationPosition parameter", - "help": "", - } - MANDATORY_SRC_OR_PATH = { - "message": "Pass one of the mandatory parameter path or src" - } - INVALID_URL_GENERATION_PARAMETER = { - "message": "Invalid url parameter", - "help": "" - } - INVALID_TRANSFORMATION_OPTIONS = { - "message": "Invalid transformation parameter options", - "help": "", - } - CACHE_PURGE_URL_MISSING = { - "message": "Missing URL parameter for this request", - "help": "", - } - CACHE_PURGE_STATUS_ID_MISSING = { - "message": "Missing Request ID parameter for this request", - "help": "", - } - FILE_ID_MISSING = { - "message": "Missing File ID parameter for this request", - "help": "", - } - UPDATE_DATA_MISSING = { - "message": "Missing file update data for this request", - "help": "", - } - UPDATE_DATA_TAGS_INVALID = { - "message": "Invalid tags parameter for this request", - "help": "tags should be passed as null or an array like ['tag1', 'tag2']", - } - UPDATE_DATA_COORDS_INVALID = ( - { - "message": "Invalid custom_coordinates parameter for this request", - "help": "custom_coordinates should be passed as null or a string like 'x,y,width,height'", - }, - ) - - LIST_FILES_INPUT_MISSING = { - "message": "Missing options for list files", - "help": "If you do not want to pass any parameter for listing, pass an empty object", - } - MISSING_FILE_URL = { - "message": "Missing file_url for purge_cache", - "help": "" - } - MISSING_UPLOAD_DATA = { - "message": "Missing data for upload", - "help": "" - - } - MISSING_UPLOAD_FILE_PARAMETER = { - "message": "Missing file parameter for upload", - "help": "", - } - MISSING_UPLOAD_FILENAME_PARAMETER = { - "message": "Missing fileName parameter for upload", - "help": "", - } - INVALID_PHASH_VALUE = ( - { - "message": "Invalid pHash value", - "help": "Both pHash strings must be valid hexadecimal numbers", - }, - ) - MISSING_PHASH_VALUE = { - "message": "Missing pHash value", - "help": "Please pass two pHash values", - } - UNEQUAL_STRING_LENGTH = { - "message": "Unequal pHash string length", - "help": "For distance calculation, the two pHash strings must have equal length", - } - VERSION_ID_MISSING = { - "message": "Missing Version ID parameter for this request", - "help": "", - } - MISSING_CUSTOM_METADATA_FIELD_ID = { - "message": "Missing field_id for update_custom_metadata_fields", - "help": "", - } diff --git a/imagekitio/constants/files.py b/imagekitio/constants/files.py deleted file mode 100644 index 3fbf3e1f..00000000 --- a/imagekitio/constants/files.py +++ /dev/null @@ -1,35 +0,0 @@ -VALID_FILE_OPTIONS = [ - "type", - "sort", - "path", - "searchQuery", - "fileType", - "limit", - "skip", - "tags", - "includeFolder", -] - -VALID_FILE_DETAIL_OPTIONS = ["fileID"] - -VALID_UPLOAD_OPTIONS = [ - "file", - "file_name", - "use_unique_file_name", - "tags", - "folder", - "is_private_file", - "custom_coordinates", - "response_fields", - "extensions", - "webhook_url", - "overwrite_file", - "overwrite_ai_tags", - "overwrite_tags", - "overwrite_custom_metadata", - "custom_metadata", - "embedded_metadata", - "transformation", - "checks", - "is_published", -] diff --git a/imagekitio/constants/supported_transform.py b/imagekitio/constants/supported_transform.py deleted file mode 100644 index d69c7dc7..00000000 --- a/imagekitio/constants/supported_transform.py +++ /dev/null @@ -1,33 +0,0 @@ -SUPPORTED_TRANS = { - "height": "h", - "width": "w", - "aspect_ratio": "ar", - "quality": "q", - "crop": "c", - "crop_mode": "cm", - "x": "x", - "y": "y", - "focus": "fo", - "format": "f", - "radius": "r", - "background": "bg", - "border": "b", - "rotation": "rt", - "blur": "bl", - "named": "n", - "progressive": "pr", - "lossless": "lo", - "trim": "t", - "metadata": "md", - "color_profile": "cp", - "default_image": "di", - "dpr": "dpr", - "effect_sharpen": "e-sharpen", - "effect_usm": "e-usm", - "effect_contrast": "e-contrast", - "effect_gray": "e-grayscale", - 'effect_shadow': "e-shadow", - 'effect_gradient': "e-gradient", - "original": "orig", - "raw": "raw", -} diff --git a/imagekitio/constants/url.py b/imagekitio/constants/url.py deleted file mode 100644 index f1fe6244..00000000 --- a/imagekitio/constants/url.py +++ /dev/null @@ -1,4 +0,0 @@ -class URL: - API_BASE_URL = "https://api.imagekit.io" - UPLOAD_BASE_URL = "https://upload.imagekit.io" - BULK_FILE_DELETE = "/batch/deleteByFileIds" diff --git a/imagekitio/exceptions/BadRequestException.py b/imagekitio/exceptions/BadRequestException.py deleted file mode 100644 index df61561b..00000000 --- a/imagekitio/exceptions/BadRequestException.py +++ /dev/null @@ -1,16 +0,0 @@ -from ..models.results.ResponseMetadata import ResponseMetadata - - -class BadRequestException(Exception): - def __init__( - self, - message, - response_help, - response_metadata: ResponseMetadata = ResponseMetadata(None, None, None), - ): - self.message = message - self.response_help = response_help - self.response_metadata = response_metadata - - def __str__(self): - return str(self.message) diff --git a/imagekitio/exceptions/ConflictException.py b/imagekitio/exceptions/ConflictException.py deleted file mode 100644 index 4d1fb93c..00000000 --- a/imagekitio/exceptions/ConflictException.py +++ /dev/null @@ -1,11 +0,0 @@ -from ..models.results.ResponseMetadata import ResponseMetadata - - -class ConflictException(Exception): - def __init__(self, message, response_help, response_metadata: ResponseMetadata): - self.message = message - self.response_help = response_help - self.response_metadata = response_metadata - - def __str__(self): - return str(self.message) diff --git a/imagekitio/exceptions/ForbiddenException.py b/imagekitio/exceptions/ForbiddenException.py deleted file mode 100644 index 6f8bc45c..00000000 --- a/imagekitio/exceptions/ForbiddenException.py +++ /dev/null @@ -1,11 +0,0 @@ -from ..models.results.ResponseMetadata import ResponseMetadata - - -class ForbiddenException(Exception): - def __init__(self, message, response_help, response_metadata: ResponseMetadata): - self.message = message - self.response_help = response_help - self.response_metadata = response_metadata - - def __str__(self): - return str(self.message) diff --git a/imagekitio/exceptions/InternalServerException.py b/imagekitio/exceptions/InternalServerException.py deleted file mode 100644 index e8410834..00000000 --- a/imagekitio/exceptions/InternalServerException.py +++ /dev/null @@ -1,11 +0,0 @@ -from ..models.results.ResponseMetadata import ResponseMetadata - - -class InternalServerException(Exception): - def __init__(self, message, response_help, response_metadata: ResponseMetadata): - self.message = message - self.response_help = response_help - self.response_metadata = response_metadata - - def __str__(self): - return str(self.message) diff --git a/imagekitio/exceptions/NotFoundException.py b/imagekitio/exceptions/NotFoundException.py deleted file mode 100644 index 0a37f526..00000000 --- a/imagekitio/exceptions/NotFoundException.py +++ /dev/null @@ -1,11 +0,0 @@ -from ..models.results.ResponseMetadata import ResponseMetadata - - -class NotFoundException(Exception): - def __init__(self, message, response_help, response_metadata: ResponseMetadata): - self.message = message - self.response_help = response_help - self.response_metadata = response_metadata - - def __str__(self): - return str(self.message) diff --git a/imagekitio/exceptions/PartialSuccessException.py b/imagekitio/exceptions/PartialSuccessException.py deleted file mode 100644 index 7ecf4c5f..00000000 --- a/imagekitio/exceptions/PartialSuccessException.py +++ /dev/null @@ -1,11 +0,0 @@ -from ..models.results.ResponseMetadata import ResponseMetadata - - -class PartialSuccessException(Exception): - def __init__(self, message, response_help, response_metadata: ResponseMetadata): - self.message = message - self.response_help = response_help - self.response_metadata = response_metadata - - def __str__(self): - return str(self.message) diff --git a/imagekitio/exceptions/TooManyRequestsException.py b/imagekitio/exceptions/TooManyRequestsException.py deleted file mode 100644 index 3405a76c..00000000 --- a/imagekitio/exceptions/TooManyRequestsException.py +++ /dev/null @@ -1,11 +0,0 @@ -from ..models.results.ResponseMetadata import ResponseMetadata - - -class TooManyRequestsException(Exception): - def __init__(self, message, response_help, response_metadata: ResponseMetadata): - self.message = message - self.response_help = response_help - self.response_metadata = response_metadata - - def __str__(self): - return str(self.message) diff --git a/imagekitio/exceptions/UnauthorizedException.py b/imagekitio/exceptions/UnauthorizedException.py deleted file mode 100644 index 66fd8f5a..00000000 --- a/imagekitio/exceptions/UnauthorizedException.py +++ /dev/null @@ -1,11 +0,0 @@ -from ..models.results.ResponseMetadata import ResponseMetadata - - -class UnauthorizedException(Exception): - def __init__(self, message, response_help, response_metadata: ResponseMetadata): - self.message = message - self.response_help = response_help - self.response_metadata = response_metadata - - def __str__(self): - return str(self.message) diff --git a/imagekitio/exceptions/UnknownException.py b/imagekitio/exceptions/UnknownException.py deleted file mode 100644 index ff507a2c..00000000 --- a/imagekitio/exceptions/UnknownException.py +++ /dev/null @@ -1,11 +0,0 @@ -from ..models.results.ResponseMetadata import ResponseMetadata - - -class UnknownException(Exception): - def __init__(self, message, response_help, response_metadata: ResponseMetadata): - self.message = message - self.response_help = response_help - self.response_metadata = response_metadata - - def __str__(self): - return str(self.message) diff --git a/imagekitio/exceptions/__init__.py b/imagekitio/exceptions/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/imagekitio/file.py b/imagekitio/file.py deleted file mode 100644 index ad29a788..00000000 --- a/imagekitio/file.py +++ /dev/null @@ -1,829 +0,0 @@ -import ast -from json import dumps -from typing import Any, Dict - -from requests_toolbelt import MultipartEncoder - -from .constants.errors import ERRORS -from .constants.files import VALID_FILE_OPTIONS, VALID_UPLOAD_OPTIONS -from .constants.url import URL -from .exceptions.BadRequestException import BadRequestException -from .exceptions.ConflictException import ConflictException -from .exceptions.NotFoundException import NotFoundException -from .exceptions.UnknownException import UnknownException -from .models.CopyFileRequestOptions import CopyFileRequestOptions -from .models.CopyFolderRequestOptions import CopyFolderRequestOptions -from .models.CreateCustomMetadataFieldsRequestOptions import ( - CreateCustomMetadataFieldsRequestOptions, -) -from .models.CreateFolderRequestOptions import CreateFolderRequestOptions -from .models.DeleteFolderRequestOptions import DeleteFolderRequestOptions -from .models.ListAndSearchFileRequestOptions import ListAndSearchFileRequestOptions -from .models.MoveFileRequestOptions import MoveFileRequestOptions -from .models.MoveFolderRequestOptions import MoveFolderRequestOptions -from .models.RenameFileRequestOptions import RenameFileRequestOptions -from .models.UpdateCustomMetadataFieldsRequestOptions import ( - UpdateCustomMetadataFieldsRequestOptions, -) -from .models.UpdateFileRequestOptions import UpdateFileRequestOptions -from .models.UploadFileRequestOptions import UploadFileRequestOptions -from .models.results.BulkDeleteFileResult import BulkDeleteFileResult -from .models.results.CustomMetadataFieldsResult import CustomMetadataFieldsResult -from .models.results.CustomMetadataFieldsResultWithResponseMetadata import ( - CustomMetadataFieldsResultWithResponseMetadata, -) -from .models.results.FileResult import FileResult -from .models.results.FileResultWithResponseMetadata import ( - FileResultWithResponseMetadata, -) -from .models.results.FolderResult import FolderResult -from .models.results.GetBulkJobStatusResult import GetBulkJobStatusResult -from .models.results.GetMetadataResult import GetMetadataResult -from .models.results.ListCustomMetadataFieldsResult import ( - ListCustomMetadataFieldsResult, -) -from .models.results.ListFileResult import ListFileResult -from .models.results.PurgeCacheResult import PurgeCacheResult -from .models.results.PurgeCacheStatusResult import PurgeCacheStatusResult -from .models.results.RenameFileResult import RenameFileResult -from .models.results.ResponseMetadataResult import ResponseMetadataResult -from .models.results.TagsResult import TagsResult -from .models.results.UploadFileResult import UploadFileResult -from .utils.formatter import ( - request_formatter, - snake_to_lower_camel, -) -from .utils.utils import ( - general_api_throw_exception, - get_response_json, - populate_response_metadata, - convert_to_response_object, - convert_to_list_response_object, - throw_other_exception, - convert_to_response_metadata_result_object, -) - -from io import BufferedReader - -class File(object): - def __init__(self, request_obj): - self.request = request_obj - - def upload( - self, file, file_name, options: UploadFileRequestOptions = None - ) -> UploadFileResult: - """Upload file to server using local image or url - :param file: either local file path or network file path - :param file_name: intended file name - :param options: intended options - :return: UploadFileResult - """ - if not file: - raise TypeError(ERRORS.MISSING_UPLOAD_FILE_PARAMETER.value) - if not file_name: - raise TypeError(ERRORS.MISSING_UPLOAD_FILENAME_PARAMETER.value) - url = "%s%s" % (URL.UPLOAD_BASE_URL, "/api/v1/files/upload") - headers = self.request.create_headers() - files = { - "file": file, - "fileName": file_name, - } - if not options: - options = dict() - else: - options = self.validate_upload(options.__dict__) - if options is False: - raise ValueError("Invalid upload options") - if isinstance(file,BufferedReader): - files.update({"file": (file_name,file,None)}) - elif isinstance(file, str) or isinstance(file, bytes): - files.update({"file": (None, file)}) - if "overwriteAiTags" in options: - options["overwriteAITags"] = options["overwriteAiTags"] - del options["overwriteAiTags"] - all_fields = {**files, **options} - multipart_data = MultipartEncoder( - fields=all_fields, boundary="--randomBoundary---------------------" - ) - headers.update({"Content-Type": multipart_data.content_type}) - resp = self.request.request( - "Post", url=url, data=multipart_data.read(), headers=headers - ) - if resp.status_code == 200: - response = convert_to_response_object(resp, UploadFileResult) - return response - else: - general_api_throw_exception(resp) - - def list(self, options: ListAndSearchFileRequestOptions = None) -> ListFileResult: - """Returns list files on ImageKit Server - :param: options dictionary of options - :return: ListFileResult - """ - if options is not None: - if "tags" in options.__dict__ and isinstance(options.tags, list): - val = ", ".join(options.tags) - if val: - options.tags = val - formatted_options = request_formatter(options.__dict__) - if not self.is_valid_list_options(formatted_options): - raise ValueError("Invalid option for list_files") - else: - formatted_options = dict() - url = "{}/v1/files".format(URL.API_BASE_URL) - headers = self.request.create_headers() - resp = self.request.request( - method="GET", url=url, headers=headers, params=formatted_options - ) - if resp.status_code == 200: - response = convert_to_list_response_object(resp, FileResult, ListFileResult) - return response - else: - general_api_throw_exception(resp) - - def details(self, file_id: str = None) -> FileResultWithResponseMetadata: - """returns file detail""" - if not file_id: - raise TypeError(ERRORS.FILE_ID_MISSING.value) - url = "{}/v1/files/{}/details".format(URL.API_BASE_URL, file_id) - resp = self.request.request( - method="GET", - url=url, - headers=self.request.create_headers(), - ) - if resp.status_code == 200: - response = convert_to_response_object(resp, FileResultWithResponseMetadata) - return response - else: - general_api_throw_exception(resp) - - def get_file_versions(self, file_id: str = None) -> ListFileResult: - """returns file versions""" - if not file_id: - raise TypeError(ERRORS.FILE_ID_MISSING.value) - url = "{}/v1/files/{}/versions".format(URL.API_BASE_URL, file_id) - resp = self.request.request( - method="GET", - url=url, - headers=self.request.create_headers(), - ) - if resp.status_code == 200: - response = convert_to_list_response_object(resp, FileResult, ListFileResult) - return response - elif resp.status_code == 404: - response_json = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response_json) == str: - response_json = ast.literal_eval(response_json) - error_message = ( - response_json["message"] if type(response_json) == dict else "" - ) - response_help = response_json["help"] if type(response_json) == dict else "" - raise NotFoundException(error_message, response_help, response_meta_data) - else: - general_api_throw_exception(resp) - - def get_file_version_details( - self, file_id: str = None, version_id: str = None - ) -> FileResultWithResponseMetadata: - """returns file version detail""" - if not file_id: - raise TypeError(ERRORS.FILE_ID_MISSING.value) - if not version_id: - raise TypeError(ERRORS.VERSION_ID_MISSING.value) - url = "{}/v1/files/{}/versions/{}".format(URL.API_BASE_URL, file_id, version_id) - resp = self.request.request( - method="GET", - url=url, - headers=self.request.create_headers(), - ) - if resp.status_code == 200: - response = convert_to_response_object(resp, FileResultWithResponseMetadata) - return response - elif resp.status_code == 404: - response_json = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response_json) == str: - response_json = ast.literal_eval(response_json) - error_message = ( - response_json["message"] if type(response_json) == dict else "" - ) - response_help = response_json["help"] if type(response_json) == dict else "" - raise NotFoundException(error_message, response_help, response_meta_data) - else: - general_api_throw_exception(resp) - - def update_file_details( - self, file_id: str, options: UpdateFileRequestOptions = None - ) -> FileResultWithResponseMetadata: - """Update detail of a file(like tags, coordinates) - update details identified by file_id and options, - which is already uploaded - """ - if not file_id: - raise TypeError(ERRORS.FILE_ID_MISSING.value) - url = "{}/v1/files/{}/details/".format(URL.API_BASE_URL, file_id) - headers = {"Content-Type": "application/json"} - headers.update(self.request.get_auth_headers()) - formatted_options = request_formatter(options.__dict__) - if "removeAiTags" in formatted_options: - remove_ai_tags_dict = {"removeAITags": formatted_options["removeAiTags"]} - del formatted_options["removeAiTags"] - request_data = {**remove_ai_tags_dict, **formatted_options} - else: - request_data = formatted_options - data = dumps(request_data) if options is not None else dict() - resp = self.request.request(method="Patch", url=url, headers=headers, data=data) - if resp.status_code == 200: - response = convert_to_response_object(resp, FileResultWithResponseMetadata) - return response - else: - general_api_throw_exception(resp) - - def manage_tags(self, file_ids, tags, action) -> TagsResult: - """Add or Remove tags of files - :param file_ids: array of file ids - :param tags: array of tags - :param action: to identify call either for removeTags or addTags - """ - url = ( - "{}/v1/files/removeTags".format(URL.API_BASE_URL) - if action == "removeTags" - else "{}/v1/files/addTags".format(URL.API_BASE_URL) - ) - headers = {"Content-Type": "application/json"} - headers.update(self.request.get_auth_headers()) - data = dumps({"fileIds": file_ids, "tags": tags}) - resp = self.request.request(method="Post", url=url, headers=headers, data=data) - if resp.status_code == 200: - response = convert_to_response_object(resp, TagsResult) - return response - elif resp.status_code == 207 or resp.status_code == 404: - throw_other_exception(resp) - else: - general_api_throw_exception(resp) - - def remove_ai_tags(self, file_ids, ai_tags) -> TagsResult: - """Remove AI tags of files - :param file_ids: array of file ids - :param ai_tags: array of AI tags - """ - url = "{}/v1/files/removeAITags".format(URL.API_BASE_URL) - headers = {"Content-Type": "application/json"} - headers.update(self.request.get_auth_headers()) - data = dumps({"fileIds": file_ids, "AITags": ai_tags}) - resp = self.request.request(method="Post", url=url, headers=headers, data=data) - if resp.status_code == 200: - response = convert_to_response_object(resp, TagsResult) - return response - elif resp.status_code == 207 or resp.status_code == 404: - throw_other_exception(resp) - else: - general_api_throw_exception(resp) - - def delete(self, file_id: str = None) -> ResponseMetadataResult: - """Delete file by file_id - deletes file from imagekit server - """ - if not file_id: - raise TypeError(ERRORS.FILE_ID_MISSING.value) - url = "{}/v1/files/{}".format(URL.API_BASE_URL, file_id) - resp = self.request.request( - method="Delete", url=url, headers=self.request.create_headers() - ) - if resp.status_code == 204: - response = convert_to_response_metadata_result_object(resp) - return response - else: - general_api_throw_exception(resp) - - def delete_file_version(self, file_id, version_id) -> ResponseMetadataResult: - """Delete file version by file_id and version_id""" - url = "{}/v1/files/{}/versions/{}".format(URL.API_BASE_URL, file_id, version_id) - headers = {"Content-Type": "application/json"} - headers.update(self.request.create_headers()) - resp = self.request.request(method="Delete", url=url, headers=headers) - if resp.status_code == 204: - response = convert_to_response_metadata_result_object(resp) - return response - elif resp.status_code == 400 or resp.status_code == 404: - response = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response) == str: - response = ast.literal_eval(response) - error_message = response["message"] if type(response) == dict else "" - response_help = response["help"] if type(response) == dict else "" - if resp.status_code == 400: - raise BadRequestException( - error_message, response_help, response_meta_data - ) - elif resp.status_code == 404: - raise NotFoundException( - error_message, response_help, response_meta_data - ) - else: - general_api_throw_exception(resp) - - def batch_delete(self, file_ids: list = None) -> BulkDeleteFileResult: - """Delete bulk files - Delete files by batch ids - """ - if not file_ids: - raise ValueError("Need to pass ids in list") - url = URL.API_BASE_URL + "/v1/files" + URL.BULK_FILE_DELETE - headers = {"Content-Type": "application/json"} - headers.update(self.request.create_headers()) - data = dumps({"fileIds": file_ids}) - resp = self.request.request(method="POST", url=url, headers=headers, data=data) - if resp.status_code == 200: - response = convert_to_response_object(resp, BulkDeleteFileResult) - return response - elif resp.status_code == 207 or resp.status_code == 404: - throw_other_exception(resp) - else: - general_api_throw_exception(resp) - - def copy_file( - self, options: CopyFileRequestOptions = None - ) -> ResponseMetadataResult: - """Copy file by provided sourceFilePath, destinationPath and includeFileVersions as an options""" - url = "{}/v1/files/copy".format(URL.API_BASE_URL) - headers = {"Content-Type": "application/json"} - headers.update(self.request.create_headers()) - formatted_options = ( - dumps(request_formatter(options.__dict__)) - if options is not None - else dict() - ) - resp = self.request.request( - method="Post", url=url, headers=headers, data=formatted_options - ) - if resp.status_code == 204: - response = convert_to_response_metadata_result_object(resp) - return response - elif resp.status_code == 404: - response = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response) == str: - response = ast.literal_eval(response) - error_message = response["message"] if type(response) == dict else "" - response_help = response["help"] if type(response) == dict else "" - raise NotFoundException(error_message, response_help, response_meta_data) - else: - general_api_throw_exception(resp) - - def move_file( - self, options: MoveFileRequestOptions = None - ) -> ResponseMetadataResult: - """Move file by provided sourceFilePath and destinationPath as an options""" - url = "{}/v1/files/move".format(URL.API_BASE_URL) - headers = {"Content-Type": "application/json"} - headers.update(self.request.create_headers()) - formatted_options = ( - dumps(request_formatter(options.__dict__)) - if options is not None - else dict() - ) - resp = self.request.request( - method="Post", url=url, headers=headers, data=formatted_options - ) - if resp.status_code == 204: - response = convert_to_response_metadata_result_object(resp) - return response - elif resp.status_code == 404: - response = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response) == str: - response = ast.literal_eval(response) - error_message = response["message"] if type(response) == dict else "" - response_help = response["help"] if type(response) == dict else "" - raise NotFoundException(error_message, response_help, response_meta_data) - else: - general_api_throw_exception(resp) - - def rename_file(self, options: RenameFileRequestOptions = None) -> RenameFileResult: - """Rename file by provided filePath, newFileName and purgeCache as an options""" - url = "{}/v1/files/rename".format(URL.API_BASE_URL) - headers = {"Content-Type": "application/json"} - headers.update(self.request.create_headers()) - formatted_options = ( - dumps(request_formatter(options.__dict__)) - if options is not None - else dict() - ) - resp = self.request.request( - method="Put", url=url, headers=headers, data=formatted_options - ) - if resp.status_code == 200: - response = convert_to_response_object(resp, RenameFileResult) - return response - elif resp.status_code == 207 or resp.status_code == 404: - throw_other_exception(resp) - elif resp.status_code == 409: - response = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response) == str: - response = ast.literal_eval(response) - error_message = response["message"] if type(response) == dict else "" - response_help = response["help"] if type(response) == dict else "" - raise ConflictException(error_message, response_help, response_meta_data) - else: - general_api_throw_exception(resp) - - def restore_file_version( - self, file_id, version_id - ) -> FileResultWithResponseMetadata: - """Restore file by provided fileId and versionId""" - url = "{}/v1/files/{}/versions/{}/restore".format( - URL.API_BASE_URL, file_id, version_id - ) - headers = self.request.create_headers() - resp = self.request.request( - method="Put", - url=url, - headers=headers, - ) - if resp.status_code == 200: - response = convert_to_response_object(resp, FileResultWithResponseMetadata) - return response - elif resp.status_code == 404: - response = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response) == str: - response = ast.literal_eval(response) - error_message = response["message"] if type(response) == dict else "" - response_help = response["help"] if type(response) == dict else "" - raise NotFoundException(error_message, response_help, response_meta_data) - else: - general_api_throw_exception(resp) - - def create_folder( - self, options: CreateFolderRequestOptions = None - ) -> ResponseMetadataResult: - """Create folder by provided folderName and parentFolderPath as an options""" - url = "{}/v1/folder".format(URL.API_BASE_URL) - headers = self.request.create_headers() - headers.update({"Content-Type": "application/json"}) - formatted_data = ( - dumps(request_formatter(options.__dict__)) if options is not None else dict() - ) - resp = self.request.request( - method="Post", url=url, headers=headers, data=formatted_data - ) - if resp.status_code == 201: - response = convert_to_response_metadata_result_object(resp) - return response - else: - response = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response) == str: - response = ast.literal_eval(response) - error_message = response["message"] if type(response) == dict else "" - response_help = response["help"] if type(response) == dict else "" - raise UnknownException(error_message, response_help, response_meta_data) - - def delete_folder( - self, options: DeleteFolderRequestOptions = None - ) -> ResponseMetadataResult: - """Delete folder by provided folderPath as an options""" - url = "{}/v1/folder".format(URL.API_BASE_URL) - headers = self.request.create_headers() - headers.update({"Content-Type": "application/json"}) - formatted_data = ( - dumps(request_formatter(options.__dict__)) if options is not None else dict() - ) - resp = self.request.request( - method="Delete", url=url, headers=headers, data=formatted_data - ) - if resp.status_code == 204: - response = convert_to_response_metadata_result_object(resp) - return response - elif resp.status_code == 404: - response_json = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response_json) == str: - response_json = ast.literal_eval(response_json) - error_message = ( - response_json["message"] if type(response_json) == dict else "" - ) - response_help = response_json["help"] if type(response_json) == dict else "" - raise NotFoundException(error_message, response_help, response_meta_data) - else: - general_api_throw_exception(resp) - - def copy_folder(self, options: CopyFolderRequestOptions = None) -> FolderResult: - """Copy folder by provided sourceFolderPath, destinationPath and includeFileVersions as an options""" - url = "{}/v1/bulkJobs/copyFolder".format(URL.API_BASE_URL) - headers = self.request.create_headers() - headers.update({"Content-Type": "application/json"}) - formatted_data = ( - dumps(request_formatter(options.__dict__)) - if options is not None - else dict() - ) - resp = self.request.request( - method="Post", url=url, headers=headers, data=formatted_data - ) - if resp.status_code == 200: - response = convert_to_response_object(resp, FolderResult) - return response - elif resp.status_code == 404: - response_json = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response_json) == str: - response_json = ast.literal_eval(response_json) - error_message = ( - response_json["message"] if type(response_json) == dict else "" - ) - response_help = response_json["help"] if type(response_json) == dict else "" - raise NotFoundException(error_message, response_help, response_meta_data) - else: - general_api_throw_exception(resp) - - def move_folder(self, options: MoveFolderRequestOptions = None) -> FolderResult: - """Move folder by provided sourceFolderPath and destinationPath as an options""" - url = "{}/v1/bulkJobs/moveFolder".format(URL.API_BASE_URL) - headers = self.request.create_headers() - headers.update({"Content-Type": "application/json"}) - formatted_data = ( - dumps(request_formatter(options.__dict__)) - if options is not None - else dict() - ) - resp = self.request.request( - method="Post", url=url, headers=headers, data=formatted_data - ) - - if resp.status_code == 200: - response = convert_to_response_object(resp, FolderResult) - return response - elif resp.status_code == 404: - response_json = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response_json) == str: - response_json = ast.literal_eval(response_json) - error_message = ( - response_json["message"] if type(response_json) == dict else "" - ) - response_help = response_json["help"] if type(response_json) == dict else "" - raise NotFoundException(error_message, response_help, response_meta_data) - else: - general_api_throw_exception(resp) - - def get_bulk_job_status(self, job_id) -> GetBulkJobStatusResult: - """Get bulk job status by provided only jobId""" - url = "{}/v1/bulkJobs/{}".format(URL.API_BASE_URL, job_id) - headers = self.request.create_headers() - resp = self.request.request( - method="Get", - url=url, - headers=headers, - ) - if resp.status_code == 200: - response = convert_to_response_object(resp, GetBulkJobStatusResult) - return response - else: - general_api_throw_exception(resp) - - def purge_cache(self, file_url: str = None) -> PurgeCacheResult: - """Use from child class to purge cache""" - if not file_url: - raise TypeError(ERRORS.MISSING_FILE_URL.value) - url = URL.API_BASE_URL + "/v1/files/purge" - headers = {"Content-Type": "application/json"} - headers.update(self.request.get_auth_headers()) - body = {"url": file_url} - resp = self.request.request("Post", headers=headers, url=url, data=dumps(body)) - if resp.status_code == 201: - response = convert_to_response_object(resp, PurgeCacheResult) - return response - else: - general_api_throw_exception(resp) - - def get_purge_cache_status( - self, cache_request_id: str = None - ) -> PurgeCacheStatusResult: - """Get purge cache status by cache_request_id - :return: PurgeCacheStatusResult - """ - if not cache_request_id: - raise TypeError(ERRORS.CACHE_PURGE_STATUS_ID_MISSING.value) - - url = "{}/v1/files/purge/{}".format(URL.API_BASE_URL, cache_request_id) - headers = self.request.create_headers() - resp = self.request.request("GET", url, headers=headers) - if resp.status_code == 200: - response = convert_to_response_object(resp, PurgeCacheStatusResult) - return response - else: - general_api_throw_exception(resp) - - def get_metadata(self, file_id: str = None) -> GetMetadataResult: - """Get metadata by file_id""" - if not file_id: - raise TypeError(ERRORS.FILE_ID_MISSING.value) - - url = "{}/v1/files/{}/metadata".format(URL.API_BASE_URL, file_id) - resp = self.request.request("GET", url, headers=self.request.create_headers()) - if resp.status_code == 200: - response = convert_to_response_object(resp, GetMetadataResult) - return response - else: - general_api_throw_exception(resp) - - def get_metadata_from_remote_url(self, remote_file_url: str) -> GetMetadataResult: - """Get remote metadata by provided remote_file_url""" - if not remote_file_url: - raise ValueError("You must provide remote url") - url = "{}/v1/metadata".format(URL.API_BASE_URL) - param = {"url": remote_file_url} - resp = self.request.request( - "GET", url, headers=self.request.create_headers(), params=param - ) - if resp.status_code == 200: - response = convert_to_response_object(resp, GetMetadataResult) - return response - else: - general_api_throw_exception(resp) - - def create_custom_metadata_fields( - self, options: CreateCustomMetadataFieldsRequestOptions = None - ) -> CustomMetadataFieldsResultWithResponseMetadata: - """creates custom metadata fields by passing name, label and schema as an options""" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - if options is not None: - if "schema" in options.__dict__: - options.schema.__dict__ = request_formatter(options.schema.__dict__) - options_dict = options.__dict__ - if "schema" in options_dict: - options_dict["schema"] = options.schema.__dict__ - formatted_options = dumps(options_dict) - else: - formatted_options = dict() - - headers = {"Content-Type": "application/json"} - headers.update(self.request.create_headers()) - resp = self.request.request( - method="Post", url=url, headers=headers, data=formatted_options - ) - if resp.status_code == 201: - response = convert_to_response_object( - resp, CustomMetadataFieldsResultWithResponseMetadata - ) - return response - else: - response_json = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response_json) == str: - response_json = ast.literal_eval(response_json) - error_message = ( - response_json["message"] if type(response_json) == dict else "" - ) - response_help = response_json["help"] if type(response_json) == dict else "" - if resp.status_code == 400: - raise BadRequestException( - error_message, response_help, response_meta_data - ) - else: - raise UnknownException(error_message, response_help, response_meta_data) - - def get_custom_metadata_fields( - self, include_deleted: bool = False - ) -> ListCustomMetadataFieldsResult: - """get custom metadata fields""" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - param = {"includeDeleted": str(include_deleted).lower()} - resp = self.request.request( - method="GET", url=url, headers=self.request.create_headers(), params=param - ) - if resp.status_code == 200: - response = convert_to_list_response_object( - resp, CustomMetadataFieldsResult, ListCustomMetadataFieldsResult - ) - return response - else: - response = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response) == str: - response = ast.literal_eval(response) - error_message = response["message"] if type(response) == dict else "" - response_help = response["help"] if type(response) == dict else "" - raise UnknownException(error_message, response_help, response_meta_data) - - def update_custom_metadata_fields( - self, field_id, options: UpdateCustomMetadataFieldsRequestOptions = None - ) -> CustomMetadataFieldsResultWithResponseMetadata: - """updates custom metadata fields by passing id of custom metadata field and params as an options""" - if not field_id: - raise ValueError(ERRORS.MISSING_CUSTOM_METADATA_FIELD_ID) - url = "{}/v1/customMetadataFields/{}".format(URL.API_BASE_URL, field_id) - if "schema" in options.__dict__: - options.schema.__dict__ = request_formatter(options.schema.__dict__) - options_dict = options.__dict__ - if "schema" in options_dict: - options_dict["schema"] = options.schema.__dict__ - formatted_options = dumps(request_formatter(options_dict)) - headers = {"Content-Type": "application/json"} - headers.update(self.request.create_headers()) - resp = self.request.request( - method="Patch", url=url, headers=headers, data=formatted_options - ) - if resp.status_code == 200: - response = convert_to_response_object( - resp, CustomMetadataFieldsResultWithResponseMetadata - ) - return response - else: - response_json = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response_json) == str: - response_json = ast.literal_eval(response_json) - error_message = ( - response_json["message"] if type(response_json) == dict else "" - ) - response_help = response_json["help"] if type(response_json) == dict else "" - if resp.status_code == 400: - raise BadRequestException( - error_message, response_help, response_meta_data - ) - elif resp.status_code == 404: - raise NotFoundException( - error_message, response_help, response_meta_data - ) - else: - raise UnknownException(error_message, response_help, response_meta_data) - - def delete_custom_metadata_field(self, field_id: str) -> ResponseMetadataResult: - """Deletes custom metadata fields by passing field_id""" - url = "{}/v1/customMetadataFields/{}".format(URL.API_BASE_URL, field_id) - resp = self.request.request( - "Delete", url, headers=self.request.create_headers() - ) - if resp.status_code == 204: - response = convert_to_response_metadata_result_object(resp) - return response - else: - response_json = get_response_json(resp) - response_meta_data = populate_response_metadata(resp) - if type(response_json) == str: - response_json = ast.literal_eval(response_json) - error_message = ( - response_json["message"] if type(response_json) == dict else "" - ) - response_help = response_json["help"] if type(response_json) == dict else "" - if resp.status_code == 404: - raise NotFoundException( - error_message, response_help, response_meta_data - ) - else: - raise UnknownException(error_message, response_help, response_meta_data) - - def is_valid_list_options(self, options: Dict[str, Any]) -> bool: - """Returns if options are valid""" - valid_values = self.get_valid_list_values() - for key in options: - if key not in valid_values: - return False - return True - - @staticmethod - def get_valid_list_values(): - """Returns valid options for list files""" - return VALID_FILE_OPTIONS - - @staticmethod - def validate_upload(options): - """ - Validates upload value, checks if params are valid, - changes snake to camel case - """ - response_list = [] - for key, val in options.items(): - if key not in VALID_UPLOAD_OPTIONS: - return False - if type(val) == dict or type(val) == tuple: - options[key] = dumps(val) - continue - if key == "extensions": - options[key] = dumps(val) - continue - if key == "response_fields": - for i, j in enumerate(options[key]): - if j not in VALID_UPLOAD_OPTIONS: - return False - response_list.append(snake_to_lower_camel(j)) - val = ",".join(response_list) - if val: - options[key] = ",".join(response_list) - continue - if isinstance(val, list): - val = ",".join([str(i) for i in val]) - if val: - options[key] = val - continue - # imagekit server accepts 'true/false' - elif isinstance(val, bool): - val = str(val).lower() - if val: - options[key] = val - return request_formatter(options) diff --git a/imagekitio/models/CopyFileRequestOptions.py b/imagekitio/models/CopyFileRequestOptions.py deleted file mode 100644 index 0a47b605..00000000 --- a/imagekitio/models/CopyFileRequestOptions.py +++ /dev/null @@ -1,13 +0,0 @@ -class CopyFileRequestOptions: - def __init__( - self, - source_file_path: str = None, - destination_path: str = None, - include_file_versions: bool = None, - ): - if source_file_path is not None: - self.source_file_path = source_file_path - if destination_path is not None: - self.destination_path = destination_path - if include_file_versions is not None: - self.include_file_versions = include_file_versions diff --git a/imagekitio/models/CopyFolderRequestOptions.py b/imagekitio/models/CopyFolderRequestOptions.py deleted file mode 100644 index d4e4c306..00000000 --- a/imagekitio/models/CopyFolderRequestOptions.py +++ /dev/null @@ -1,13 +0,0 @@ -class CopyFolderRequestOptions: - def __init__( - self, - source_folder_path: str = None, - destination_path: str = None, - include_file_versions: bool = None, - ): - if source_folder_path is not None: - self.source_folder_path = source_folder_path - if destination_path is not None: - self.destination_path = destination_path - if include_file_versions is not None: - self.include_file_versions = include_file_versions diff --git a/imagekitio/models/CreateCustomMetadataFieldsRequestOptions.py b/imagekitio/models/CreateCustomMetadataFieldsRequestOptions.py deleted file mode 100644 index ffcc3bfa..00000000 --- a/imagekitio/models/CreateCustomMetadataFieldsRequestOptions.py +++ /dev/null @@ -1,36 +0,0 @@ -from ..models.CustomMetadataFieldsSchema import CustomMetadataFieldsSchema - - -class CreateCustomMetadataFieldsRequestOptions: - def __init__( - self, - name: str = None, - label: str = None, - schema: CustomMetadataFieldsSchema = None, - ): - self.name = name - self.label = label - if schema is None or schema == {}: - CustomMetadataFieldsSchema(None, None, None, None, None, None, None, None) - else: - if type(schema) == CustomMetadataFieldsSchema: - self.schema = schema - else: - self.schema = ( - CustomMetadataFieldsSchema( - schema["type"] if "type" in schema else None, - schema["select_options"] - if "select_options" in schema - else None, - schema["default_value"] if "default_value" in schema else None, - schema["is_value_required"] - if "is_value_required" in schema - else None, - schema["min_value"] if "min_value" in schema else None, - schema["max_value"] if "max_value" in schema else None, - schema["min_length"] if "min_length" in schema else None, - schema["max_length"], - ) - if "max_length" in schema - else None - ) diff --git a/imagekitio/models/CreateFolderRequestOptions.py b/imagekitio/models/CreateFolderRequestOptions.py deleted file mode 100644 index de3fcef6..00000000 --- a/imagekitio/models/CreateFolderRequestOptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class CreateFolderRequestOptions: - def __init__(self, folder_name: str = None, parent_folder_path: str = None): - if folder_name is not None: - self.folder_name = folder_name - if parent_folder_path is not None: - self.parent_folder_path = parent_folder_path diff --git a/imagekitio/models/CustomMetaDataTypeEnum.py b/imagekitio/models/CustomMetaDataTypeEnum.py deleted file mode 100644 index 9740e587..00000000 --- a/imagekitio/models/CustomMetaDataTypeEnum.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import Enum - - -class CustomMetaDataTypeEnum(Enum): - Text = 1 - Textarea = 2 - Number = 3 - Date = 4 - Boolean = 5 - SingleSelect = 6 - MultiSelect = 7 diff --git a/imagekitio/models/CustomMetadataFieldsSchema.py b/imagekitio/models/CustomMetadataFieldsSchema.py deleted file mode 100644 index 9f667a63..00000000 --- a/imagekitio/models/CustomMetadataFieldsSchema.py +++ /dev/null @@ -1,31 +0,0 @@ -from ..models.CustomMetaDataTypeEnum import CustomMetaDataTypeEnum - - -class CustomMetadataFieldsSchema: - def __init__( - self, - type: CustomMetaDataTypeEnum = None, - select_options=None, - default_value=None, - is_value_required: bool = None, - min_value=None, - max_value=None, - min_length: int = None, - max_length: int = None, - ): - if type is not None: - self.type = type.name - if select_options is not None: - self.select_options = select_options - if default_value is not None: - self.default_value = default_value - if is_value_required is not None: - self.is_value_required = is_value_required - if min_value is not None: - self.min_value = min_value - if max_value is not None: - self.max_value = max_value - if min_length is not None: - self.min_length = min_length - if max_length is not None: - self.max_length = max_length diff --git a/imagekitio/models/DeleteFolderRequestOptions.py b/imagekitio/models/DeleteFolderRequestOptions.py deleted file mode 100644 index dd1fcd67..00000000 --- a/imagekitio/models/DeleteFolderRequestOptions.py +++ /dev/null @@ -1,4 +0,0 @@ -class DeleteFolderRequestOptions: - def __init__(self, folder_path: str = None): - if folder_path is not None: - self.folder_path = folder_path diff --git a/imagekitio/models/ListAndSearchFileRequestOptions.py b/imagekitio/models/ListAndSearchFileRequestOptions.py deleted file mode 100644 index 3b834f2a..00000000 --- a/imagekitio/models/ListAndSearchFileRequestOptions.py +++ /dev/null @@ -1,31 +0,0 @@ -import array - - -class ListAndSearchFileRequestOptions: - def __init__( - self, - type: str = None, - sort: str = None, - path: str = None, - search_query: str = None, - file_type: str = None, - limit: int = None, - skip: int = None, - tags=None, - ): - if type is not None: - self.type = type - if sort is not None: - self.sort = sort - if path is not None: - self.path = path - if search_query is not None: - self.search_query = search_query - if file_type is not None: - self.file_type = file_type - if limit is not None: - self.limit = limit - if skip is not None: - self.skip = skip - if tags is not None: - self.tags = tags diff --git a/imagekitio/models/MoveFileRequestOptions.py b/imagekitio/models/MoveFileRequestOptions.py deleted file mode 100644 index cf68f31f..00000000 --- a/imagekitio/models/MoveFileRequestOptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class MoveFileRequestOptions: - def __init__(self, source_file_path: str = None, destination_path: str = None): - if source_file_path is not None: - self.source_file_path = source_file_path - if destination_path is not None: - self.destination_path = destination_path diff --git a/imagekitio/models/MoveFolderRequestOptions.py b/imagekitio/models/MoveFolderRequestOptions.py deleted file mode 100644 index bfd9c58c..00000000 --- a/imagekitio/models/MoveFolderRequestOptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class MoveFolderRequestOptions: - def __init__(self, source_folder_path: str = None, destination_path: str = None): - if source_folder_path is not None: - self.source_folder_path = source_folder_path - if destination_path is not None: - self.destination_path = destination_path diff --git a/imagekitio/models/RenameFileRequestOptions.py b/imagekitio/models/RenameFileRequestOptions.py deleted file mode 100644 index 60d74b4d..00000000 --- a/imagekitio/models/RenameFileRequestOptions.py +++ /dev/null @@ -1,10 +0,0 @@ -class RenameFileRequestOptions: - def __init__( - self, file_path: str = None, new_file_name: str = None, purge_cache: bool = None - ): - if file_path is not None: - self.file_path = file_path - if new_file_name is not None: - self.new_file_name = new_file_name - if purge_cache is not None: - self.purge_cache = purge_cache diff --git a/imagekitio/models/UpdateCustomMetadataFieldsRequestOptions.py b/imagekitio/models/UpdateCustomMetadataFieldsRequestOptions.py deleted file mode 100644 index 92294a16..00000000 --- a/imagekitio/models/UpdateCustomMetadataFieldsRequestOptions.py +++ /dev/null @@ -1,30 +0,0 @@ -from ..models.CustomMetadataFieldsSchema import CustomMetadataFieldsSchema - - -class UpdateCustomMetadataFieldsRequestOptions: - def __init__(self, label: str = None, schema: CustomMetadataFieldsSchema = None): - self.label = label - if schema is None or schema == {}: - CustomMetadataFieldsSchema(None, None, None, None, None, None, None, None) - else: - if type(schema) == CustomMetadataFieldsSchema: - self.schema = schema - else: - self.schema = ( - CustomMetadataFieldsSchema( - schema["type"] if "type" in schema else None, - schema["select_options"] - if "select_options" in schema - else None, - schema["default_value"] if "default_value" in schema else None, - schema["is_value_required"] - if "is_value_required" in schema - else None, - schema["min_value"] if "min_value" in schema else None, - schema["max_value"] if "max_value" in schema else None, - schema["min_length"] if "min_length" in schema else None, - schema["max_length"], - ) - if "max_length" in schema - else None - ) diff --git a/imagekitio/models/UpdateFileRequestOptions.py b/imagekitio/models/UpdateFileRequestOptions.py deleted file mode 100644 index c1f5f3d1..00000000 --- a/imagekitio/models/UpdateFileRequestOptions.py +++ /dev/null @@ -1,29 +0,0 @@ -import json -from typing import List - - -class UpdateFileRequestOptions: - def __init__( - self, - remove_ai_tags: List[str] = None, - webhook_url: str = None, - extensions: json = None, - tags: List[str] = None, - custom_coordinates: str = None, - custom_metadata: json = None, - publish: json = None, - ): - if publish is not None: - self.publish = publish - if remove_ai_tags is not None: - self.remove_ai_tags = remove_ai_tags - if webhook_url is not None: - self.webhook_url = webhook_url - if extensions is not None: - self.extensions = extensions - if tags is not None: - self.tags = tags - if custom_coordinates is not None: - self.custom_coordinates = custom_coordinates - if custom_metadata is not None: - self.custom_metadata = custom_metadata diff --git a/imagekitio/models/UploadFileRequestOptions.py b/imagekitio/models/UploadFileRequestOptions.py deleted file mode 100644 index 98bd0594..00000000 --- a/imagekitio/models/UploadFileRequestOptions.py +++ /dev/null @@ -1,56 +0,0 @@ -import json -from typing import List - - -class UploadFileRequestOptions: - def __init__( - self, - use_unique_file_name: bool = None, - tags: List[str] = None, - folder: str = None, - is_private_file: bool = None, - custom_coordinates: str = None, - response_fields: List[str] = None, - extensions: json = None, - webhook_url: str = None, - overwrite_file: bool = None, - overwrite_ai_tags: bool = None, - overwrite_tags: bool = None, - overwrite_custom_metadata: bool = None, - custom_metadata: json = None, - transformation: json = None, - checks: str = None, - is_published: bool = None, - ): - if use_unique_file_name is not None: - self.use_unique_file_name = use_unique_file_name - if tags is not None: - self.tags = tags - if folder is not None: - self.folder = folder - if is_private_file is not None: - self.is_private_file = is_private_file - if custom_coordinates is not None: - self.custom_coordinates = custom_coordinates - if response_fields is not None: - self.response_fields = response_fields - if extensions is not None: - self.extensions = extensions - if webhook_url is not None: - self.webhook_url = webhook_url - if overwrite_file is not None: - self.overwrite_file = overwrite_file - if overwrite_ai_tags is not None: - self.overwrite_ai_tags = overwrite_ai_tags - if overwrite_tags is not None: - self.overwrite_tags = overwrite_tags - if overwrite_custom_metadata is not None: - self.overwrite_custom_metadata = overwrite_custom_metadata - if custom_metadata is not None: - self.custom_metadata = custom_metadata - if transformation is not None: - self.transformation = transformation - if checks is not None: - self.checks = checks - if is_published is not None: - self.is_published = is_published diff --git a/imagekitio/models/__init__.py b/imagekitio/models/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/imagekitio/models/results/AITags.py b/imagekitio/models/results/AITags.py deleted file mode 100644 index 19aaad44..00000000 --- a/imagekitio/models/results/AITags.py +++ /dev/null @@ -1,10 +0,0 @@ -class AITags: - def __init__(self, name=None, confidence=None, source=None,**kwargs): - self.name = name - self.confidence = confidence - self.source = source - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - diff --git a/imagekitio/models/results/BulkDeleteFileResult.py b/imagekitio/models/results/BulkDeleteFileResult.py deleted file mode 100644 index c5aab305..00000000 --- a/imagekitio/models/results/BulkDeleteFileResult.py +++ /dev/null @@ -1,20 +0,0 @@ -from .ResponseMetadata import ResponseMetadata - - -class BulkDeleteFileResult: - def __init__(self, successfully_deleted_file_ids=None,**kwargs): - self.successfully_deleted_file_ids = successfully_deleted_file_ids - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/CustomMetadataFieldsResult.py b/imagekitio/models/results/CustomMetadataFieldsResult.py deleted file mode 100644 index c96a4649..00000000 --- a/imagekitio/models/results/CustomMetadataFieldsResult.py +++ /dev/null @@ -1,23 +0,0 @@ -from .CustomMetadataSchema import CustomMetadataSchema -from ...utils.utils import camel_dict_to_snake_dict - -class CustomMetadataFieldsResult: - def __init__( - self, - id=None, - name=None, - label=None, - schema: dict = {}, - **kwargs - ): - self.id = id - self.name = name - self.label = label - self.schema = CustomMetadataSchema( - **camel_dict_to_snake_dict(schema) - ) - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - diff --git a/imagekitio/models/results/CustomMetadataFieldsResultWithResponseMetadata.py b/imagekitio/models/results/CustomMetadataFieldsResultWithResponseMetadata.py deleted file mode 100644 index d7d611b2..00000000 --- a/imagekitio/models/results/CustomMetadataFieldsResultWithResponseMetadata.py +++ /dev/null @@ -1,18 +0,0 @@ -from .CustomMetadataFieldsResult import CustomMetadataFieldsResult -from .CustomMetadataSchema import CustomMetadataSchema -from .ResponseMetadata import ResponseMetadata - - -class CustomMetadataFieldsResultWithResponseMetadata(CustomMetadataFieldsResult): - def __init__( - self, - id=None, - name=None, - label=None, - schema:dict = {}, - **kwargs - ): - super(CustomMetadataFieldsResultWithResponseMetadata, self).__init__( - id, name, label, schema,**kwargs - ) - self.response_metadata: ResponseMetadata = ResponseMetadata("", "", "") diff --git a/imagekitio/models/results/CustomMetadataSchema.py b/imagekitio/models/results/CustomMetadataSchema.py deleted file mode 100644 index 76e42a5b..00000000 --- a/imagekitio/models/results/CustomMetadataSchema.py +++ /dev/null @@ -1,27 +0,0 @@ -class CustomMetadataSchema: - def __init__( - self, - type=None, - select_options=None, - default_value=None, - is_value_required=None, - min_value=None, - max_value=None, - min_length=None, - max_length=None, - **kwargs - - ): - self.type = type - self.select_options = select_options - self.default_value = default_value - self.is_value_required = is_value_required - self.min_value = min_value - self.max_value = max_value - self.min_length = min_length - self.max_length = max_length - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - diff --git a/imagekitio/models/results/EmbeddedMetadata.py b/imagekitio/models/results/EmbeddedMetadata.py deleted file mode 100644 index d6828b6e..00000000 --- a/imagekitio/models/results/EmbeddedMetadata.py +++ /dev/null @@ -1,15 +0,0 @@ -class EmbeddedMetadata: - - def __init__(self, x_resolution=None, y_resolution=None, date_created=None, date_time_created=None,**kwargs): - self.x_resolution = x_resolution - self.y_resolution = y_resolution - self.date_created = date_created - self.date_time_created = date_time_created - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - - def __getattr__(self,attr): - return None - - - diff --git a/imagekitio/models/results/FileResult.py b/imagekitio/models/results/FileResult.py deleted file mode 100644 index 6431eb15..00000000 --- a/imagekitio/models/results/FileResult.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import List -from .AITags import AITags -from .VersionInfo import VersionInfo -from ...utils.utils import camel_dict_to_snake_dict - -class FileResult: - def __init__( - self, - type=None, - name=None, - created_at=None, - updated_at=None, - file_id=None, - tags=None, - ai_tags: List[AITags] = [], - version_info: dict = {}, - embedded_metadata: dict = {}, - custom_coordinates: str = "", - custom_metadata: dict = {}, - is_private_file=False, - url: str = "", - thumbnail: str = "", - file_type: str = "", - file_path: str = "", - height: int = None, - width: int = None, - size: int = None, - has_alpha=False, - mime: str = None, - extension_status: dict = {}, - **kwargs - ): - self.type = type - self.name = name - self.created_at = created_at - self.updated_at = updated_at - self.file_id = file_id - self.tags = tags - self.ai_tags: List[AITags] = [] - if ai_tags is not None: - for i in ai_tags: - self.ai_tags.append( - AITags( - **camel_dict_to_snake_dict(i) - ) - ) - self.version_info = VersionInfo(**camel_dict_to_snake_dict(version_info)) - self.embedded_metadata = embedded_metadata - self.custom_coordinates = custom_coordinates - self.custom_metadata = custom_metadata - self.is_private_file = is_private_file - self.url = url - self.thumbnail = thumbnail - self.file_type = file_type - self.file_path = file_path - self.height = height - self.width = width - self.size = size - self.has_alpha = has_alpha - self.mime = mime - self.extension_status = extension_status - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - diff --git a/imagekitio/models/results/FileResultWithResponseMetadata.py b/imagekitio/models/results/FileResultWithResponseMetadata.py deleted file mode 100644 index f4242997..00000000 --- a/imagekitio/models/results/FileResultWithResponseMetadata.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import List - -from .AITags import AITags -from .FileResult import FileResult -from .ResponseMetadata import ResponseMetadata -from .VersionInfo import VersionInfo - - -class FileResultWithResponseMetadata(FileResult): - def __init__( - self, - type=None, - name=None, - created_at=None, - updated_at=None, - file_id=None, - tags=None, - ai_tags: List[AITags] = [], - version_info: dict = {}, - embedded_metadata=None, - custom_coordinates: str = "", - custom_metadata=None, - is_private_file=False, - url: str = "", - thumbnail: str = "", - file_type: str = "", - file_path: str = "", - height: int = None, - width: int = None, - size: int = None, - has_alpha=False, - mime: str = None, - extension_status=None, - **kwargs - ): - super().__init__( - type, - name, - created_at, - updated_at, - file_id, - tags, - ai_tags, - version_info, - embedded_metadata, - custom_coordinates, - custom_metadata, - is_private_file, - url, - thumbnail, - file_type, - file_path, - height, - width, - size, - has_alpha, - mime, - extension_status, - **kwargs - ) - self.response_metadata: ResponseMetadata = ResponseMetadata("", "", "") diff --git a/imagekitio/models/results/FolderResult.py b/imagekitio/models/results/FolderResult.py deleted file mode 100644 index b720245e..00000000 --- a/imagekitio/models/results/FolderResult.py +++ /dev/null @@ -1,20 +0,0 @@ -from .ResponseMetadata import ResponseMetadata - - -class FolderResult: - def __init__(self, job_id=None,**kwargs): - self.job_id = job_id - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/GetBulkJobStatusResult.py b/imagekitio/models/results/GetBulkJobStatusResult.py deleted file mode 100644 index 8df6e1ef..00000000 --- a/imagekitio/models/results/GetBulkJobStatusResult.py +++ /dev/null @@ -1,22 +0,0 @@ -from .ResponseMetadata import ResponseMetadata - - -class GetBulkJobStatusResult: - def __init__(self, job_id=None, type=None, status=None,**kwargs): - self.job_id = job_id - self.type = type - self.status = status - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/GetMetadataResult.py b/imagekitio/models/results/GetMetadataResult.py deleted file mode 100644 index 82e282f1..00000000 --- a/imagekitio/models/results/GetMetadataResult.py +++ /dev/null @@ -1,51 +0,0 @@ -from .MetadataExifExif import MetadataExifExif -from .MetadataExifGPS import MetadataExifGPS -from .MetadataExifInteroperability import MetadataExifInteroperability -from .MetadataExifThumbnail import MetadataExifThumbnail -from .MetadataExif import MetadataExif -from .MetadataExifImage import MetadataExifImage -from .ResponseMetadata import ResponseMetadata -from ...utils.utils import camel_dict_to_snake_dict - - -class GetMetadataResult: - def __init__( - self, - height=None, - width=None, - size=None, - format=None, - has_color_profile=None, - quality=None, - density=None, - has_transparency=None, - p_hash=None, - exif: dict = {}, - **kwargs - ): - self.height = height - self.width = width - self.size = size - self.format = format - self.has_color_profile = has_color_profile - self.quality = quality - self.density = density - self.has_transparency = has_transparency - self.p_hash = p_hash - self.exif: MetadataExif = MetadataExif( - ** camel_dict_to_snake_dict(exif) - ) - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/ListCustomMetadataFieldsResult.py b/imagekitio/models/results/ListCustomMetadataFieldsResult.py deleted file mode 100644 index 23ba45fb..00000000 --- a/imagekitio/models/results/ListCustomMetadataFieldsResult.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import List - -from .CustomMetadataFieldsResult import CustomMetadataFieldsResult -from .ResponseMetadata import ResponseMetadata - - -class ListCustomMetadataFieldsResult: - def __init__(self, list: List[CustomMetadataFieldsResult]): - self.list = list - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/ListFileResult.py b/imagekitio/models/results/ListFileResult.py deleted file mode 100644 index 7db8cce0..00000000 --- a/imagekitio/models/results/ListFileResult.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import List -from .FileResult import FileResult -from .ResponseMetadata import ResponseMetadata - - -class ListFileResult: - def __init__(self, list: List[FileResult] = None): - self.list = list - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/MetadataExif.py b/imagekitio/models/results/MetadataExif.py deleted file mode 100644 index d38add96..00000000 --- a/imagekitio/models/results/MetadataExif.py +++ /dev/null @@ -1,105 +0,0 @@ -from .MetadataExifExif import MetadataExifExif -from .MetadataExifGPS import MetadataExifGPS -from .MetadataExifInteroperability import MetadataExifInteroperability -from .MetadataExifThumbnail import MetadataExifThumbnail -from .MetadataExifImage import MetadataExifImage -from ...utils.utils import camel_dict_to_snake_dict - - -class MetadataExif: - def __init__( - self, - image: MetadataExifImage = None, - thumbnail: MetadataExifThumbnail = None, - exif: MetadataExifExif = None, - gps: MetadataExifGPS = None, - interoperability: MetadataExifInteroperability = None, - makernote=None, - ** kwargs - ): - if makernote is None: - makernote = {} - if image is None or image == {}: - self.image = MetadataExifImage( - None, None, None, None, None, None, None, None, None, None, None - ) - else: - if type(image) == MetadataExifImage: - self.image = image - else: - self.image = MetadataExifImage( - **camel_dict_to_snake_dict(image) - ) - - if thumbnail is None or thumbnail == {}: - self.thumbnail = MetadataExifThumbnail(None, None, None, None, None, None) - else: - if type(thumbnail) == MetadataExifThumbnail: - self.thumbnail = thumbnail - else: - self.thumbnail = MetadataExifThumbnail( - **camel_dict_to_snake_dict(thumbnail) - ) - if exif is None or exif == {}: - self.exif = MetadataExifExif( - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - ) - else: - if type(exif) == MetadataExifExif: - self.exif = exif - else: - self.exif = MetadataExifExif( - **camel_dict_to_snake_dict(exif) - ) - if gps is None or gps == {}: - self.gps = MetadataExifGPS(None) - else: - if type(gps) == MetadataExifGPS: - self.gps = gps - else: - self.gps = MetadataExifGPS( - **camel_dict_to_snake_dict(gps) - ) - - if interoperability is None or interoperability == {}: - self.interoperability = MetadataExifInteroperability(None, None) - else: - if type(interoperability) == MetadataExifInteroperability: - self.interoperability = interoperability - else: - self.interoperability = MetadataExifInteroperability( - **camel_dict_to_snake_dict(interoperability) - ) - - self.makernote = makernote - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None diff --git a/imagekitio/models/results/MetadataExifExif.py b/imagekitio/models/results/MetadataExifExif.py deleted file mode 100644 index fef646a5..00000000 --- a/imagekitio/models/results/MetadataExifExif.py +++ /dev/null @@ -1,65 +0,0 @@ -class MetadataExifExif: - def __init__( - self, - exposure_time=None, - f_number=None, - exposure_program=None, - iso=None, - exif_version=None, - date_time_original=None, - create_date=None, - shutter_speed_value=None, - aperture_value=None, - exposure_compensation=None, - metering_mode=None, - flash=None, - focal_length=None, - sub_sec_time=None, - sub_sec_time_original=None, - sub_sec_time_digitized=None, - flashpix_version=None, - color_space=None, - exif_image_width=None, - exif_image_height=None, - interop_offset=None, - focal_plane_x_resolution=None, - focal_plane_y_resolution=None, - focal_plane_resolution_unit=None, - custom_rendered=None, - exposure_mode=None, - white_balance=None, - scene_capture_type=None, - **kwargs - ): - self.exposure_time = exposure_time - self.f_number = f_number - self.exposure_program = exposure_program - self.iso = iso - self.exif_version = exif_version - self.date_time_original = date_time_original - self.create_date = create_date - self.shutter_speed_value = shutter_speed_value - self.aperture_value = aperture_value - self.exposure_compensation = exposure_compensation - self.metering_mode = metering_mode - self.flash = flash - self.focal_length = focal_length - self.sub_sec_time = sub_sec_time - self.sub_sec_time_original = sub_sec_time_original - self.sub_sec_time_digitized = sub_sec_time_digitized - self.flashpix_version = flashpix_version - self.color_space = color_space - self.exif_image_width = exif_image_width - self.exif_image_height = exif_image_height - self.interop_offset = interop_offset - self.focal_plane_x_resolution = focal_plane_x_resolution - self.focal_plane_y_resolution = focal_plane_y_resolution - self.focal_plane_resolution_unit = focal_plane_resolution_unit - self.custom_rendered = custom_rendered - self.exposure_mode = exposure_mode - self.white_balance = white_balance - self.scene_capture_type = scene_capture_type - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None diff --git a/imagekitio/models/results/MetadataExifGPS.py b/imagekitio/models/results/MetadataExifGPS.py deleted file mode 100644 index 452fa411..00000000 --- a/imagekitio/models/results/MetadataExifGPS.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import List - - -class MetadataExifGPS: - def __init__(self, gps_version_id=None,**kwargs): - if gps_version_id is None: - gps_version_id = [] - self.gps_version_id: List[int] = [] - for i in gps_version_id: - self.gps_version_id.append(i) - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None diff --git a/imagekitio/models/results/MetadataExifImage.py b/imagekitio/models/results/MetadataExifImage.py deleted file mode 100644 index 108aceda..00000000 --- a/imagekitio/models/results/MetadataExifImage.py +++ /dev/null @@ -1,31 +0,0 @@ -class MetadataExifImage: - def __init__( - self, - make=None, - model=None, - orientation=None, - x_resolution=None, - y_resolution=None, - resolution_unit=None, - software=None, - modify_date=None, - y_cb_cr_positioning=None, - exif_offset=None, - gps_info=None, - **kwargs - ): - self.make = make - self.model = model - self.orientation = orientation - self.x_resolution = x_resolution - self.y_resolution = y_resolution - self.resolution_unit = resolution_unit - self.software = software - self.modify_date = modify_date - self.y_cb_cr_positioning = y_cb_cr_positioning - self.exif_offset = exif_offset - self.gps_info = gps_info - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None diff --git a/imagekitio/models/results/MetadataExifInteroperability.py b/imagekitio/models/results/MetadataExifInteroperability.py deleted file mode 100644 index a6a8a4f6..00000000 --- a/imagekitio/models/results/MetadataExifInteroperability.py +++ /dev/null @@ -1,9 +0,0 @@ -class MetadataExifInteroperability: - def __init__(self, interop_index=None, interop_version=None,**kwargs): - self.interop_index = interop_index - self.interop_version = interop_version - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - diff --git a/imagekitio/models/results/MetadataExifThumbnail.py b/imagekitio/models/results/MetadataExifThumbnail.py deleted file mode 100644 index 9f22010a..00000000 --- a/imagekitio/models/results/MetadataExifThumbnail.py +++ /dev/null @@ -1,21 +0,0 @@ -class MetadataExifThumbnail: - def __init__( - self, - compression=None, - x_resolution=None, - y_resolution=None, - resolution_unit=None, - thumbnail_offset=None, - thumbnail_length=None, - **kwargs - ): - self.compression = compression - self.x_resolution = x_resolution - self.y_resolution = y_resolution - self.resolution_unit = resolution_unit - self.thumbnail_offset = thumbnail_offset - self.thumbnail_length = thumbnail_length - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None diff --git a/imagekitio/models/results/PurgeCacheResult.py b/imagekitio/models/results/PurgeCacheResult.py deleted file mode 100644 index ca7cd3c5..00000000 --- a/imagekitio/models/results/PurgeCacheResult.py +++ /dev/null @@ -1,15 +0,0 @@ -from .ResponseMetadata import ResponseMetadata - - -class PurgeCacheResult: - def __init__(self, request_id=None): - self.request_id = request_id - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/PurgeCacheStatusResult.py b/imagekitio/models/results/PurgeCacheStatusResult.py deleted file mode 100644 index bd2153fb..00000000 --- a/imagekitio/models/results/PurgeCacheStatusResult.py +++ /dev/null @@ -1,15 +0,0 @@ -from .ResponseMetadata import ResponseMetadata - - -class PurgeCacheStatusResult: - def __init__(self, status=None): - self.status = status - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/RenameFileResult.py b/imagekitio/models/results/RenameFileResult.py deleted file mode 100644 index ab2a7f63..00000000 --- a/imagekitio/models/results/RenameFileResult.py +++ /dev/null @@ -1,15 +0,0 @@ -from .ResponseMetadata import ResponseMetadata - - -class RenameFileResult: - def __init__(self, purge_request_id: str = None): - self.purge_request_id = purge_request_id - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/ResponseMetadata.py b/imagekitio/models/results/ResponseMetadata.py deleted file mode 100644 index 21fcd120..00000000 --- a/imagekitio/models/results/ResponseMetadata.py +++ /dev/null @@ -1,9 +0,0 @@ -class ResponseMetadata: - def __init__(self, raw=None, http_status_code=None, headers=None,**kwargs): - self.raw = raw - self.http_status_code = http_status_code - self.headers = headers - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None diff --git a/imagekitio/models/results/ResponseMetadataResult.py b/imagekitio/models/results/ResponseMetadataResult.py deleted file mode 100644 index 92ce25ee..00000000 --- a/imagekitio/models/results/ResponseMetadataResult.py +++ /dev/null @@ -1,14 +0,0 @@ -from .ResponseMetadata import ResponseMetadata - - -class ResponseMetadataResult: - def __init__(self): - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/TagsResult.py b/imagekitio/models/results/TagsResult.py deleted file mode 100644 index f8b23d1f..00000000 --- a/imagekitio/models/results/TagsResult.py +++ /dev/null @@ -1,19 +0,0 @@ -from .ResponseMetadata import ResponseMetadata - - -class TagsResult: - def __init__(self, successfully_updated_file_ids=None,**kwargs): - self.successfully_updated_file_ids = successfully_updated_file_ids - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/UploadFileResult.py b/imagekitio/models/results/UploadFileResult.py deleted file mode 100644 index 6a027ded..00000000 --- a/imagekitio/models/results/UploadFileResult.py +++ /dev/null @@ -1,76 +0,0 @@ -from typing import List - -from .AITags import AITags -from .EmbeddedMetadata import EmbeddedMetadata -from .ResponseMetadata import ResponseMetadata -from .VersionInfo import VersionInfo -from ...utils.utils import camel_dict_to_snake_dict - -class UploadFileResult: - def __init__( - self, - file_id=None, - name=None, - url=None, - thumbnail_url: str = None, - height: int = None, - width: int = None, - size: int = None, - file_path: str = None, - tags: dict = None, - ai_tags: List[AITags] = AITags(None, None, None), - version_info: VersionInfo = VersionInfo(None, None), - is_private_file=False, - custom_coordinates: dict = None, - custom_metadata: dict = None, - embedded_metadata: EmbeddedMetadata = EmbeddedMetadata(None, None, None, None), - extension_status: dict = None, - file_type: str = None, - orientation: int = None, - **kwargs - ): - self.file_id = file_id - self.name = name - self.url = url - self.thumbnail_url = thumbnail_url - self.height = height - self.width = width - self.size = size - self.file_path = file_path - self.tags = tags - self.ai_tags: List[AITags] = [] - if ai_tags is not None: - for i in ai_tags: - self.ai_tags.append(AITags(**camel_dict_to_snake_dict(i))) - else: - self.ai_tags.append(AITags(None, None, None)) - self.version_info = VersionInfo(**camel_dict_to_snake_dict(version_info)) - self.is_private_file = is_private_file - self.custom_coordinates = custom_coordinates - self.custom_metadata = custom_metadata - if embedded_metadata is None or embedded_metadata == {}: - self.embedded_metadata = EmbeddedMetadata(None, None, None, None) - else: - if type(embedded_metadata) == EmbeddedMetadata: - self.embedded_metadata = embedded_metadata - else: - self.embedded_metadata: EmbeddedMetadata = EmbeddedMetadata( - **camel_dict_to_snake_dict(embedded_metadata) - ) - self.extension_status = extension_status - self.file_type = file_type - self.orientation = orientation - self.__response_metadata: ResponseMetadata = ResponseMetadata("", "", "") - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - def __getattr__(self,key): - return None - - - @property - def response_metadata(self): - return self.__response_metadata - - @response_metadata.setter - def response_metadata(self, value): - self.__response_metadata = value diff --git a/imagekitio/models/results/VersionInfo.py b/imagekitio/models/results/VersionInfo.py deleted file mode 100644 index 07c4e442..00000000 --- a/imagekitio/models/results/VersionInfo.py +++ /dev/null @@ -1,13 +0,0 @@ -class VersionInfo: - def __init__(self, id=None, name=None,**kwargs): - self.id = id - self.name = name - for key in kwargs.keys(): - self.__setattr__(key,kwargs[key]) - - def __getattr__(self,attr): - return None - - - - diff --git a/imagekitio/models/results/__init__.py b/imagekitio/models/results/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/imagekitio/resource.py b/imagekitio/resource.py deleted file mode 100644 index 20855a78..00000000 --- a/imagekitio/resource.py +++ /dev/null @@ -1,90 +0,0 @@ -import base64 -from datetime import datetime as dt -from typing import Dict - -import requests -from requests import Response - -from .constants.defaults import Default -from .constants.errors import ERRORS - - -class ImageKitRequest(object): - """ - ImageKitRequest is holds the methods and attributes about server - communications and communicates to server, used by Internal classes - """ - - def __init__( - self, private_key, public_key, url_endpoint, transformation_position, options - ): - self.private_key = private_key - self.public_key = public_key - self.url_endpoint = url_endpoint - self.transformation_position = ( - transformation_position or Default.DEFAULT_TRANSFORMATION_POSITION.value - ) - self.options = options or {} - - if not (self.private_key and self.public_key and self.url_endpoint): - raise ValueError(ERRORS.MANDATORY_INITIALIZATION_MISSING.value) - - def create_headers(self): - """Create headers dict and sets Authorization header""" - headers = {"Accept-Encoding": "gzip, deflate"} - headers.update(self.get_auth_headers()) - return headers - - def get_auth_headers(self): - """Create dictionary with encoded private key - The out put is used in request header as authorization header - - :return: dictionary of encoded private key - """ - encoded_private_key = base64.b64encode( - (self.private_key + ":").encode() - ).decode("utf-8") - return {"Authorization": "Basic {}".format(encoded_private_key)} - - @staticmethod - def request(method, url, headers, params=None, files=None, data=None) -> Response: - """Requests from ImageKit server used,by internal methods""" - resp = requests.request( - method=method, - url=url, - params=params, - files=files, - data=data, - headers=headers, - ) - - return resp - - def extend_url_options(self, options: Dict) -> Dict: - """ - adds data to the options from the object, so that - required data can be used by url builder - """ - attr_dict = { - "public_key": self.public_key, - "private_key": self.private_key, - "url_endpoint": self.url_endpoint, - "transformation_position": self.transformation_position, - } - - extended_options = {**self.options, **attr_dict, **options} - return extended_options - - @staticmethod - def get_signature_timestamp(seconds: int = None) -> int: - """ - Returns either default time stamp - or current unix time and expiry seconds to get - signature time stamp - """ - - if not seconds: - return Default.DEFAULT_TIMESTAMP.value - current_timestamp = int(dt.now().timestamp()) - - return current_timestamp + seconds diff --git a/imagekitio/url.py b/imagekitio/url.py deleted file mode 100644 index 39305173..00000000 --- a/imagekitio/url.py +++ /dev/null @@ -1,231 +0,0 @@ -import hashlib -import hmac -import sys -from datetime import datetime as dt -from typing import Any, Dict, List -from urllib.parse import ParseResult, urlparse, urlunparse, parse_qsl, urlencode, quote, unquote - -from .constants.defaults import Default -from .constants.supported_transform import SUPPORTED_TRANS -from .utils.formatter import camel_dict_to_snake_dict, flatten_dict - -from .constants.errors import ERRORS - - -class Url: - """ - Url class holds the request and related methods - to generate url(signed and unsigned) - """ - - def __init__(self, request_obj): - self.request = request_obj - - def generate_url(self, options: Dict = None) -> str: - options = camel_dict_to_snake_dict(options) - attachment = options.get("query_parameters",{}).get("ik-attachment") - if attachment: - options["query_parameters"]["ik-attachment"] = str(attachment).lower() - extended_options = self.request.extend_url_options(options) - return self.build_url(extended_options) - - def build_url(self, options: dict) -> str: - """ - builds url for from all options, - """ - - # important to strip the trailing slashes. later logic assumes no trailing slashes. - path = options.get("path", "").strip("/") - src = options.get("src", "").strip("/") - url_endpoint = options.get("url_endpoint", "").strip("/") - transformation_str = self.transformation_to_str(options.get("transformation")) - transformation_position = options.get( - "transformation_position", Default.DEFAULT_TRANSFORMATION_POSITION.value - ) - - if transformation_position not in Default.VALID_TRANSFORMATION_POSITION.value: - raise ValueError(ERRORS.INVALID_TRANSFORMATION_POSITION.value) - - if path == "" and src == "": - return "" - - # if path is present then it is given priority over src parameter - if path: - if transformation_position == "path" and len(transformation_str) != 0: - temp_url = "{}/{}:{}/{}".format( - url_endpoint, - Default.TRANSFORMATION_PARAMETER.value, - transformation_str.strip("/"), - path, - ) - else: - temp_url = "{}/{}".format(url_endpoint, path) - else: - temp_url = src - # if src parameter is used, then we force transformation position in query - transformation_position = Default.QUERY_TRANSFORMATION_POSITION.value - - url_object = urlparse(temp_url) - - query_params = dict(parse_qsl(url_object.query)) - query_params.update(options.get("query_parameters", {})) - if ( - transformation_position == Default.QUERY_TRANSFORMATION_POSITION.value - and len(transformation_str) != 0 - ): - query_params.update( - {Default.TRANSFORMATION_PARAMETER.value: transformation_str} - ) - - # Update query params in the url - url_object = url_object._replace(query=urlencode(query_params)) - - if options.get("signed"): - expire_seconds = options.get("expire_seconds") - private_key = options.get("private_key") - expiry_timestamp = self.get_signature_timestamp(expire_seconds) - url_signature = self.get_signature( - private_key=private_key, - url=url_object.geturl(), - url_endpoint=url_endpoint, - expiry_timestamp=expiry_timestamp, - ) - - """ - If the expire_seconds parameter is specified then the output URL contains - ik-t parameter (unix timestamp seconds when the URL expires) and - the signature contains the timestamp for computation. - - If not present, then no ik-t parameter and the value 9999999999 is used. - """ - if expire_seconds: - query_params.update( - { - Default.TIMESTAMP_PARAMETER.value: expiry_timestamp, - Default.SIGNATURE_PARAMETER.value: url_signature, - } - ) - else: - query_params.update({Default.SIGNATURE_PARAMETER.value: url_signature}) - - # Update signature related query params - url_object = url_object._replace(query=urlencode(query_params)) - - return url_object.geturl() - - @staticmethod - def get_signature_timestamp(expiry_seconds: int = None) -> int: - """ - this function returns the signature timestamp to be used - with the generated url. - If expiry_seconds is provided, it returns expiry_seconds added - to the current unix time, otherwise the default time stamp - is returned. - """ - if not expiry_seconds: - return Default.DEFAULT_TIMESTAMP.value - current_timestamp = int(dt.now().timestamp()) - - return current_timestamp + expiry_seconds - - @staticmethod - def get_signature(private_key, url, url_endpoint, expiry_timestamp: int) -> str: - """ " - create signature(hashed hex key) from - private_key, url, url_endpoint and expiry_timestamp - """ - # ensure url_endpoint has a trailing slash - if url_endpoint[-1] != "/": - url_endpoint += "/" - - if expiry_timestamp < 1: - expiry_timestamp = Default.DEFAULT_TIMESTAMP.value - - replaced_url = url.replace(url_endpoint, "") + str(expiry_timestamp) - replaced_url = Url.encode_string_if_required(replaced_url) - signature = hmac.new( - key=private_key.encode(), msg=replaced_url.encode(), digestmod=hashlib.sha1 - ) - return signature.hexdigest() - - @staticmethod - def is_valid_trans_options(options: Dict[str, Any]) -> bool: - """ - check if transformation options parameter provided by user is valid - so that ValueError exception can be raised with appropriate error - message in the ImageKitRequest Class - """ - supported_trans_keys = SUPPORTED_TRANS.keys() - # flattening to dict from list of dict to check key validation - transformation_dict = flatten_dict(options.get("transformation", [])) - for key in transformation_dict: - if key not in supported_trans_keys: - return False - return True - - @staticmethod - def is_valid_transformation_pos(trans_pos: str) -> bool: - """ - Returns if transformation position is valid as per Server Documentation - """ - return trans_pos in Default.VALID_TRANSFORMATION_POSITION.value - - @staticmethod - def transformation_to_str(transformation): - """ - creates transformation_position string for url from - transformation_position dictionary - """ - if not isinstance(transformation, list): - return "" - parsed_transforms = [] - for i in range(len(transformation)): - parsed_transform_step = [] - for key in transformation[i]: - transform_key = SUPPORTED_TRANS.get(key, "") - if not transform_key: - transform_key = key - if transformation[i][key] == "-": - parsed_transform_step.append(transform_key) - else: - value = transformation[i][key] - if isinstance(value, bool): - value = str(value).lower() - if transform_key == "oi" or transform_key == "di": - value = value.strip("/") - value = value.replace("/", "@@") - if transform_key == "raw": - for j in value.split(","): - parsed_transform_step.append(j) - else: - parsed_transform_step.append( - "{}{}{}".format( - transform_key, - Default.TRANSFORM_KEY_VALUE_DELIMITER.value, - value, - ) - ) - - parsed_transforms.append( - Default.TRANSFORM_DELIMITER.value.join(parsed_transform_step) - ) - - return Default.CHAIN_TRANSFORM_DELIMITER.value.join(parsed_transforms) - - @staticmethod - def encodeURI(url_str): - # https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI - if "?" in url_str: - # here we are not encoding query parameters as it is allready encoded - encoded_url = quote(url_str.split('?')[0], safe=Default.IGNORE_CHARACTERS.value)+"?"+url_str.split('?')[1] - else: - encoded_url = quote(url_str, safe=Default.IGNORE_CHARACTERS.value) - return encoded_url - - @staticmethod - def has_more_than_ascii(s): - return any(ord(char) > 127 for char in s) - - @staticmethod - def encode_string_if_required(s): - return Url.encodeURI(s) if Url.has_more_than_ascii(s) else s \ No newline at end of file diff --git a/imagekitio/utils/__init__.py b/imagekitio/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/imagekitio/utils/calculation.py b/imagekitio/utils/calculation.py deleted file mode 100644 index 8bf495a5..00000000 --- a/imagekitio/utils/calculation.py +++ /dev/null @@ -1,40 +0,0 @@ -import hashlib -import hmac -import uuid -from datetime import datetime as dt - -from ..constants.errors import ERRORS - -DEFAULT_TIME_DIFF = 60 * 30 - - -def hamming_distance(first: str, second: str) -> int: - """Calculate Hamming Distance between to hex string""" - try: - a = bin(int(first, 16))[2:].zfill(64) - b = bin(int(second, 16))[2:].zfill(64) - except TypeError: - raise TypeError(ERRORS.INVALID_PHASH_VALUE.value) - - return len(list(filter(lambda x: ord(x[0]) ^ ord(x[1]), zip(a, b)))) - - -def get_authenticated_params(token, expire, private_key): - default_expire = int(dt.now().timestamp()) + DEFAULT_TIME_DIFF - token = token or str(uuid.uuid4()) - expire = expire or default_expire - auth_params = {"token": token, "expire": expire, "signature": ""} - - if not private_key: - return - signature = hmac.new( - key=private_key.encode(), - msg=(token + str(expire)).encode(), - digestmod=hashlib.sha1, - ).hexdigest() - - auth_params["token"] = token - auth_params["expire"] = expire - auth_params["signature"] = signature - - return auth_params diff --git a/imagekitio/utils/formatter.py b/imagekitio/utils/formatter.py deleted file mode 100644 index 335956d5..00000000 --- a/imagekitio/utils/formatter.py +++ /dev/null @@ -1,46 +0,0 @@ -import re -from collections import ChainMap, OrderedDict -from typing import Dict, List - - -def camel_to_snake(name): - """ - converts camelCase to snake_case for python - """ - s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) - return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() - - -def snake_to_lower_camel(word): - """ - changes word snake to lower camelCase example: my_plan -> MyPlan - :return camelCaseWord - """ - word_list = word.split("_") - if word_list: - return word_list[0] + "".join(x.title() for x in word_list[1:]) - return word - - -def request_formatter(data: dict) -> dict: - """Converts all keys to camelCase format required for ImageKit server - :param data: dict() - :return: converted_dict -> dict() - """ - return {snake_to_lower_camel(key): val for key, val in data.items()} - - -def camel_dict_to_snake_dict(data: dict) -> dict: - """Convert the keys of dictionary from camel case to snake case""" - return {camel_to_snake(key): val for key, val in data.items()} - - -def flatten_dict(dict_list: List[Dict]) -> OrderedDict: - """Convert list of dictionary to flatten dict - :param dict_list: list of dictionary - :return: flatten_dict - """ - flat_dict = OrderedDict() - for dict_var in dict_list: - flat_dict.update(dict_var) - return flat_dict diff --git a/imagekitio/utils/utils.py b/imagekitio/utils/utils.py deleted file mode 100644 index 2cfba0f7..00000000 --- a/imagekitio/utils/utils.py +++ /dev/null @@ -1,104 +0,0 @@ -import ast -from json import loads, dumps -from requests.models import Response - -from ..exceptions.BadRequestException import BadRequestException -from ..exceptions.ForbiddenException import ForbiddenException -from ..exceptions.InternalServerException import InternalServerException -from ..exceptions.NotFoundException import NotFoundException -from ..exceptions.PartialSuccessException import PartialSuccessException -from ..exceptions.TooManyRequestsException import TooManyRequestsException -from ..exceptions.UnauthorizedException import UnauthorizedException -from ..exceptions.UnknownException import UnknownException -from ..models.results.ResponseMetadata import ResponseMetadata -from ..models.results.ResponseMetadataResult import ResponseMetadataResult -from ..utils.formatter import camel_dict_to_snake_dict - -try: - from simplejson.errors import JSONDecodeError -except ImportError: - from json import JSONDecodeError - - -def get_response_json(response: Response): - try: - resp = response.json() - except JSONDecodeError: - resp = response.text - return resp - - -def populate_response_metadata(response: Response): - resp = get_response_json(response) - response_metadata = ResponseMetadata(resp, response.status_code, response.headers) - return response_metadata - - -def general_api_throw_exception(response: Response): - resp = get_response_json(response) - response_meta_data = populate_response_metadata(response) - if type(resp) == str: - resp = ast.literal_eval(resp) - error_message = resp["message"] if type(resp) == dict else "" - response_help = resp["help"] if type(resp) == dict and "help" in resp else "" - if response.status_code == 400: - raise BadRequestException(error_message, response_help, response_meta_data) - elif response.status_code == 401: - raise UnauthorizedException(error_message, response_help, response_meta_data) - elif response.status_code == 403: - raise ForbiddenException(error_message, response_help, response_meta_data) - elif response.status_code == 429: - raise TooManyRequestsException(error_message, response_help, response_meta_data) - elif ( - response.status_code == 500 - or response.status_code == 502 - or response.status_code == 503 - or response.status_code == 504 - ): - raise InternalServerException(error_message, response_help, response_meta_data) - else: - raise UnknownException(error_message, response_help, response_meta_data) - - -def throw_other_exception(response: Response): - resp = get_response_json(response) - response_meta_data = populate_response_metadata(response) - if type(resp) == str: - resp = ast.literal_eval(resp) - error_message = resp["message"] if type(resp) == dict else "" - response_help = resp["help"] if type(resp) == dict else "" - if response.status_code == 207: - raise PartialSuccessException(error_message, response_help, response_meta_data) - elif response.status_code == 404: - raise NotFoundException(error_message, response_help, response_meta_data) - else: - raise UnknownException(error_message, response_help, response_meta_data) - - -def convert_to_response_object(resp: Response, response_object): - res_new = loads(dumps(camel_dict_to_snake_dict(resp.json()))) - u = response_object(**res_new) - u.response_metadata = ResponseMetadata(resp.json(), resp.status_code, resp.headers) - return u - - -def convert_to_response_metadata_result_object(resp: Response = None): - u = ResponseMetadataResult() - u.response_metadata = ResponseMetadata( - resp.json() if resp.status_code != 204 else None, resp.status_code, resp.headers - ) - return u - - -def convert_to_list_response_object( - resp: Response, response_object, list_response_object -): - response_list = [] - for item in resp.json(): - res_new = loads(dumps(camel_dict_to_snake_dict(item))) - u = response_object(**res_new) - response_list.append(u) - - u = list_response_object(response_list) - u.response_metadata = ResponseMetadata(resp.json(), resp.status_code, resp.headers) - return u diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 00000000..53bca7ff --- /dev/null +++ b/noxfile.py @@ -0,0 +1,9 @@ +import nox + + +@nox.session(reuse_venv=True, name="test-pydantic-v1") +def test_pydantic_v1(session: nox.Session) -> None: + session.install("-r", "requirements-dev.lock") + session.install("pydantic<2") + + session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..43ba273f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,270 @@ +[project] +name = "imagekitio" +version = "0.0.1" +description = "The official Python library for the ImageKit API" +dynamic = ["readme"] +license = "Apache-2.0" +authors = [ +{ name = "Image Kit", email = "developer@imagekit.io" }, +] + +dependencies = [ + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", +] + +requires-python = ">= 3.9" +classifiers = [ + "Typing :: Typed", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: Apache Software License" +] + +[project.urls] +Homepage = "https://github.com/stainless-sdks/imagekit-python" +Repository = "https://github.com/stainless-sdks/imagekit-python" + +[project.optional-dependencies] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] +webhooks = ["standardwebhooks"] + +[tool.rye] +managed = true +# version pins are in requirements-dev.lock +dev-dependencies = [ + "pyright==1.1.399", + "mypy==1.17", + "respx", + "pytest", + "pytest-asyncio", + "ruff", + "time-machine", + "nox", + "dirty-equals>=0.6.0", + "importlib-metadata>=6.7.0", + "rich>=13.7.1", + "pytest-xdist>=3.6.1", +] + +[tool.rye.scripts] +format = { chain = [ + "format:ruff", + "format:docs", + "fix:ruff", + # run formatting again to fix any inconsistencies when imports are stripped + "format:ruff", +]} +"format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md" +"format:ruff" = "ruff format" + +"lint" = { chain = [ + "check:ruff", + "typecheck", + "check:importable", +]} +"check:ruff" = "ruff check ." +"fix:ruff" = "ruff check --fix ." + +"check:importable" = "python -c 'import imagekitio'" + +typecheck = { chain = [ + "typecheck:pyright", + "typecheck:mypy" +]} +"typecheck:pyright" = "pyright" +"typecheck:verify-types" = "pyright --verifytypes imagekitio --ignoreexternal" +"typecheck:mypy" = "mypy ." + +[build-system] +requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] +build-backend = "hatchling.build" + +[tool.hatch.build] +include = [ + "src/*" +] + +[tool.hatch.build.targets.wheel] +packages = ["src/imagekitio"] + +[tool.hatch.build.targets.sdist] +# Basically everything except hidden files/directories (such as .github, .devcontainers, .python-version, etc) +include = [ + "/*.toml", + "/*.json", + "/*.lock", + "/*.md", + "/mypy.ini", + "/noxfile.py", + "bin/*", + "examples/*", + "src/*", + "tests/*", +] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/markdown" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "README.md" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] +# replace relative links with absolute links +pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' +replacement = '[\1](https://github.com/stainless-sdks/imagekit-python/tree/main/\g<2>)' + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--tb=short -n auto" +xfail_strict = true +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +filterwarnings = [ + "error" +] + +[tool.pyright] +# this enables practically every flag given by pyright. +# there are a couple of flags that are still disabled by +# default in strict mode as they are experimental and niche. +typeCheckingMode = "strict" +pythonVersion = "3.9" + +exclude = [ + "_dev", + ".venv", + ".nox", + ".git", +] + +reportImplicitOverride = true +reportOverlappingOverload = false + +reportImportCycles = false +reportPrivateUsage = false + +[tool.mypy] +pretty = true +show_error_codes = true + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +# +# We also exclude our `tests` as mypy doesn't always infer +# types correctly and Pyright will still catch any type errors. +exclude = ['src/imagekitio/_files.py', '_dev/.*.py', 'tests/.*'] + +strict_equality = true +implicit_reexport = true +check_untyped_defs = true +no_implicit_optional = true + +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true + +# Turn these options off as it could cause conflicts +# with the Pyright options. +warn_unused_ignores = false +warn_redundant_casts = false + +disallow_any_generics = true +disallow_untyped_defs = true +disallow_untyped_calls = true +disallow_subclassing_any = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +cache_fine_grained = true + +# By default, mypy reports an error if you assign a value to the result +# of a function call that doesn't return anything. We do this in our test +# cases: +# ``` +# result = ... +# assert result is None +# ``` +# Changing this codegen to make mypy happy would increase complexity +# and would not be worth it. +disable_error_code = "func-returns-value,overload-cannot-match" + +# https://github.com/python/mypy/issues/12162 +[[tool.mypy.overrides]] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true + + +[tool.ruff] +line-length = 120 +output-format = "grouped" +target-version = "py38" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = [ + # isort + "I", + # bugbear rules + "B", + # remove unused imports + "F401", + # check for missing future annotations + "FA102", + # bare except statements + "E722", + # unused arguments + "ARG", + # print statements + "T201", + "T203", + # misuse of typing.TYPE_CHECKING + "TC004", + # import rules + "TID251", +] +ignore = [ + # mutable defaults + "B006", +] +unfixable = [ + # disable auto fix for print statements + "T201", + "T203", +] + +extend-safe-fixes = ["FA102"] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" + +[tool.ruff.lint.isort] +length-sort = true +length-sort-straight = true +combine-as-imports = true +extra-standard-library = ["typing_extensions"] +known-first-party = ["imagekitio", "tests"] + +[tool.ruff.lint.per-file-ignores] +"bin/**.py" = ["T201", "T203"] +"scripts/**.py" = ["T201", "T203"] +"tests/**.py" = ["T201", "T203"] +"examples/**.py" = ["T201", "T203"] diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 00000000..c34becba --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,162 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: true +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.13.2 + # via httpx-aiohttp + # via imagekitio +aiosignal==1.4.0 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +anyio==4.12.0 + # via httpx + # via imagekitio +argcomplete==3.6.3 + # via nox +async-timeout==5.0.1 + # via aiohttp +attrs==25.4.0 + # via aiohttp + # via nox + # via standardwebhooks +backports-asyncio-runner==1.2.0 + # via pytest-asyncio +certifi==2025.11.12 + # via httpcore + # via httpx +colorlog==6.10.1 + # via nox +dependency-groups==1.3.1 + # via nox +deprecated==1.3.1 + # via standardwebhooks +dirty-equals==0.11 +distlib==0.4.0 + # via virtualenv +distro==1.9.0 + # via imagekitio +exceptiongroup==1.3.1 + # via anyio + # via pytest +execnet==2.1.2 + # via pytest-xdist +filelock==3.19.1 + # via virtualenv +frozenlist==1.8.0 + # via aiohttp + # via aiosignal +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via httpx-aiohttp + # via imagekitio + # via respx + # via standardwebhooks +httpx-aiohttp==0.1.9 + # via imagekitio +humanize==4.13.0 + # via nox +idna==3.11 + # via anyio + # via httpx + # via yarl +importlib-metadata==8.7.0 +iniconfig==2.1.0 + # via pytest +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +multidict==6.7.0 + # via aiohttp + # via yarl +mypy==1.17.0 +mypy-extensions==1.1.0 + # via mypy +nodeenv==1.9.1 + # via pyright +nox==2025.11.12 +packaging==25.0 + # via dependency-groups + # via nox + # via pytest +pathspec==0.12.1 + # via mypy +platformdirs==4.4.0 + # via virtualenv +pluggy==1.6.0 + # via pytest +propcache==0.4.1 + # via aiohttp + # via yarl +pydantic==2.12.5 + # via imagekitio +pydantic-core==2.41.5 + # via pydantic +pygments==2.19.2 + # via pytest + # via rich +pyright==1.1.399 +pytest==8.4.2 + # via pytest-asyncio + # via pytest-xdist +pytest-asyncio==1.2.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 + # via standardwebhooks + # via time-machine +respx==0.22.0 +rich==14.2.0 +ruff==0.14.7 +six==1.17.0 + # via python-dateutil +sniffio==1.3.1 + # via imagekitio +standardwebhooks==1.0.0 + # via imagekitio +time-machine==2.19.0 +tomli==2.3.0 + # via dependency-groups + # via mypy + # via nox + # via pytest +types-deprecated==1.3.1.20251101 + # via standardwebhooks +types-python-dateutil==2.9.0.20251115 + # via standardwebhooks +typing-extensions==4.15.0 + # via aiosignal + # via anyio + # via exceptiongroup + # via imagekitio + # via multidict + # via mypy + # via pydantic + # via pydantic-core + # via pyright + # via pytest-asyncio + # via typing-inspection + # via virtualenv +typing-inspection==0.4.2 + # via pydantic +virtualenv==20.35.4 + # via nox +wrapt==2.0.1 + # via deprecated +yarl==1.22.0 + # via aiohttp +zipp==3.23.0 + # via importlib-metadata diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 00000000..acc82f8d --- /dev/null +++ b/requirements.lock @@ -0,0 +1,92 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: true +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.13.2 + # via httpx-aiohttp + # via imagekitio +aiosignal==1.4.0 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +anyio==4.12.0 + # via httpx + # via imagekitio +async-timeout==5.0.1 + # via aiohttp +attrs==25.4.0 + # via aiohttp + # via standardwebhooks +certifi==2025.11.12 + # via httpcore + # via httpx +deprecated==1.3.1 + # via standardwebhooks +distro==1.9.0 + # via imagekitio +exceptiongroup==1.3.1 + # via anyio +frozenlist==1.8.0 + # via aiohttp + # via aiosignal +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via httpx-aiohttp + # via imagekitio + # via standardwebhooks +httpx-aiohttp==0.1.9 + # via imagekitio +idna==3.11 + # via anyio + # via httpx + # via yarl +multidict==6.7.0 + # via aiohttp + # via yarl +propcache==0.4.1 + # via aiohttp + # via yarl +pydantic==2.12.5 + # via imagekitio +pydantic-core==2.41.5 + # via pydantic +python-dateutil==2.9.0.post0 + # via standardwebhooks +six==1.17.0 + # via python-dateutil +sniffio==1.3.1 + # via imagekitio +standardwebhooks==1.0.0 + # via imagekitio +types-deprecated==1.3.1.20251101 + # via standardwebhooks +types-python-dateutil==2.9.0.20251115 + # via standardwebhooks +typing-extensions==4.15.0 + # via aiosignal + # via anyio + # via exceptiongroup + # via imagekitio + # via multidict + # via pydantic + # via pydantic-core + # via typing-inspection +typing-inspection==0.4.2 + # via pydantic +wrapt==2.0.1 + # via deprecated +yarl==1.22.0 + # via aiohttp diff --git a/requirements/requirements.txt b/requirements/requirements.txt deleted file mode 100644 index 13342e00..00000000 --- a/requirements/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -requests_toolbelt>=1.0.0 - -urllib3<3; python_version >= "3.9" -urllib3!=2.0.0,<3; python_version < "3.9" - -requests>=2.32.2; python_version >= "3.9" -requests>=2.22.0,<2.32; python_version >= "3.7" and python_version < "3.9" -requests>=2.22.0,<2.28; python_version < "3.7" \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt deleted file mode 100644 index 7df3a74b..00000000 --- a/requirements/test.txt +++ /dev/null @@ -1,23 +0,0 @@ -requests_toolbelt>=1.0.0 -urllib3<3; python_version >= "3.9" -urllib3!=2.0.0,<3; python_version < "3.9" - -requests>=2.32.2; python_version >= "3.9" -requests>=2.22.0,<2.32; python_version >= "3.7" and python_version < "3.9" -requests>=2.22.0,<2.28; python_version < "3.7" - -coverage>=7.6.0; python_version >= "3.13" -coverage>=7.5.0; python_version >= "3.9" and python_version < "3.13" -coverage>=5.0,<7.0; python_version >= "3.7" and python_version < "3.9" -coverage==4.5.4; python_version < "3.7" - -responses>=0.25.0; python_version >= "3.8" -responses>=0.18.0,<0.25; python_version >= "3.7" and python_version < "3.8" -responses<0.18.0; python_version < "3.7" - -black>=24.8.0; python_version >= "3.8" -black==19.10b0; python_version < "3.8" - -tox>=4; python_version >= "3.13" -tox==3.14.2; python_version < "3.13" - diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 00000000..b430fee3 --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then + brew bundle check >/dev/null 2>&1 || { + echo -n "==> Install Homebrew dependencies? (y/N): " + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + brew bundle + ;; + *) + ;; + esac + echo + } +fi + +echo "==> Installing Python dependencies…" + +# experimental uv support makes installations significantly faster +rye config --set-bool behavior.use-uv=true + +rye sync --all-features diff --git a/scripts/format b/scripts/format new file mode 100755 index 00000000..667ec2d7 --- /dev/null +++ b/scripts/format @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running formatters" +rye run format diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 00000000..eb9a4dda --- /dev/null +++ b/scripts/lint @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running lints" +rye run lint + +echo "==> Making sure it imports" +rye run python -c 'import imagekitio' diff --git a/scripts/mock b/scripts/mock new file mode 100755 index 00000000..0b28f6ea --- /dev/null +++ b/scripts/mock @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [[ -n "$1" && "$1" != '--'* ]]; then + URL="$1" + shift +else + URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)" +fi + +# Check if the URL is empty +if [ -z "$URL" ]; then + echo "Error: No OpenAPI spec path/url provided or found in .stats.yml" + exit 1 +fi + +echo "==> Starting mock server with URL ${URL}" + +# Run prism mock on the given spec +if [ "$1" == "--daemon" ]; then + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & + + # Wait for server to come online + echo -n "Waiting for server" + while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + echo -n "." + sleep 0.1 + done + + if grep -q "✖ fatal" ".prism.log"; then + cat .prism.log + exit 1 + fi + + echo +else + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" +fi diff --git a/scripts/test b/scripts/test new file mode 100755 index 00000000..dbeda2d2 --- /dev/null +++ b/scripts/test @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +function prism_is_running() { + curl --silent "http://localhost:4010" >/dev/null 2>&1 +} + +kill_server_on_port() { + pids=$(lsof -t -i tcp:"$1" || echo "") + if [ "$pids" != "" ]; then + kill "$pids" + echo "Stopped $pids." + fi +} + +function is_overriding_api_base_url() { + [ -n "$TEST_API_BASE_URL" ] +} + +if ! is_overriding_api_base_url && ! prism_is_running ; then + # When we exit this script, make sure to kill the background mock server process + trap 'kill_server_on_port 4010' EXIT + + # Start the dev server + ./scripts/mock --daemon +fi + +if is_overriding_api_base_url ; then + echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" + echo +elif ! prism_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" + echo -e "running against your OpenAPI spec." + echo + echo -e "To run the server, pass in the path or url of your OpenAPI" + echo -e "spec to the prism command:" + echo + echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" + echo + + exit 1 +else + echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo +fi + +export DEFER_PYDANTIC_BUILD=false + +echo "==> Running tests" +rye run pytest "$@" + +echo "==> Running Pydantic v1 tests" +rye run nox -s test-pydantic-v1 -- "$@" diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py new file mode 100644 index 00000000..0cf2bd2f --- /dev/null +++ b/scripts/utils/ruffen-docs.py @@ -0,0 +1,167 @@ +# fork of https://github.com/asottile/blacken-docs adapted for ruff +from __future__ import annotations + +import re +import sys +import argparse +import textwrap +import contextlib +import subprocess +from typing import Match, Optional, Sequence, Generator, NamedTuple, cast + +MD_RE = re.compile( + r"(?P^(?P *)```\s*python\n)" r"(?P.*?)" r"(?P^(?P=indent)```\s*$)", + re.DOTALL | re.MULTILINE, +) +MD_PYCON_RE = re.compile( + r"(?P^(?P *)```\s*pycon\n)" r"(?P.*?)" r"(?P^(?P=indent)```.*$)", + re.DOTALL | re.MULTILINE, +) +PYCON_PREFIX = ">>> " +PYCON_CONTINUATION_PREFIX = "..." +PYCON_CONTINUATION_RE = re.compile( + rf"^{re.escape(PYCON_CONTINUATION_PREFIX)}( |$)", +) +DEFAULT_LINE_LENGTH = 100 + + +class CodeBlockError(NamedTuple): + offset: int + exc: Exception + + +def format_str( + src: str, +) -> tuple[str, Sequence[CodeBlockError]]: + errors: list[CodeBlockError] = [] + + @contextlib.contextmanager + def _collect_error(match: Match[str]) -> Generator[None, None, None]: + try: + yield + except Exception as e: + errors.append(CodeBlockError(match.start(), e)) + + def _md_match(match: Match[str]) -> str: + code = textwrap.dedent(match["code"]) + with _collect_error(match): + code = format_code_block(code) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + def _pycon_match(match: Match[str]) -> str: + code = "" + fragment = cast(Optional[str], None) + + def finish_fragment() -> None: + nonlocal code + nonlocal fragment + + if fragment is not None: + with _collect_error(match): + fragment = format_code_block(fragment) + fragment_lines = fragment.splitlines() + code += f"{PYCON_PREFIX}{fragment_lines[0]}\n" + for line in fragment_lines[1:]: + # Skip blank lines to handle Black adding a blank above + # functions within blocks. A blank line would end the REPL + # continuation prompt. + # + # >>> if True: + # ... def f(): + # ... pass + # ... + if line: + code += f"{PYCON_CONTINUATION_PREFIX} {line}\n" + if fragment_lines[-1].startswith(" "): + code += f"{PYCON_CONTINUATION_PREFIX}\n" + fragment = None + + indentation = None + for line in match["code"].splitlines(): + orig_line, line = line, line.lstrip() + if indentation is None and line: + indentation = len(orig_line) - len(line) + continuation_match = PYCON_CONTINUATION_RE.match(line) + if continuation_match and fragment is not None: + fragment += line[continuation_match.end() :] + "\n" + else: + finish_fragment() + if line.startswith(PYCON_PREFIX): + fragment = line[len(PYCON_PREFIX) :] + "\n" + else: + code += orig_line[indentation:] + "\n" + finish_fragment() + return code + + def _md_pycon_match(match: Match[str]) -> str: + code = _pycon_match(match) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + src = MD_RE.sub(_md_match, src) + src = MD_PYCON_RE.sub(_md_pycon_match, src) + return src, errors + + +def format_code_block(code: str) -> str: + return subprocess.check_output( + [ + sys.executable, + "-m", + "ruff", + "format", + "--stdin-filename=script.py", + f"--line-length={DEFAULT_LINE_LENGTH}", + ], + encoding="utf-8", + input=code, + ) + + +def format_file( + filename: str, + skip_errors: bool, +) -> int: + with open(filename, encoding="UTF-8") as f: + contents = f.read() + new_contents, errors = format_str(contents) + for error in errors: + lineno = contents[: error.offset].count("\n") + 1 + print(f"{filename}:{lineno}: code block parse error {error.exc}") + if errors and not skip_errors: + return 1 + if contents != new_contents: + print(f"{filename}: Rewriting...") + with open(filename, "w", encoding="UTF-8") as f: + f.write(new_contents) + return 0 + else: + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "-l", + "--line-length", + type=int, + default=DEFAULT_LINE_LENGTH, + ) + parser.add_argument( + "-S", + "--skip-string-normalization", + action="store_true", + ) + parser.add_argument("-E", "--skip-errors", action="store_true") + parser.add_argument("filenames", nargs="*") + args = parser.parse_args(argv) + + retv = 0 + for filename in args.filenames: + retv |= format_file(filename, skip_errors=args.skip_errors) + return retv + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 00000000..ace7ffbf --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -exuo pipefail + +FILENAME=$(basename dist/*.whl) + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ + -H "Authorization: Bearer $AUTH" \ + -H "Content-Type: application/json") + +SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') + +if [[ "$SIGNED_URL" == "null" ]]; then + echo -e "\033[31mFailed to get signed URL.\033[0m" + exit 1 +fi + +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: binary/octet-stream" \ + --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) + +if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then + echo -e "\033[32mUploaded build to Stainless storage.\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/imagekit-python/$SHA/$FILENAME'\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi diff --git a/setup.py b/setup.py deleted file mode 100644 index c5df52a9..00000000 --- a/setup.py +++ /dev/null @@ -1,24 +0,0 @@ -import setuptools - -with open("README.md", "r") as fh: - long_description = fh.read() - -with open("requirements/requirements.txt") as f: - install_requires = f.read().splitlines() - -setuptools.setup( - name="imagekitio", - version="4.2.0", - description="Python wrapper for the ImageKit API", - long_description=long_description, - long_description_content_type="text/markdown", - install_requires=install_requires, - url="https://github.com/imagekit-developer/imagekit-python", - packages=setuptools.find_packages(), - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires=">=3.6", -) diff --git a/src/imagekit/lib/.keep b/src/imagekit/lib/.keep new file mode 100644 index 00000000..5e2c99fd --- /dev/null +++ b/src/imagekit/lib/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store custom files to expand the SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/src/imagekitio/__init__.py b/src/imagekitio/__init__.py new file mode 100644 index 00000000..90416321 --- /dev/null +++ b/src/imagekitio/__init__.py @@ -0,0 +1,104 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import typing as _t + +from . import types +from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given +from ._utils import file_from_path +from ._client import ( + Client, + Stream, + Timeout, + ImageKit, + Transport, + AsyncClient, + AsyncStream, + AsyncImageKit, + RequestOptions, +) +from ._models import BaseModel +from ._version import __title__, __version__ +from ._response import APIResponse as APIResponse, AsyncAPIResponse as AsyncAPIResponse +from ._constants import DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_CONNECTION_LIMITS +from ._exceptions import ( + APIError, + ConflictError, + ImageKitError, + NotFoundError, + APIStatusError, + RateLimitError, + APITimeoutError, + BadRequestError, + APIConnectionError, + AuthenticationError, + InternalServerError, + PermissionDeniedError, + UnprocessableEntityError, + APIWebhookValidationError, + APIResponseValidationError, +) +from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient +from ._utils._logs import setup_logging as _setup_logging + +__all__ = [ + "types", + "__version__", + "__title__", + "NoneType", + "Transport", + "ProxiesTypes", + "NotGiven", + "NOT_GIVEN", + "not_given", + "Omit", + "omit", + "ImageKitError", + "APIError", + "APIStatusError", + "APITimeoutError", + "APIConnectionError", + "APIResponseValidationError", + "APIWebhookValidationError", + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", + "Timeout", + "RequestOptions", + "Client", + "AsyncClient", + "Stream", + "AsyncStream", + "ImageKit", + "AsyncImageKit", + "file_from_path", + "BaseModel", + "DEFAULT_TIMEOUT", + "DEFAULT_MAX_RETRIES", + "DEFAULT_CONNECTION_LIMITS", + "DefaultHttpxClient", + "DefaultAsyncHttpxClient", + "DefaultAioHttpClient", +] + +if not _t.TYPE_CHECKING: + from ._utils._resources_proxy import resources as resources + +_setup_logging() + +# Update the __module__ attribute for exported symbols so that +# error messages point to this module instead of the module +# it was originally defined in, e.g. +# imagekitio._exceptions.NotFoundError -> imagekitio.NotFoundError +__locals = locals() +for __name in __all__: + if not __name.startswith("__"): + try: + __locals[__name].__module__ = "imagekitio" + except (TypeError, AttributeError): + # Some of our exported symbols are builtins which we can't set attributes for. + pass diff --git a/src/imagekitio/_base_client.py b/src/imagekitio/_base_client.py new file mode 100644 index 00000000..384e7c0a --- /dev/null +++ b/src/imagekitio/_base_client.py @@ -0,0 +1,1995 @@ +from __future__ import annotations + +import sys +import json +import time +import uuid +import email +import asyncio +import inspect +import logging +import platform +import email.utils +from types import TracebackType +from random import random +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Type, + Union, + Generic, + Mapping, + TypeVar, + Iterable, + Iterator, + Optional, + Generator, + AsyncIterator, + cast, + overload, +) +from typing_extensions import Literal, override, get_origin + +import anyio +import httpx +import distro +import pydantic +from httpx import URL +from pydantic import PrivateAttr + +from . import _exceptions +from ._qs import Querystring +from ._files import to_httpx_files, async_to_httpx_files +from ._types import ( + Body, + Omit, + Query, + Headers, + Timeout, + NotGiven, + ResponseT, + AnyMapping, + PostParser, + RequestFiles, + HttpxSendArgs, + RequestOptions, + HttpxRequestFiles, + ModelBuilderProtocol, + not_given, +) +from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping +from ._compat import PYDANTIC_V1, model_copy, model_dump +from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type +from ._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + extract_response_type, +) +from ._constants import ( + DEFAULT_TIMEOUT, + MAX_RETRY_DELAY, + DEFAULT_MAX_RETRIES, + INITIAL_RETRY_DELAY, + RAW_RESPONSE_HEADER, + OVERRIDE_CAST_TO_HEADER, + DEFAULT_CONNECTION_LIMITS, +) +from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder +from ._exceptions import ( + APIStatusError, + APITimeoutError, + APIConnectionError, + APIResponseValidationError, +) + +log: logging.Logger = logging.getLogger(__name__) + +# TODO: make base page type vars covariant +SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]") +AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]") + + +_T = TypeVar("_T") +_T_co = TypeVar("_T_co", covariant=True) + +_StreamT = TypeVar("_StreamT", bound=Stream[Any]) +_AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any]) + +if TYPE_CHECKING: + from httpx._config import ( + DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage] + ) + + HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG +else: + try: + from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT + except ImportError: + # taken from https://github.com/encode/httpx/blob/3ba5fe0d7ac70222590e759c31442b1cab263791/httpx/_config.py#L366 + HTTPX_DEFAULT_TIMEOUT = Timeout(5.0) + + +class PageInfo: + """Stores the necessary information to build the request to retrieve the next page. + + Either `url` or `params` must be set. + """ + + url: URL | NotGiven + params: Query | NotGiven + json: Body | NotGiven + + @overload + def __init__( + self, + *, + url: URL, + ) -> None: ... + + @overload + def __init__( + self, + *, + params: Query, + ) -> None: ... + + @overload + def __init__( + self, + *, + json: Body, + ) -> None: ... + + def __init__( + self, + *, + url: URL | NotGiven = not_given, + json: Body | NotGiven = not_given, + params: Query | NotGiven = not_given, + ) -> None: + self.url = url + self.json = json + self.params = params + + @override + def __repr__(self) -> str: + if self.url: + return f"{self.__class__.__name__}(url={self.url})" + if self.json: + return f"{self.__class__.__name__}(json={self.json})" + return f"{self.__class__.__name__}(params={self.params})" + + +class BasePage(GenericModel, Generic[_T]): + """ + Defines the core interface for pagination. + + Type Args: + ModelT: The pydantic model that represents an item in the response. + + Methods: + has_next_page(): Check if there is another page available + next_page_info(): Get the necessary information to make a request for the next page + """ + + _options: FinalRequestOptions = PrivateAttr() + _model: Type[_T] = PrivateAttr() + + def has_next_page(self) -> bool: + items = self._get_page_items() + if not items: + return False + return self.next_page_info() is not None + + def next_page_info(self) -> Optional[PageInfo]: ... + + def _get_page_items(self) -> Iterable[_T]: # type: ignore[empty-body] + ... + + def _params_from_url(self, url: URL) -> httpx.QueryParams: + # TODO: do we have to preprocess params here? + return httpx.QueryParams(cast(Any, self._options.params)).merge(url.params) + + def _info_to_options(self, info: PageInfo) -> FinalRequestOptions: + options = model_copy(self._options) + options._strip_raw_response_header() + + if not isinstance(info.params, NotGiven): + options.params = {**options.params, **info.params} + return options + + if not isinstance(info.url, NotGiven): + params = self._params_from_url(info.url) + url = info.url.copy_with(params=params) + options.params = dict(url.params) + options.url = str(url) + return options + + if not isinstance(info.json, NotGiven): + if not is_mapping(info.json): + raise TypeError("Pagination is only supported with mappings") + + if not options.json_data: + options.json_data = {**info.json} + else: + if not is_mapping(options.json_data): + raise TypeError("Pagination is only supported with mappings") + + options.json_data = {**options.json_data, **info.json} + return options + + raise ValueError("Unexpected PageInfo state") + + +class BaseSyncPage(BasePage[_T], Generic[_T]): + _client: SyncAPIClient = pydantic.PrivateAttr() + + def _set_private_attributes( + self, + client: SyncAPIClient, + model: Type[_T], + options: FinalRequestOptions, + ) -> None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + + self._model = model + self._client = client + self._options = options + + # Pydantic uses a custom `__iter__` method to support casting BaseModels + # to dictionaries. e.g. dict(model). + # As we want to support `for item in page`, this is inherently incompatible + # with the default pydantic behaviour. It is not possible to support both + # use cases at once. Fortunately, this is not a big deal as all other pydantic + # methods should continue to work as expected as there is an alternative method + # to cast a model to a dictionary, model.dict(), which is used internally + # by pydantic. + def __iter__(self) -> Iterator[_T]: # type: ignore + for page in self.iter_pages(): + for item in page._get_page_items(): + yield item + + def iter_pages(self: SyncPageT) -> Iterator[SyncPageT]: + page = self + while True: + yield page + if page.has_next_page(): + page = page.get_next_page() + else: + return + + def get_next_page(self: SyncPageT) -> SyncPageT: + info = self.next_page_info() + if not info: + raise RuntimeError( + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." + ) + + options = self._info_to_options(info) + return self._client._request_api_list(self._model, page=self.__class__, options=options) + + +class AsyncPaginator(Generic[_T, AsyncPageT]): + def __init__( + self, + client: AsyncAPIClient, + options: FinalRequestOptions, + page_cls: Type[AsyncPageT], + model: Type[_T], + ) -> None: + self._model = model + self._client = client + self._options = options + self._page_cls = page_cls + + def __await__(self) -> Generator[Any, None, AsyncPageT]: + return self._get_page().__await__() + + async def _get_page(self) -> AsyncPageT: + def _parser(resp: AsyncPageT) -> AsyncPageT: + resp._set_private_attributes( + model=self._model, + options=self._options, + client=self._client, + ) + return resp + + self._options.post_parser = _parser + + return await self._client.request(self._page_cls, self._options) + + async def __aiter__(self) -> AsyncIterator[_T]: + # https://github.com/microsoft/pyright/issues/3464 + page = cast( + AsyncPageT, + await self, # type: ignore + ) + async for item in page: + yield item + + +class BaseAsyncPage(BasePage[_T], Generic[_T]): + _client: AsyncAPIClient = pydantic.PrivateAttr() + + def _set_private_attributes( + self, + model: Type[_T], + client: AsyncAPIClient, + options: FinalRequestOptions, + ) -> None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + + self._model = model + self._client = client + self._options = options + + async def __aiter__(self) -> AsyncIterator[_T]: + async for page in self.iter_pages(): + for item in page._get_page_items(): + yield item + + async def iter_pages(self: AsyncPageT) -> AsyncIterator[AsyncPageT]: + page = self + while True: + yield page + if page.has_next_page(): + page = await page.get_next_page() + else: + return + + async def get_next_page(self: AsyncPageT) -> AsyncPageT: + info = self.next_page_info() + if not info: + raise RuntimeError( + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." + ) + + options = self._info_to_options(info) + return await self._client._request_api_list(self._model, page=self.__class__, options=options) + + +_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient]) +_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]]) + + +class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]): + _client: _HttpxClientT + _version: str + _base_url: URL + max_retries: int + timeout: Union[float, Timeout, None] + _strict_response_validation: bool + _idempotency_header: str | None + _default_stream_cls: type[_DefaultStreamT] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + _strict_response_validation: bool, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None = DEFAULT_TIMEOUT, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + ) -> None: + self._version = version + self._base_url = self._enforce_trailing_slash(URL(base_url)) + self.max_retries = max_retries + self.timeout = timeout + self._custom_headers = custom_headers or {} + self._custom_query = custom_query or {} + self._strict_response_validation = _strict_response_validation + self._idempotency_header = None + self._platform: Platform | None = None + + if max_retries is None: # pyright: ignore[reportUnnecessaryComparison] + raise TypeError( + "max_retries cannot be None. If you want to disable retries, pass `0`; if you want unlimited retries, pass `math.inf` or a very high number; if you want the default behavior, pass `imagekitio.DEFAULT_MAX_RETRIES`" + ) + + def _enforce_trailing_slash(self, url: URL) -> URL: + if url.raw_path.endswith(b"/"): + return url + return url.copy_with(raw_path=url.raw_path + b"/") + + def _make_status_error_from_response( + self, + response: httpx.Response, + ) -> APIStatusError: + if response.is_closed and not response.is_stream_consumed: + # We can't read the response body as it has been closed + # before it was read. This can happen if an event hook + # raises a status error. + body = None + err_msg = f"Error code: {response.status_code}" + else: + err_text = response.text.strip() + body = err_text + + try: + body = json.loads(err_text) + err_msg = f"Error code: {response.status_code} - {body}" + except Exception: + err_msg = err_text or f"Error code: {response.status_code}" + + return self._make_status_error(err_msg, body=body, response=response) + + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> _exceptions.APIStatusError: + raise NotImplementedError() + + def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers: + custom_headers = options.headers or {} + headers_dict = _merge_mappings(self.default_headers, custom_headers) + self._validate_headers(headers_dict, custom_headers) + + # headers are case-insensitive while dictionaries are not. + headers = httpx.Headers(headers_dict) + + idempotency_header = self._idempotency_header + if idempotency_header and options.idempotency_key and idempotency_header not in headers: + headers[idempotency_header] = options.idempotency_key + + # Don't set these headers if they were already set or removed by the caller. We check + # `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case. + lower_custom_headers = [header.lower() for header in custom_headers] + if "x-stainless-retry-count" not in lower_custom_headers: + headers["x-stainless-retry-count"] = str(retries_taken) + if "x-stainless-read-timeout" not in lower_custom_headers: + timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout + if isinstance(timeout, Timeout): + timeout = timeout.read + if timeout is not None: + headers["x-stainless-read-timeout"] = str(timeout) + + return headers + + def _prepare_url(self, url: str) -> URL: + """ + Merge a URL argument together with any 'base_url' on the client, + to create the URL used for the outgoing request. + """ + # Copied from httpx's `_merge_url` method. + merge_url = URL(url) + if merge_url.is_relative_url: + merge_raw_path = self.base_url.raw_path + merge_url.raw_path.lstrip(b"/") + return self.base_url.copy_with(raw_path=merge_raw_path) + + return merge_url + + def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder: + return SSEDecoder() + + def _build_request( + self, + options: FinalRequestOptions, + *, + retries_taken: int = 0, + ) -> httpx.Request: + if log.isEnabledFor(logging.DEBUG): + log.debug("Request options: %s", model_dump(options, exclude_unset=True)) + + kwargs: dict[str, Any] = {} + + json_data = options.json_data + if options.extra_json is not None: + if json_data is None: + json_data = cast(Body, options.extra_json) + elif is_mapping(json_data): + json_data = _merge_mappings(json_data, options.extra_json) + else: + raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`") + + headers = self._build_headers(options, retries_taken=retries_taken) + params = _merge_mappings(self.default_query, options.params) + content_type = headers.get("Content-Type") + files = options.files + + # If the given Content-Type header is multipart/form-data then it + # has to be removed so that httpx can generate the header with + # additional information for us as it has to be in this form + # for the server to be able to correctly parse the request: + # multipart/form-data; boundary=---abc-- + if content_type is not None and content_type.startswith("multipart/form-data"): + if "boundary" not in content_type: + # only remove the header if the boundary hasn't been explicitly set + # as the caller doesn't want httpx to come up with their own boundary + headers.pop("Content-Type") + + # As we are now sending multipart/form-data instead of application/json + # we need to tell httpx to use it, https://www.python-httpx.org/advanced/clients/#multipart-file-encoding + if json_data: + if not is_dict(json_data): + raise TypeError( + f"Expected query input to be a dictionary for multipart requests but got {type(json_data)} instead." + ) + kwargs["data"] = self._serialize_multipartform(json_data) + + # httpx determines whether or not to send a "multipart/form-data" + # request based on the truthiness of the "files" argument. + # This gets around that issue by generating a dict value that + # evaluates to true. + # + # https://github.com/encode/httpx/discussions/2399#discussioncomment-3814186 + if not files: + files = cast(HttpxRequestFiles, ForceMultipartDict()) + + prepared_url = self._prepare_url(options.url) + if "_" in prepared_url.host: + # work around https://github.com/encode/httpx/discussions/2880 + kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + + is_body_allowed = options.method.lower() != "get" + + if is_body_allowed: + if isinstance(json_data, bytes): + kwargs["content"] = json_data + else: + kwargs["json"] = json_data if is_given(json_data) else None + kwargs["files"] = files + else: + headers.pop("Content-Type", None) + kwargs.pop("data", None) + + # TODO: report this error to httpx + return self._client.build_request( # pyright: ignore[reportUnknownMemberType] + headers=headers, + timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout, + method=options.method, + url=prepared_url, + # the `Query` type that we use is incompatible with qs' + # `Params` type as it needs to be typed as `Mapping[str, object]` + # so that passing a `TypedDict` doesn't cause an error. + # https://github.com/microsoft/pyright/issues/3526#event-6715453066 + params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, + **kwargs, + ) + + def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, object]: + items = self.qs.stringify_items( + # TODO: type ignore is required as stringify_items is well typed but we can't be + # well typed without heavy validation. + data, # type: ignore + array_format="brackets", + ) + serialized: dict[str, object] = {} + for key, value in items: + existing = serialized.get(key) + + if not existing: + serialized[key] = value + continue + + # If a value has already been set for this key then that + # means we're sending data like `array[]=[1, 2, 3]` and we + # need to tell httpx that we want to send multiple values with + # the same key which is done by using a list or a tuple. + # + # Note: 2d arrays should never result in the same key at both + # levels so it's safe to assume that if the value is a list, + # it was because we changed it to be a list. + if is_list(existing): + existing.append(value) + else: + serialized[key] = [existing, value] + + return serialized + + def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalRequestOptions) -> type[ResponseT]: + if not is_given(options.headers): + return cast_to + + # make a copy of the headers so we don't mutate user-input + headers = dict(options.headers) + + # we internally support defining a temporary header to override the + # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response` + # see _response.py for implementation details + override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, not_given) + if is_given(override_cast_to): + options.headers = headers + return cast(Type[ResponseT], override_cast_to) + + return cast_to + + def _should_stream_response_body(self, request: httpx.Request) -> bool: + return request.headers.get(RAW_RESPONSE_HEADER) == "stream" # type: ignore[no-any-return] + + def _process_response_data( + self, + *, + data: object, + cast_to: type[ResponseT], + response: httpx.Response, + ) -> ResponseT: + if data is None: + return cast(ResponseT, None) + + if cast_to is object: + return cast(ResponseT, data) + + try: + if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol): + return cast(ResponseT, cast_to.build(response=response, data=data)) + + if self._strict_response_validation: + return cast(ResponseT, validate_type(type_=cast_to, value=data)) + + return cast(ResponseT, construct_type(type_=cast_to, value=data)) + except pydantic.ValidationError as err: + raise APIResponseValidationError(response=response, body=data) from err + + @property + def qs(self) -> Querystring: + return Querystring() + + @property + def custom_auth(self) -> httpx.Auth | None: + return None + + @property + def auth_headers(self) -> dict[str, str]: + return {} + + @property + def default_headers(self) -> dict[str, str | Omit]: + return { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": self.user_agent, + **self.platform_headers(), + **self.auth_headers, + **self._custom_headers, + } + + @property + def default_query(self) -> dict[str, object]: + return { + **self._custom_query, + } + + def _validate_headers( + self, + headers: Headers, # noqa: ARG002 + custom_headers: Headers, # noqa: ARG002 + ) -> None: + """Validate the given default headers and custom headers. + + Does nothing by default. + """ + return + + @property + def user_agent(self) -> str: + return f"{self.__class__.__name__}/Python {self._version}" + + @property + def base_url(self) -> URL: + return self._base_url + + @base_url.setter + def base_url(self, url: URL | str) -> None: + self._base_url = self._enforce_trailing_slash(url if isinstance(url, URL) else URL(url)) + + def platform_headers(self) -> Dict[str, str]: + # the actual implementation is in a separate `lru_cache` decorated + # function because adding `lru_cache` to methods will leak memory + # https://github.com/python/cpython/issues/88476 + return platform_headers(self._version, platform=self._platform) + + def _parse_retry_after_header(self, response_headers: Optional[httpx.Headers] = None) -> float | None: + """Returns a float of the number of seconds (not milliseconds) to wait after retrying, or None if unspecified. + + About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + See also https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#syntax + """ + if response_headers is None: + return None + + # First, try the non-standard `retry-after-ms` header for milliseconds, + # which is more precise than integer-seconds `retry-after` + try: + retry_ms_header = response_headers.get("retry-after-ms", None) + return float(retry_ms_header) / 1000 + except (TypeError, ValueError): + pass + + # Next, try parsing `retry-after` header as seconds (allowing nonstandard floats). + retry_header = response_headers.get("retry-after") + try: + # note: the spec indicates that this should only ever be an integer + # but if someone sends a float there's no reason for us to not respect it + return float(retry_header) + except (TypeError, ValueError): + pass + + # Last, try parsing `retry-after` as a date. + retry_date_tuple = email.utils.parsedate_tz(retry_header) + if retry_date_tuple is None: + return None + + retry_date = email.utils.mktime_tz(retry_date_tuple) + return float(retry_date - time.time()) + + def _calculate_retry_timeout( + self, + remaining_retries: int, + options: FinalRequestOptions, + response_headers: Optional[httpx.Headers] = None, + ) -> float: + max_retries = options.get_max_retries(self.max_retries) + + # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says. + retry_after = self._parse_retry_after_header(response_headers) + if retry_after is not None and 0 < retry_after <= 60: + return retry_after + + # Also cap retry count to 1000 to avoid any potential overflows with `pow` + nb_retries = min(max_retries - remaining_retries, 1000) + + # Apply exponential backoff, but not more than the max. + sleep_seconds = min(INITIAL_RETRY_DELAY * pow(2.0, nb_retries), MAX_RETRY_DELAY) + + # Apply some jitter, plus-or-minus half a second. + jitter = 1 - 0.25 * random() + timeout = sleep_seconds * jitter + return timeout if timeout >= 0 else 0 + + def _should_retry(self, response: httpx.Response) -> bool: + # Note: this is not a standard header + should_retry_header = response.headers.get("x-should-retry") + + # If the server explicitly says whether or not to retry, obey. + if should_retry_header == "true": + log.debug("Retrying as header `x-should-retry` is set to `true`") + return True + if should_retry_header == "false": + log.debug("Not retrying as header `x-should-retry` is set to `false`") + return False + + # Retry on request timeouts. + if response.status_code == 408: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry on lock timeouts. + if response.status_code == 409: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry on rate limits. + if response.status_code == 429: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry internal errors. + if response.status_code >= 500: + log.debug("Retrying due to status code %i", response.status_code) + return True + + log.debug("Not retrying") + return False + + def _idempotency_key(self) -> str: + return f"stainless-python-retry-{uuid.uuid4()}" + + +class _DefaultHttpxClient(httpx.Client): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +if TYPE_CHECKING: + DefaultHttpxClient = httpx.Client + """An alias to `httpx.Client` that provides the same defaults that this SDK + uses internally. + + This is useful because overriding the `http_client` with your own instance of + `httpx.Client` will result in httpx's defaults being used, not ours. + """ +else: + DefaultHttpxClient = _DefaultHttpxClient + + +class SyncHttpxClientWrapper(DefaultHttpxClient): + def __del__(self) -> None: + if self.is_closed: + return + + try: + self.close() + except Exception: + pass + + +class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]): + _client: httpx.Client + _default_stream_cls: type[Stream[Any]] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.Client | None = None, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + _strict_response_validation: bool, + ) -> None: + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + + if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}" + ) + + super().__init__( + version=version, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + base_url=base_url, + max_retries=max_retries, + custom_query=custom_query, + custom_headers=custom_headers, + _strict_response_validation=_strict_response_validation, + ) + self._client = http_client or SyncHttpxClientWrapper( + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + # If an error is thrown while constructing a client, self._client + # may not be present + if hasattr(self, "_client"): + self._client.close() + + def __enter__(self: _T) -> _T: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def _prepare_options( + self, + options: FinalRequestOptions, # noqa: ARG002 + ) -> FinalRequestOptions: + """Hook for mutating the given options""" + return options + + def _prepare_request( + self, + request: httpx.Request, # noqa: ARG002 + ) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[True], + stream_cls: Type[_StreamT], + ) -> _StreamT: ... + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: Type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + cast_to = self._maybe_override_cast_to(cast_to, options) + + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() + + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) + + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = self._prepare_options(options) + + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + self._prepare_request(request) + + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth + + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + + log.debug("Sending HTTP Request: %s %s", request.method, request.url) + + response = None + try: + response = self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + err.response.close() + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue + + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + err.response.read() + + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None + + break + + assert response is not None, "could not resolve response (should never happen)" + return self._process_response( + cast_to=cast_to, + options=options, + response=response, + stream=stream, + stream_cls=stream_cls, + retries_taken=retries_taken, + ) + + def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken + if remaining_retries == 1: + log.debug("1 retry left") + else: + log.debug("%i retries left", remaining_retries) + + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) + log.info("Retrying request to %s in %f seconds", options.url, timeout) + + time.sleep(timeout) + + def _process_response( + self, + *, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + response: httpx.Response, + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + retries_taken: int = 0, + ) -> ResponseT: + origin = get_origin(cast_to) or cast_to + + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): + if not issubclass(origin, APIResponse): + raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") + + response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) + return cast( + ResponseT, + response_cls( + raw=response, + client=self, + cast_to=extract_response_type(response_cls), + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ), + ) + + if cast_to == httpx.Response: + return cast(ResponseT, response) + + api_response = APIResponse( + raw=response, + client=self, + cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ) + if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): + return cast(ResponseT, api_response) + + return api_response.parse() + + def _request_api_list( + self, + model: Type[object], + page: Type[SyncPageT], + options: FinalRequestOptions, + ) -> SyncPageT: + def _parser(resp: SyncPageT) -> SyncPageT: + resp._set_private_attributes( + client=self, + model=model, + options=options, + ) + return resp + + options.post_parser = _parser + + return self.request(page, options, stream=False) + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + opts = FinalRequestOptions.construct(method="get", url=path, **options) + # cast is required because mypy complains about returning Any even though + # it understands the type variables + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: ... + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, files=to_httpx_files(files), **options + ) + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) + + def patch( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + return self.request(cast_to, opts) + + def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, files=to_httpx_files(files), **options + ) + return self.request(cast_to, opts) + + def delete( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + return self.request(cast_to, opts) + + def get_api_list( + self, + path: str, + *, + model: Type[object], + page: Type[SyncPageT], + body: Body | None = None, + options: RequestOptions = {}, + method: str = "get", + ) -> SyncPageT: + opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) + return self._request_api_list(model, page, opts) + + +class _DefaultAsyncHttpxClient(httpx.AsyncClient): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +try: + import httpx_aiohttp +except ImportError: + + class _DefaultAioHttpClient(httpx.AsyncClient): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra") +else: + + class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + + super().__init__(**kwargs) + + +if TYPE_CHECKING: + DefaultAsyncHttpxClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK + uses internally. + + This is useful because overriding the `http_client` with your own instance of + `httpx.AsyncClient` will result in httpx's defaults being used, not ours. + """ + + DefaultAioHttpClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`.""" +else: + DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient + DefaultAioHttpClient = _DefaultAioHttpClient + + +class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): + def __del__(self) -> None: + if self.is_closed: + return + + try: + # TODO(someday): support non asyncio runtimes here + asyncio.get_running_loop().create_task(self.aclose()) + except Exception: + pass + + +class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]): + _client: httpx.AsyncClient + _default_stream_cls: type[AsyncStream[Any]] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + _strict_response_validation: bool, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.AsyncClient | None = None, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + ) -> None: + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + + if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}" + ) + + super().__init__( + version=version, + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + max_retries=max_retries, + custom_query=custom_query, + custom_headers=custom_headers, + _strict_response_validation=_strict_response_validation, + ) + self._client = http_client or AsyncHttpxClientWrapper( + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + async def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + await self._client.aclose() + + async def __aenter__(self: _T) -> _T: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def _prepare_options( + self, + options: FinalRequestOptions, # noqa: ARG002 + ) -> FinalRequestOptions: + """Hook for mutating the given options""" + return options + + async def _prepare_request( + self, + request: httpx.Request, # noqa: ARG002 + ) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + if self._platform is None: + # `get_platform` can make blocking IO calls so we + # execute it earlier while we are in an async context + self._platform = await asyncify(get_platform)() + + cast_to = self._maybe_override_cast_to(cast_to, options) + + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() + + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) + + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = await self._prepare_options(options) + + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + await self._prepare_request(request) + + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth + + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + + log.debug("Sending HTTP Request: %s %s", request.method, request.url) + + response = None + try: + response = await self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + await err.response.aclose() + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue + + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + await err.response.aread() + + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None + + break + + assert response is not None, "could not resolve response (should never happen)" + return await self._process_response( + cast_to=cast_to, + options=options, + response=response, + stream=stream, + stream_cls=stream_cls, + retries_taken=retries_taken, + ) + + async def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken + if remaining_retries == 1: + log.debug("1 retry left") + else: + log.debug("%i retries left", remaining_retries) + + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) + log.info("Retrying request to %s in %f seconds", options.url, timeout) + + await anyio.sleep(timeout) + + async def _process_response( + self, + *, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + response: httpx.Response, + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + retries_taken: int = 0, + ) -> ResponseT: + origin = get_origin(cast_to) or cast_to + + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): + if not issubclass(origin, AsyncAPIResponse): + raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") + + response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) + return cast( + "ResponseT", + response_cls( + raw=response, + client=self, + cast_to=extract_response_type(response_cls), + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ), + ) + + if cast_to == httpx.Response: + return cast(ResponseT, response) + + api_response = AsyncAPIResponse( + raw=response, + client=self, + cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ) + if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): + return cast(ResponseT, api_response) + + return await api_response.parse() + + def _request_api_list( + self, + model: Type[_T], + page: Type[AsyncPageT], + options: FinalRequestOptions, + ) -> AsyncPaginator[_T, AsyncPageT]: + return AsyncPaginator(client=self, options=options, page_cls=page, model=model) + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + opts = FinalRequestOptions.construct(method="get", url=path, **options) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + ) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) + + async def patch( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + return await self.request(cast_to, opts) + + async def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + ) + return await self.request(cast_to, opts) + + async def delete( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + return await self.request(cast_to, opts) + + def get_api_list( + self, + path: str, + *, + model: Type[_T], + page: Type[AsyncPageT], + body: Body | None = None, + options: RequestOptions = {}, + method: str = "get", + ) -> AsyncPaginator[_T, AsyncPageT]: + opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) + return self._request_api_list(model, page, opts) + + +def make_request_options( + *, + query: Query | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + post_parser: PostParser | NotGiven = not_given, +) -> RequestOptions: + """Create a dict of type RequestOptions without keys of NotGiven values.""" + options: RequestOptions = {} + if extra_headers is not None: + options["headers"] = extra_headers + + if extra_body is not None: + options["extra_json"] = cast(AnyMapping, extra_body) + + if query is not None: + options["params"] = query + + if extra_query is not None: + options["params"] = {**options.get("params", {}), **extra_query} + + if not isinstance(timeout, NotGiven): + options["timeout"] = timeout + + if idempotency_key is not None: + options["idempotency_key"] = idempotency_key + + if is_given(post_parser): + # internal + options["post_parser"] = post_parser # type: ignore + + return options + + +class ForceMultipartDict(Dict[str, None]): + def __bool__(self) -> bool: + return True + + +class OtherPlatform: + def __init__(self, name: str) -> None: + self.name = name + + @override + def __str__(self) -> str: + return f"Other:{self.name}" + + +Platform = Union[ + OtherPlatform, + Literal[ + "MacOS", + "Linux", + "Windows", + "FreeBSD", + "OpenBSD", + "iOS", + "Android", + "Unknown", + ], +] + + +def get_platform() -> Platform: + try: + system = platform.system().lower() + platform_name = platform.platform().lower() + except Exception: + return "Unknown" + + if "iphone" in platform_name or "ipad" in platform_name: + # Tested using Python3IDE on an iPhone 11 and Pythonista on an iPad 7 + # system is Darwin and platform_name is a string like: + # - Darwin-21.6.0-iPhone12,1-64bit + # - Darwin-21.6.0-iPad7,11-64bit + return "iOS" + + if system == "darwin": + return "MacOS" + + if system == "windows": + return "Windows" + + if "android" in platform_name: + # Tested using Pydroid 3 + # system is Linux and platform_name is a string like 'Linux-5.10.81-android12-9-00001-geba40aecb3b7-ab8534902-aarch64-with-libc' + return "Android" + + if system == "linux": + # https://distro.readthedocs.io/en/latest/#distro.id + distro_id = distro.id() + if distro_id == "freebsd": + return "FreeBSD" + + if distro_id == "openbsd": + return "OpenBSD" + + return "Linux" + + if platform_name: + return OtherPlatform(platform_name) + + return "Unknown" + + +@lru_cache(maxsize=None) +def platform_headers(version: str, *, platform: Platform | None) -> Dict[str, str]: + return { + "X-Stainless-Lang": "python", + "X-Stainless-Package-Version": version, + "X-Stainless-OS": str(platform or get_platform()), + "X-Stainless-Arch": str(get_architecture()), + "X-Stainless-Runtime": get_python_runtime(), + "X-Stainless-Runtime-Version": get_python_version(), + } + + +class OtherArch: + def __init__(self, name: str) -> None: + self.name = name + + @override + def __str__(self) -> str: + return f"other:{self.name}" + + +Arch = Union[OtherArch, Literal["x32", "x64", "arm", "arm64", "unknown"]] + + +def get_python_runtime() -> str: + try: + return platform.python_implementation() + except Exception: + return "unknown" + + +def get_python_version() -> str: + try: + return platform.python_version() + except Exception: + return "unknown" + + +def get_architecture() -> Arch: + try: + machine = platform.machine().lower() + except Exception: + return "unknown" + + if machine in ("arm64", "aarch64"): + return "arm64" + + # TODO: untested + if machine == "arm": + return "arm" + + if machine == "x86_64": + return "x64" + + # TODO: untested + if sys.maxsize <= 2**32: + return "x32" + + if machine: + return OtherArch(machine) + + return "unknown" + + +def _merge_mappings( + obj1: Mapping[_T_co, Union[_T, Omit]], + obj2: Mapping[_T_co, Union[_T, Omit]], +) -> Dict[_T_co, _T]: + """Merge two mappings of the same type, removing any values that are instances of `Omit`. + + In cases with duplicate keys the second mapping takes precedence. + """ + merged = {**obj1, **obj2} + return {key: value for key, value in merged.items() if not isinstance(value, Omit)} diff --git a/src/imagekitio/_client.py b/src/imagekitio/_client.py new file mode 100644 index 00000000..3b9f4aec --- /dev/null +++ b/src/imagekitio/_client.py @@ -0,0 +1,555 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +import base64 +from typing import Any, Mapping +from typing_extensions import Self, override + +import httpx + +from . import _exceptions +from ._qs import Querystring +from .lib import helper +from ._types import ( + Omit, + Headers, + Timeout, + NotGiven, + Transport, + ProxiesTypes, + RequestOptions, + not_given, +) +from ._utils import is_given, get_async_library +from ._version import __version__ +from .resources import dummy, assets, webhooks, custom_metadata_fields +from ._streaming import Stream as Stream, AsyncStream as AsyncStream +from ._exceptions import ImageKitError, APIStatusError +from ._base_client import ( + DEFAULT_MAX_RETRIES, + SyncAPIClient, + AsyncAPIClient, +) +from .resources.beta import beta +from .resources.cache import cache +from .resources.files import files +from .resources.folders import folders +from .resources.accounts import accounts + +__all__ = [ + "Timeout", + "Transport", + "ProxiesTypes", + "RequestOptions", + "ImageKit", + "AsyncImageKit", + "Client", + "AsyncClient", +] + + +class ImageKit(SyncAPIClient): + dummy: dummy.DummyResource + custom_metadata_fields: custom_metadata_fields.CustomMetadataFieldsResource + files: files.FilesResource + assets: assets.AssetsResource + cache: cache.CacheResource + folders: folders.FoldersResource + accounts: accounts.AccountsResource + beta: beta.BetaResource + webhooks: webhooks.WebhooksResource + helper: helper.HelperResource + with_raw_response: ImageKitWithRawResponse + with_streaming_response: ImageKitWithStreamedResponse + + # client options + private_key: str + password: str | None + webhook_secret: str | None + + def __init__( + self, + *, + private_key: str | None = None, + password: str | None = None, + webhook_secret: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. + # We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. + # See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details. + http_client: httpx.Client | None = None, + # Enable or disable schema validation for data returned by the API. + # When enabled an error APIResponseValidationError is raised + # if the API responds with invalid data for the expected schema. + # + # This parameter may be removed or changed in the future. + # If you rely on this feature, please open a GitHub issue + # outlining your use-case to help us decide if it should be + # part of our public interface in the future. + _strict_response_validation: bool = False, + ) -> None: + """Construct a new synchronous ImageKit client instance. + + This automatically infers the following arguments from their corresponding environment variables if they are not provided: + - `private_key` from `IMAGEKIT_PRIVATE_KEY` + - `password` from `OPTIONAL_IMAGEKIT_IGNORES_THIS` + - `webhook_secret` from `IMAGEKIT_WEBHOOK_SECRET` + """ + if private_key is None: + private_key = os.environ.get("IMAGEKIT_PRIVATE_KEY") + if private_key is None: + raise ImageKitError( + "The private_key client option must be set either by passing private_key to the client or by setting the IMAGEKIT_PRIVATE_KEY environment variable" + ) + self.private_key = private_key + + if password is None: + password = os.environ.get("OPTIONAL_IMAGEKIT_IGNORES_THIS") or "do_not_set" + self.password = password + + if webhook_secret is None: + webhook_secret = os.environ.get("IMAGEKIT_WEBHOOK_SECRET") + self.webhook_secret = webhook_secret + + if base_url is None: + base_url = os.environ.get("IMAGE_KIT_BASE_URL") + self._base_url_overridden = base_url is not None + if base_url is None: + base_url = f"https://api.imagekit.io" + + super().__init__( + version=__version__, + base_url=base_url, + max_retries=max_retries, + timeout=timeout, + http_client=http_client, + custom_headers=default_headers, + custom_query=default_query, + _strict_response_validation=_strict_response_validation, + ) + + self.dummy = dummy.DummyResource(self) + self.custom_metadata_fields = custom_metadata_fields.CustomMetadataFieldsResource(self) + self.files = files.FilesResource(self) + self.assets = assets.AssetsResource(self) + self.cache = cache.CacheResource(self) + self.folders = folders.FoldersResource(self) + self.accounts = accounts.AccountsResource(self) + self.beta = beta.BetaResource(self) + self.webhooks = webhooks.WebhooksResource(self) + self.helper = helper.HelperResource(self) + self.with_raw_response = ImageKitWithRawResponse(self) + self.with_streaming_response = ImageKitWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="comma") + + @property + @override + def auth_headers(self) -> dict[str, str]: + if self.password is None: + return {} + credentials = f"{self.private_key}:{self.password}".encode("ascii") + header = f"Basic {base64.b64encode(credentials).decode('ascii')}" + return {"Authorization": header} + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": "false", + **self._custom_headers, + } + + @override + def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: + if self.private_key and self.password and headers.get("Authorization"): + return + if isinstance(custom_headers.get("Authorization"), Omit): + return + + raise TypeError( + '"Could not resolve authentication method. Expected the private_key or password to be set. Or for the `Authorization` headers to be explicitly omitted"' + ) + + def copy( + self, + *, + private_key: str | None = None, + password: str | None = None, + webhook_secret: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.Client | None = None, + max_retries: int | NotGiven = not_given, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + http_client = http_client or self._client + client = self.__class__( + private_key=private_key or self.private_key, + password=password or self.password, + webhook_secret=webhook_secret or self.webhook_secret, + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + client._base_url_overridden = self._base_url_overridden or base_url is not None + return client + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class AsyncImageKit(AsyncAPIClient): + dummy: dummy.AsyncDummyResource + custom_metadata_fields: custom_metadata_fields.AsyncCustomMetadataFieldsResource + files: files.AsyncFilesResource + assets: assets.AsyncAssetsResource + cache: cache.AsyncCacheResource + folders: folders.AsyncFoldersResource + accounts: accounts.AsyncAccountsResource + beta: beta.AsyncBetaResource + webhooks: webhooks.AsyncWebhooksResource + helper: helper.AsyncHelperResource + with_raw_response: AsyncImageKitWithRawResponse + with_streaming_response: AsyncImageKitWithStreamedResponse + + # client options + private_key: str + password: str | None + webhook_secret: str | None + + def __init__( + self, + *, + private_key: str | None = None, + password: str | None = None, + webhook_secret: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. + # We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. + # See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details. + http_client: httpx.AsyncClient | None = None, + # Enable or disable schema validation for data returned by the API. + # When enabled an error APIResponseValidationError is raised + # if the API responds with invalid data for the expected schema. + # + # This parameter may be removed or changed in the future. + # If you rely on this feature, please open a GitHub issue + # outlining your use-case to help us decide if it should be + # part of our public interface in the future. + _strict_response_validation: bool = False, + ) -> None: + """Construct a new async AsyncImageKit client instance. + + This automatically infers the following arguments from their corresponding environment variables if they are not provided: + - `private_key` from `IMAGEKIT_PRIVATE_KEY` + - `password` from `OPTIONAL_IMAGEKIT_IGNORES_THIS` + - `webhook_secret` from `IMAGEKIT_WEBHOOK_SECRET` + """ + if private_key is None: + private_key = os.environ.get("IMAGEKIT_PRIVATE_KEY") + if private_key is None: + raise ImageKitError( + "The private_key client option must be set either by passing private_key to the client or by setting the IMAGEKIT_PRIVATE_KEY environment variable" + ) + self.private_key = private_key + + if password is None: + password = os.environ.get("OPTIONAL_IMAGEKIT_IGNORES_THIS") or "do_not_set" + self.password = password + + if webhook_secret is None: + webhook_secret = os.environ.get("IMAGEKIT_WEBHOOK_SECRET") + self.webhook_secret = webhook_secret + + if base_url is None: + base_url = os.environ.get("IMAGE_KIT_BASE_URL") + self._base_url_overridden = base_url is not None + if base_url is None: + base_url = f"https://api.imagekit.io" + + super().__init__( + version=__version__, + base_url=base_url, + max_retries=max_retries, + timeout=timeout, + http_client=http_client, + custom_headers=default_headers, + custom_query=default_query, + _strict_response_validation=_strict_response_validation, + ) + + self.dummy = dummy.AsyncDummyResource(self) + self.custom_metadata_fields = custom_metadata_fields.AsyncCustomMetadataFieldsResource(self) + self.files = files.AsyncFilesResource(self) + self.assets = assets.AsyncAssetsResource(self) + self.cache = cache.AsyncCacheResource(self) + self.folders = folders.AsyncFoldersResource(self) + self.accounts = accounts.AsyncAccountsResource(self) + self.beta = beta.AsyncBetaResource(self) + self.webhooks = webhooks.AsyncWebhooksResource(self) + self.helper = helper.AsyncHelperResource(self) + self.with_raw_response = AsyncImageKitWithRawResponse(self) + self.with_streaming_response = AsyncImageKitWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="comma") + + @property + @override + def auth_headers(self) -> dict[str, str]: + if self.password is None: + return {} + credentials = f"{self.private_key}:{self.password}".encode("ascii") + header = f"Basic {base64.b64encode(credentials).decode('ascii')}" + return {"Authorization": header} + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": f"async:{get_async_library()}", + **self._custom_headers, + } + + @override + def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: + if self.private_key and self.password and headers.get("Authorization"): + return + if isinstance(custom_headers.get("Authorization"), Omit): + return + + raise TypeError( + '"Could not resolve authentication method. Expected the private_key or password to be set. Or for the `Authorization` headers to be explicitly omitted"' + ) + + def copy( + self, + *, + private_key: str | None = None, + password: str | None = None, + webhook_secret: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.AsyncClient | None = None, + max_retries: int | NotGiven = not_given, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + http_client = http_client or self._client + client = self.__class__( + private_key=private_key or self.private_key, + password=password or self.password, + webhook_secret=webhook_secret or self.webhook_secret, + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + client._base_url_overridden = self._base_url_overridden or base_url is not None + return client + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class ImageKitWithRawResponse: + def __init__(self, client: ImageKit) -> None: + self.dummy = dummy.DummyResourceWithRawResponse(client.dummy) + self.custom_metadata_fields = custom_metadata_fields.CustomMetadataFieldsResourceWithRawResponse( + client.custom_metadata_fields + ) + self.files = files.FilesResourceWithRawResponse(client.files) + self.assets = assets.AssetsResourceWithRawResponse(client.assets) + self.cache = cache.CacheResourceWithRawResponse(client.cache) + self.folders = folders.FoldersResourceWithRawResponse(client.folders) + self.accounts = accounts.AccountsResourceWithRawResponse(client.accounts) + self.beta = beta.BetaResourceWithRawResponse(client.beta) + + +class AsyncImageKitWithRawResponse: + def __init__(self, client: AsyncImageKit) -> None: + self.dummy = dummy.AsyncDummyResourceWithRawResponse(client.dummy) + self.custom_metadata_fields = custom_metadata_fields.AsyncCustomMetadataFieldsResourceWithRawResponse( + client.custom_metadata_fields + ) + self.files = files.AsyncFilesResourceWithRawResponse(client.files) + self.assets = assets.AsyncAssetsResourceWithRawResponse(client.assets) + self.cache = cache.AsyncCacheResourceWithRawResponse(client.cache) + self.folders = folders.AsyncFoldersResourceWithRawResponse(client.folders) + self.accounts = accounts.AsyncAccountsResourceWithRawResponse(client.accounts) + self.beta = beta.AsyncBetaResourceWithRawResponse(client.beta) + + +class ImageKitWithStreamedResponse: + def __init__(self, client: ImageKit) -> None: + self.dummy = dummy.DummyResourceWithStreamingResponse(client.dummy) + self.custom_metadata_fields = custom_metadata_fields.CustomMetadataFieldsResourceWithStreamingResponse( + client.custom_metadata_fields + ) + self.files = files.FilesResourceWithStreamingResponse(client.files) + self.assets = assets.AssetsResourceWithStreamingResponse(client.assets) + self.cache = cache.CacheResourceWithStreamingResponse(client.cache) + self.folders = folders.FoldersResourceWithStreamingResponse(client.folders) + self.accounts = accounts.AccountsResourceWithStreamingResponse(client.accounts) + self.beta = beta.BetaResourceWithStreamingResponse(client.beta) + + +class AsyncImageKitWithStreamedResponse: + def __init__(self, client: AsyncImageKit) -> None: + self.dummy = dummy.AsyncDummyResourceWithStreamingResponse(client.dummy) + self.custom_metadata_fields = custom_metadata_fields.AsyncCustomMetadataFieldsResourceWithStreamingResponse( + client.custom_metadata_fields + ) + self.files = files.AsyncFilesResourceWithStreamingResponse(client.files) + self.assets = assets.AsyncAssetsResourceWithStreamingResponse(client.assets) + self.cache = cache.AsyncCacheResourceWithStreamingResponse(client.cache) + self.folders = folders.AsyncFoldersResourceWithStreamingResponse(client.folders) + self.accounts = accounts.AsyncAccountsResourceWithStreamingResponse(client.accounts) + self.beta = beta.AsyncBetaResourceWithStreamingResponse(client.beta) + + +Client = ImageKit + +AsyncClient = AsyncImageKit diff --git a/src/imagekitio/_compat.py b/src/imagekitio/_compat.py new file mode 100644 index 00000000..bdef67f0 --- /dev/null +++ b/src/imagekitio/_compat.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload +from datetime import date, datetime +from typing_extensions import Self, Literal + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import IncEx, StrBytesIntFloat + +_T = TypeVar("_T") +_ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) + +# --------------- Pydantic v2, v3 compatibility --------------- + +# Pyright incorrectly reports some of our functions as overriding a method when they don't +# pyright: reportIncompatibleMethodOverride=false + +PYDANTIC_V1 = pydantic.VERSION.startswith("1.") + +if TYPE_CHECKING: + + def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 + ... + + def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: # noqa: ARG001 + ... + + def get_args(t: type[Any]) -> tuple[Any, ...]: # noqa: ARG001 + ... + + def is_union(tp: type[Any] | None) -> bool: # noqa: ARG001 + ... + + def get_origin(t: type[Any]) -> type[Any] | None: # noqa: ARG001 + ... + + def is_literal_type(type_: type[Any]) -> bool: # noqa: ARG001 + ... + + def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 + ... + +else: + # v1 re-exports + if PYDANTIC_V1: + from pydantic.typing import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, + ) + from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + else: + from ._utils import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + parse_date as parse_date, + is_typeddict as is_typeddict, + parse_datetime as parse_datetime, + is_literal_type as is_literal_type, + ) + + +# refactored config +if TYPE_CHECKING: + from pydantic import ConfigDict as ConfigDict +else: + if PYDANTIC_V1: + # TODO: provide an error message here? + ConfigDict = None + else: + from pydantic import ConfigDict as ConfigDict + + +# renamed methods / properties +def parse_obj(model: type[_ModelT], value: object) -> _ModelT: + if PYDANTIC_V1: + return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + else: + return model.model_validate(value) + + +def field_is_required(field: FieldInfo) -> bool: + if PYDANTIC_V1: + return field.required # type: ignore + return field.is_required() + + +def field_get_default(field: FieldInfo) -> Any: + value = field.get_default() + if PYDANTIC_V1: + return value + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None + return value + + +def field_outer_type(field: FieldInfo) -> Any: + if PYDANTIC_V1: + return field.outer_type_ # type: ignore + return field.annotation + + +def get_model_config(model: type[pydantic.BaseModel]) -> Any: + if PYDANTIC_V1: + return model.__config__ # type: ignore + return model.model_config + + +def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: + if PYDANTIC_V1: + return model.__fields__ # type: ignore + return model.model_fields + + +def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: + if PYDANTIC_V1: + return model.copy(deep=deep) # type: ignore + return model.model_copy(deep=deep) + + +def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: + if PYDANTIC_V1: + return model.json(indent=indent) # type: ignore + return model.model_dump_json(indent=indent) + + +def model_dump( + model: pydantic.BaseModel, + *, + exclude: IncEx | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + warnings: bool = True, + mode: Literal["json", "python"] = "python", +) -> dict[str, Any]: + if (not PYDANTIC_V1) or hasattr(model, "model_dump"): + return model.model_dump( + mode=mode, + exclude=exclude, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + # warnings are not supported in Pydantic v1 + warnings=True if PYDANTIC_V1 else warnings, + ) + return cast( + "dict[str, Any]", + model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + exclude=exclude, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + ), + ) + + +def model_parse(model: type[_ModelT], data: Any) -> _ModelT: + if PYDANTIC_V1: + return model.parse_obj(data) # pyright: ignore[reportDeprecated] + return model.model_validate(data) + + +# generic models +if TYPE_CHECKING: + + class GenericModel(pydantic.BaseModel): ... + +else: + if PYDANTIC_V1: + import pydantic.generics + + class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... + else: + # there no longer needs to be a distinction in v2 but + # we still have to create our own subclass to avoid + # inconsistent MRO ordering errors + class GenericModel(pydantic.BaseModel): ... + + +# cached properties +if TYPE_CHECKING: + cached_property = property + + # we define a separate type (copied from typeshed) + # that represents that `cached_property` is `set`able + # at runtime, which differs from `@property`. + # + # this is a separate type as editors likely special case + # `@property` and we don't want to cause issues just to have + # more helpful internal types. + + class typed_cached_property(Generic[_T]): + func: Callable[[Any], _T] + attrname: str | None + + def __init__(self, func: Callable[[Any], _T]) -> None: ... + + @overload + def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... + + @overload + def __get__(self, instance: object, owner: type[Any] | None = None) -> _T: ... + + def __get__(self, instance: object, owner: type[Any] | None = None) -> _T | Self: + raise NotImplementedError() + + def __set_name__(self, owner: type[Any], name: str) -> None: ... + + # __set__ is not defined at runtime, but @cached_property is designed to be settable + def __set__(self, instance: object, value: _T) -> None: ... +else: + from functools import cached_property as cached_property + + typed_cached_property = cached_property diff --git a/src/imagekitio/_constants.py b/src/imagekitio/_constants.py new file mode 100644 index 00000000..6ddf2c71 --- /dev/null +++ b/src/imagekitio/_constants.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import httpx + +RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response" +OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to" + +# default timeout is 1 minute +DEFAULT_TIMEOUT = httpx.Timeout(timeout=60, connect=5.0) +DEFAULT_MAX_RETRIES = 2 +DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20) + +INITIAL_RETRY_DELAY = 0.5 +MAX_RETRY_DELAY = 8.0 diff --git a/src/imagekitio/_exceptions.py b/src/imagekitio/_exceptions.py new file mode 100644 index 00000000..364ab5af --- /dev/null +++ b/src/imagekitio/_exceptions.py @@ -0,0 +1,112 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal + +import httpx + +__all__ = [ + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", +] + + +class ImageKitError(Exception): + pass + + +class APIError(ImageKitError): + message: str + request: httpx.Request + + body: object | None + """The API response body. + + If the API responded with a valid JSON structure then this property will be the + decoded result. + + If it isn't a valid JSON structure then this will be the raw response. + + If there was no response associated with this error then it will be `None`. + """ + + def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None: # noqa: ARG002 + super().__init__(message) + self.request = request + self.message = message + self.body = body + + +class APIResponseValidationError(APIError): + response: httpx.Response + status_code: int + + def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None: + super().__init__(message or "Data returned by API invalid for expected schema.", response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIWebhookValidationError(APIError): + pass + + +class APIStatusError(APIError): + """Raised when an API response has a status code of 4xx or 5xx.""" + + response: httpx.Response + status_code: int + + def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None: + super().__init__(message, response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIConnectionError(APIError): + def __init__(self, *, message: str = "Connection error.", request: httpx.Request) -> None: + super().__init__(message, request, body=None) + + +class APITimeoutError(APIConnectionError): + def __init__(self, request: httpx.Request) -> None: + super().__init__(message="Request timed out.", request=request) + + +class BadRequestError(APIStatusError): + status_code: Literal[400] = 400 # pyright: ignore[reportIncompatibleVariableOverride] + + +class AuthenticationError(APIStatusError): + status_code: Literal[401] = 401 # pyright: ignore[reportIncompatibleVariableOverride] + + +class PermissionDeniedError(APIStatusError): + status_code: Literal[403] = 403 # pyright: ignore[reportIncompatibleVariableOverride] + + +class NotFoundError(APIStatusError): + status_code: Literal[404] = 404 # pyright: ignore[reportIncompatibleVariableOverride] + + +class ConflictError(APIStatusError): + status_code: Literal[409] = 409 # pyright: ignore[reportIncompatibleVariableOverride] + + +class UnprocessableEntityError(APIStatusError): + status_code: Literal[422] = 422 # pyright: ignore[reportIncompatibleVariableOverride] + + +class RateLimitError(APIStatusError): + status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] + + +class InternalServerError(APIStatusError): + pass diff --git a/src/imagekitio/_files.py b/src/imagekitio/_files.py new file mode 100644 index 00000000..3cf7941c --- /dev/null +++ b/src/imagekitio/_files.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import io +import os +import pathlib +from typing import overload +from typing_extensions import TypeGuard + +import anyio + +from ._types import ( + FileTypes, + FileContent, + RequestFiles, + HttpxFileTypes, + Base64FileInput, + HttpxFileContent, + HttpxRequestFiles, +) +from ._utils import is_tuple_t, is_mapping_t, is_sequence_t + + +def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: + return isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + + +def is_file_content(obj: object) -> TypeGuard[FileContent]: + return ( + isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + ) + + +def assert_is_file_content(obj: object, *, key: str | None = None) -> None: + if not is_file_content(obj): + prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" + raise RuntimeError( + f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead. See https://github.com/stainless-sdks/imagekit-python/tree/main#file-uploads" + ) from None + + +@overload +def to_httpx_files(files: None) -> None: ... + + +@overload +def to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... + + +def to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: + if files is None: + return None + + if is_mapping_t(files): + files = {key: _transform_file(file) for key, file in files.items()} + elif is_sequence_t(files): + files = [(key, _transform_file(file)) for key, file in files] + else: + raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") + + return files + + +def _transform_file(file: FileTypes) -> HttpxFileTypes: + if is_file_content(file): + if isinstance(file, os.PathLike): + path = pathlib.Path(file) + return (path.name, path.read_bytes()) + + return file + + if is_tuple_t(file): + return (file[0], read_file_content(file[1]), *file[2:]) + + raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") + + +def read_file_content(file: FileContent) -> HttpxFileContent: + if isinstance(file, os.PathLike): + return pathlib.Path(file).read_bytes() + return file + + +@overload +async def async_to_httpx_files(files: None) -> None: ... + + +@overload +async def async_to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... + + +async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: + if files is None: + return None + + if is_mapping_t(files): + files = {key: await _async_transform_file(file) for key, file in files.items()} + elif is_sequence_t(files): + files = [(key, await _async_transform_file(file)) for key, file in files] + else: + raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence") + + return files + + +async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: + if is_file_content(file): + if isinstance(file, os.PathLike): + path = anyio.Path(file) + return (path.name, await path.read_bytes()) + + return file + + if is_tuple_t(file): + return (file[0], await async_read_file_content(file[1]), *file[2:]) + + raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") + + +async def async_read_file_content(file: FileContent) -> HttpxFileContent: + if isinstance(file, os.PathLike): + return await anyio.Path(file).read_bytes() + + return file diff --git a/src/imagekitio/_models.py b/src/imagekitio/_models.py new file mode 100644 index 00000000..ca9500b2 --- /dev/null +++ b/src/imagekitio/_models.py @@ -0,0 +1,857 @@ +from __future__ import annotations + +import os +import inspect +import weakref +from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from datetime import date, datetime +from typing_extensions import ( + List, + Unpack, + Literal, + ClassVar, + Protocol, + Required, + ParamSpec, + TypedDict, + TypeGuard, + final, + override, + runtime_checkable, +) + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import ( + Body, + IncEx, + Query, + ModelT, + Headers, + Timeout, + NotGiven, + AnyMapping, + HttpxRequestFiles, +) +from ._utils import ( + PropertyInfo, + is_list, + is_given, + json_safe, + lru_cache, + is_mapping, + parse_date, + coerce_boolean, + parse_datetime, + strip_not_given, + extract_type_arg, + is_annotated_type, + is_type_alias_type, + strip_annotated_type, +) +from ._compat import ( + PYDANTIC_V1, + ConfigDict, + GenericModel as BaseGenericModel, + get_args, + is_union, + parse_obj, + get_origin, + is_literal_type, + get_model_config, + get_model_fields, + field_get_default, +) +from ._constants import RAW_RESPONSE_HEADER + +if TYPE_CHECKING: + from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema + +__all__ = ["BaseModel", "GenericModel"] + +_T = TypeVar("_T") +_BaseModelT = TypeVar("_BaseModelT", bound="BaseModel") + +P = ParamSpec("P") + + +@runtime_checkable +class _ConfigProtocol(Protocol): + allow_population_by_field_name: bool + + +class BaseModel(pydantic.BaseModel): + if PYDANTIC_V1: + + @property + @override + def model_fields_set(self) -> set[str]: + # a forwards-compat shim for pydantic v2 + return self.__fields_set__ # type: ignore + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + extra: Any = pydantic.Extra.allow # type: ignore + else: + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) + + def to_dict( + self, + *, + mode: Literal["json", "python"] = "python", + use_api_names: bool = True, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = False, + warnings: bool = True, + ) -> dict[str, object]: + """Recursively generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + By default, fields that were not set by the API will not be included, + and keys will match the API response, *not* the property names from the model. + + For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, + the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). + + Args: + mode: + If mode is 'json', the dictionary will only contain JSON serializable types. e.g. `datetime` will be turned into a string, `"2024-3-22T18:11:19.117000Z"`. + If mode is 'python', the dictionary may contain any Python objects. e.g. `datetime(2024, 3, 22)` + + use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value from the output. + exclude_none: Whether to exclude fields that have a value of `None` from the output. + warnings: Whether to log warnings when invalid fields are encountered. This is only supported in Pydantic v2. + """ + return self.model_dump( + mode=mode, + by_alias=use_api_names, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + warnings=warnings, + ) + + def to_json( + self, + *, + indent: int | None = 2, + use_api_names: bool = True, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = False, + warnings: bool = True, + ) -> str: + """Generates a JSON string representing this model as it would be received from or sent to the API (but with indentation). + + By default, fields that were not set by the API will not be included, + and keys will match the API response, *not* the property names from the model. + + For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, + the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). + + Args: + indent: Indentation to use in the JSON output. If `None` is passed, the output will be compact. Defaults to `2` + use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + warnings: Whether to show any warnings that occurred during serialization. This is only supported in Pydantic v2. + """ + return self.model_dump_json( + indent=indent, + by_alias=use_api_names, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + warnings=warnings, + ) + + @override + def __str__(self) -> str: + # mypy complains about an invalid self arg + return f"{self.__repr_name__()}({self.__repr_str__(', ')})" # type: ignore[misc] + + # Override the 'construct' method in a way that supports recursive parsing without validation. + # Based on https://github.com/samuelcolvin/pydantic/issues/1168#issuecomment-817742836. + @classmethod + @override + def construct( # pyright: ignore[reportIncompatibleMethodOverride] + __cls: Type[ModelT], + _fields_set: set[str] | None = None, + **values: object, + ) -> ModelT: + m = __cls.__new__(__cls) + fields_values: dict[str, object] = {} + + config = get_model_config(__cls) + populate_by_name = ( + config.allow_population_by_field_name + if isinstance(config, _ConfigProtocol) + else config.get("populate_by_name") + ) + + if _fields_set is None: + _fields_set = set() + + model_fields = get_model_fields(__cls) + for name, field in model_fields.items(): + key = field.alias + if key is None or (key not in values and populate_by_name): + key = name + + if key in values: + fields_values[name] = _construct_field(value=values[key], field=field, key=key) + _fields_set.add(name) + else: + fields_values[name] = field_get_default(field) + + extra_field_type = _get_extra_fields_type(__cls) + + _extra = {} + for key, value in values.items(): + if key not in model_fields: + parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value + + if PYDANTIC_V1: + _fields_set.add(key) + fields_values[key] = parsed + else: + _extra[key] = parsed + + object.__setattr__(m, "__dict__", fields_values) + + if PYDANTIC_V1: + # init_private_attributes() does not exist in v2 + m._init_private_attributes() # type: ignore + + # copied from Pydantic v1's `construct()` method + object.__setattr__(m, "__fields_set__", _fields_set) + else: + # these properties are copied from Pydantic's `model_construct()` method + object.__setattr__(m, "__pydantic_private__", None) + object.__setattr__(m, "__pydantic_extra__", _extra) + object.__setattr__(m, "__pydantic_fields_set__", _fields_set) + + return m + + if not TYPE_CHECKING: + # type checkers incorrectly complain about this assignment + # because the type signatures are technically different + # although not in practice + model_construct = construct + + if PYDANTIC_V1: + # we define aliases for some of the new pydantic v2 methods so + # that we can just document these methods without having to specify + # a specific pydantic version as some users may not know which + # pydantic version they are currently using + + @override + def model_dump( + self, + *, + mode: Literal["json", "python"] | str = "python", + include: IncEx | None = None, + exclude: IncEx | None = None, + context: Any | None = None, + by_alias: bool | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + exclude_computed_fields: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, + ) -> dict[str, Any]: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump + + Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + Args: + mode: The mode in which `to_python` should run. + If mode is 'json', the output will only contain JSON serializable types. + If mode is 'python', the output may contain non-JSON-serializable Python objects. + include: A set of fields to include in the output. + exclude: A set of fields to exclude from the output. + context: Additional context to pass to the serializer. + by_alias: Whether to use the field's alias in the dictionary key if defined. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value. + exclude_none: Whether to exclude fields that have a value of `None`. + exclude_computed_fields: Whether to exclude computed fields. + While this can be useful for round-tripping, it is usually recommended to use the dedicated + `round_trip` parameter instead. + round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T]. + warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors, + "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. + fallback: A function to call when an unknown value is encountered. If not provided, + a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised. + serialize_as_any: Whether to serialize fields with duck-typing serialization behavior. + + Returns: + A dictionary representation of the model. + """ + if mode not in {"json", "python"}: + raise ValueError("mode must be either 'json' or 'python'") + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + if context is not None: + raise ValueError("context is only supported in Pydantic v2") + if serialize_as_any != False: + raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") + dumped = super().dict( # pyright: ignore[reportDeprecated] + include=include, + exclude=exclude, + by_alias=by_alias if by_alias is not None else False, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + return cast("dict[str, Any]", json_safe(dumped)) if mode == "json" else dumped + + @override + def model_dump_json( + self, + *, + indent: int | None = None, + ensure_ascii: bool = False, + include: IncEx | None = None, + exclude: IncEx | None = None, + context: Any | None = None, + by_alias: bool | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + exclude_computed_fields: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, + ) -> str: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json + + Generates a JSON representation of the model using Pydantic's `to_json` method. + + Args: + indent: Indentation to use in the JSON output. If None is passed, the output will be compact. + include: Field(s) to include in the JSON output. Can take either a string or set of strings. + exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings. + by_alias: Whether to serialize using field aliases. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + round_trip: Whether to use serialization/deserialization between JSON and class instance. + warnings: Whether to show any warnings that occurred during serialization. + + Returns: + A JSON string representation of the model. + """ + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + if context is not None: + raise ValueError("context is only supported in Pydantic v2") + if serialize_as_any != False: + raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") + if ensure_ascii != False: + raise ValueError("ensure_ascii is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") + return super().json( # type: ignore[reportDeprecated] + indent=indent, + include=include, + exclude=exclude, + by_alias=by_alias if by_alias is not None else False, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + +def _construct_field(value: object, field: FieldInfo, key: str) -> object: + if value is None: + return field_get_default(field) + + if PYDANTIC_V1: + type_ = cast(type, field.outer_type_) # type: ignore + else: + type_ = field.annotation # type: ignore + + if type_ is None: + raise RuntimeError(f"Unexpected field type is None for {key}") + + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) + + +def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: + if PYDANTIC_V1: + # TODO + return None + + schema = cls.__pydantic_core_schema__ + if schema["type"] == "model": + fields = schema["schema"] + if fields["type"] == "model-fields": + extras = fields.get("extras_schema") + if extras and "cls" in extras: + # mypy can't narrow the type + return extras["cls"] # type: ignore[no-any-return] + + return None + + +def is_basemodel(type_: type) -> bool: + """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`""" + if is_union(type_): + for variant in get_args(type_): + if is_basemodel(variant): + return True + + return False + + return is_basemodel_type(type_) + + +def is_basemodel_type(type_: type) -> TypeGuard[type[BaseModel] | type[GenericModel]]: + origin = get_origin(type_) or type_ + if not inspect.isclass(origin): + return False + return issubclass(origin, BaseModel) or issubclass(origin, GenericModel) + + +def build( + base_model_cls: Callable[P, _BaseModelT], + *args: P.args, + **kwargs: P.kwargs, +) -> _BaseModelT: + """Construct a BaseModel class without validation. + + This is useful for cases where you need to instantiate a `BaseModel` + from an API response as this provides type-safe params which isn't supported + by helpers like `construct_type()`. + + ```py + build(MyModel, my_field_a="foo", my_field_b=123) + ``` + """ + if args: + raise TypeError( + "Received positional arguments which are not supported; Keyword arguments must be used instead", + ) + + return cast(_BaseModelT, construct_type(type_=base_model_cls, value=kwargs)) + + +def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: + """Loose coercion to the expected type with construction of nested values. + + Note: the returned value from this function is not guaranteed to match the + given type. + """ + return cast(_T, construct_type(value=value, type_=type_)) + + +def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: + """Loose coercion to the expected type with construction of nested values. + + If the given value does not match the expected type then it is returned as-is. + """ + + # store a reference to the original type we were given before we extract any inner + # types so that we can properly resolve forward references in `TypeAliasType` annotations + original_type = None + + # we allow `object` as the input type because otherwise, passing things like + # `Literal['value']` will be reported as a type error by type checkers + type_ = cast("type[object]", type_) + if is_type_alias_type(type_): + original_type = type_ # type: ignore[unreachable] + type_ = type_.__value__ # type: ignore[unreachable] + + # unwrap `Annotated[T, ...]` -> `T` + if metadata is not None and len(metadata) > 0: + meta: tuple[Any, ...] = tuple(metadata) + elif is_annotated_type(type_): + meta = get_args(type_)[1:] + type_ = extract_type_arg(type_, 0) + else: + meta = tuple() + + # we need to use the origin class for any types that are subscripted generics + # e.g. Dict[str, object] + origin = get_origin(type_) or type_ + args = get_args(type_) + + if is_union(origin): + try: + return validate_type(type_=cast("type[object]", original_type or type_), value=value) + except Exception: + pass + + # if the type is a discriminated union then we want to construct the right variant + # in the union, even if the data doesn't match exactly, otherwise we'd break code + # that relies on the constructed class types, e.g. + # + # class FooType: + # kind: Literal['foo'] + # value: str + # + # class BarType: + # kind: Literal['bar'] + # value: int + # + # without this block, if the data we get is something like `{'kind': 'bar', 'value': 'foo'}` then + # we'd end up constructing `FooType` when it should be `BarType`. + discriminator = _build_discriminated_union_meta(union=type_, meta_annotations=meta) + if discriminator and is_mapping(value): + variant_value = value.get(discriminator.field_alias_from or discriminator.field_name) + if variant_value and isinstance(variant_value, str): + variant_type = discriminator.mapping.get(variant_value) + if variant_type: + return construct_type(type_=variant_type, value=value) + + # if the data is not valid, use the first variant that doesn't fail while deserializing + for variant in args: + try: + return construct_type(value=value, type_=variant) + except Exception: + continue + + raise RuntimeError(f"Could not convert data into a valid instance of {type_}") + + if origin == dict: + if not is_mapping(value): + return value + + _, items_type = get_args(type_) # Dict[_, items_type] + return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} + + if ( + not is_literal_type(type_) + and inspect.isclass(origin) + and (issubclass(origin, BaseModel) or issubclass(origin, GenericModel)) + ): + if is_list(value): + return [cast(Any, type_).construct(**entry) if is_mapping(entry) else entry for entry in value] + + if is_mapping(value): + if issubclass(type_, BaseModel): + return type_.construct(**value) # type: ignore[arg-type] + + return cast(Any, type_).construct(**value) + + if origin == list: + if not is_list(value): + return value + + inner_type = args[0] # List[inner_type] + return [construct_type(value=entry, type_=inner_type) for entry in value] + + if origin == float: + if isinstance(value, int): + coerced = float(value) + if coerced != value: + return value + return coerced + + return value + + if type_ == datetime: + try: + return parse_datetime(value) # type: ignore + except Exception: + return value + + if type_ == date: + try: + return parse_date(value) # type: ignore + except Exception: + return value + + return value + + +@runtime_checkable +class CachedDiscriminatorType(Protocol): + __discriminator__: DiscriminatorDetails + + +DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary() + + +class DiscriminatorDetails: + field_name: str + """The name of the discriminator field in the variant class, e.g. + + ```py + class Foo(BaseModel): + type: Literal['foo'] + ``` + + Will result in field_name='type' + """ + + field_alias_from: str | None + """The name of the discriminator field in the API response, e.g. + + ```py + class Foo(BaseModel): + type: Literal['foo'] = Field(alias='type_from_api') + ``` + + Will result in field_alias_from='type_from_api' + """ + + mapping: dict[str, type] + """Mapping of discriminator value to variant type, e.g. + + {'foo': FooVariant, 'bar': BarVariant} + """ + + def __init__( + self, + *, + mapping: dict[str, type], + discriminator_field: str, + discriminator_alias: str | None, + ) -> None: + self.mapping = mapping + self.field_name = discriminator_field + self.field_alias_from = discriminator_alias + + +def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: + cached = DISCRIMINATOR_CACHE.get(union) + if cached is not None: + return cached + + discriminator_field_name: str | None = None + + for annotation in meta_annotations: + if isinstance(annotation, PropertyInfo) and annotation.discriminator is not None: + discriminator_field_name = annotation.discriminator + break + + if not discriminator_field_name: + return None + + mapping: dict[str, type] = {} + discriminator_alias: str | None = None + + for variant in get_args(union): + variant = strip_annotated_type(variant) + if is_basemodel_type(variant): + if PYDANTIC_V1: + field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + if not field_info: + continue + + # Note: if one variant defines an alias then they all should + discriminator_alias = field_info.alias + + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): + if isinstance(entry, str): + mapping[entry] = variant + else: + field = _extract_field_schema_pv2(variant, discriminator_field_name) + if not field: + continue + + # Note: if one variant defines an alias then they all should + discriminator_alias = field.get("serialization_alias") + + field_schema = field["schema"] + + if field_schema["type"] == "literal": + for entry in cast("LiteralSchema", field_schema)["expected"]: + if isinstance(entry, str): + mapping[entry] = variant + + if not mapping: + return None + + details = DiscriminatorDetails( + mapping=mapping, + discriminator_field=discriminator_field_name, + discriminator_alias=discriminator_alias, + ) + DISCRIMINATOR_CACHE.setdefault(union, details) + return details + + +def _extract_field_schema_pv2(model: type[BaseModel], field_name: str) -> ModelField | None: + schema = model.__pydantic_core_schema__ + if schema["type"] == "definitions": + schema = schema["schema"] + + if schema["type"] != "model": + return None + + schema = cast("ModelSchema", schema) + fields_schema = schema["schema"] + if fields_schema["type"] != "model-fields": + return None + + fields_schema = cast("ModelFieldsSchema", fields_schema) + field = fields_schema["fields"].get(field_name) + if not field: + return None + + return cast("ModelField", field) # pyright: ignore[reportUnnecessaryCast] + + +def validate_type(*, type_: type[_T], value: object) -> _T: + """Strict validation that the given value matches the expected type""" + if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): + return cast(_T, parse_obj(type_, value)) + + return cast(_T, _validate_non_model_type(type_=type_, value=value)) + + +def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None: + """Add a pydantic config for the given type. + + Note: this is a no-op on Pydantic v1. + """ + setattr(typ, "__pydantic_config__", config) # noqa: B010 + + +# our use of subclassing here causes weirdness for type checkers, +# so we just pretend that we don't subclass +if TYPE_CHECKING: + GenericModel = BaseModel +else: + + class GenericModel(BaseGenericModel, BaseModel): + pass + + +if not PYDANTIC_V1: + from pydantic import TypeAdapter as _TypeAdapter + + _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) + + if TYPE_CHECKING: + from pydantic import TypeAdapter + else: + TypeAdapter = _CachedTypeAdapter + + def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: + return TypeAdapter(type_).validate_python(value) + +elif not TYPE_CHECKING: # TODO: condition is weird + + class RootModel(GenericModel, Generic[_T]): + """Used as a placeholder to easily convert runtime types to a Pydantic format + to provide validation. + + For example: + ```py + validated = RootModel[int](__root__="5").__root__ + # validated: 5 + ``` + """ + + __root__: _T + + def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: + model = _create_pydantic_model(type_).validate(value) + return cast(_T, model.__root__) + + def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]: + return RootModel[type_] # type: ignore + + +class FinalRequestOptionsInput(TypedDict, total=False): + method: Required[str] + url: Required[str] + params: Query + headers: Headers + max_retries: int + timeout: float | Timeout | None + files: HttpxRequestFiles | None + idempotency_key: str + json_data: Body + extra_json: AnyMapping + follow_redirects: bool + + +@final +class FinalRequestOptions(pydantic.BaseModel): + method: str + url: str + params: Query = {} + headers: Union[Headers, NotGiven] = NotGiven() + max_retries: Union[int, NotGiven] = NotGiven() + timeout: Union[float, Timeout, None, NotGiven] = NotGiven() + files: Union[HttpxRequestFiles, None] = None + idempotency_key: Union[str, None] = None + post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, None] = None + + # It should be noted that we cannot use `json` here as that would override + # a BaseModel method in an incompatible fashion. + json_data: Union[Body, None] = None + extra_json: Union[AnyMapping, None] = None + + if PYDANTIC_V1: + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + arbitrary_types_allowed: bool = True + else: + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) + + def get_max_retries(self, max_retries: int) -> int: + if isinstance(self.max_retries, NotGiven): + return max_retries + return self.max_retries + + def _strip_raw_response_header(self) -> None: + if not is_given(self.headers): + return + + if self.headers.get(RAW_RESPONSE_HEADER): + self.headers = {**self.headers} + self.headers.pop(RAW_RESPONSE_HEADER) + + # override the `construct` method so that we can run custom transformations. + # this is necessary as we don't want to do any actual runtime type checking + # (which means we can't use validators) but we do want to ensure that `NotGiven` + # values are not present + # + # type ignore required because we're adding explicit types to `**values` + @classmethod + def construct( # type: ignore + cls, + _fields_set: set[str] | None = None, + **values: Unpack[FinalRequestOptionsInput], + ) -> FinalRequestOptions: + kwargs: dict[str, Any] = { + # we unconditionally call `strip_not_given` on any value + # as it will just ignore any non-mapping types + key: strip_not_given(value) + for key, value in values.items() + } + if PYDANTIC_V1: + return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + return super().model_construct(_fields_set, **kwargs) + + if not TYPE_CHECKING: + # type checkers incorrectly complain about this assignment + model_construct = construct diff --git a/src/imagekitio/_qs.py b/src/imagekitio/_qs.py new file mode 100644 index 00000000..ada6fd3f --- /dev/null +++ b/src/imagekitio/_qs.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from typing import Any, List, Tuple, Union, Mapping, TypeVar +from urllib.parse import parse_qs, urlencode +from typing_extensions import Literal, get_args + +from ._types import NotGiven, not_given +from ._utils import flatten + +_T = TypeVar("_T") + + +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + +PrimitiveData = Union[str, int, float, bool, None] +# this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] +# https://github.com/microsoft/pyright/issues/3555 +Data = Union[PrimitiveData, List[Any], Tuple[Any], "Mapping[str, Any]"] +Params = Mapping[str, Data] + + +class Querystring: + array_format: ArrayFormat + nested_format: NestedFormat + + def __init__( + self, + *, + array_format: ArrayFormat = "repeat", + nested_format: NestedFormat = "brackets", + ) -> None: + self.array_format = array_format + self.nested_format = nested_format + + def parse(self, query: str) -> Mapping[str, object]: + # Note: custom format syntax is not supported yet + return parse_qs(query) + + def stringify( + self, + params: Params, + *, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, + ) -> str: + return urlencode( + self.stringify_items( + params, + array_format=array_format, + nested_format=nested_format, + ) + ) + + def stringify_items( + self, + params: Params, + *, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, + ) -> list[tuple[str, str]]: + opts = Options( + qs=self, + array_format=array_format, + nested_format=nested_format, + ) + return flatten([self._stringify_item(key, value, opts) for key, value in params.items()]) + + def _stringify_item( + self, + key: str, + value: Data, + opts: Options, + ) -> list[tuple[str, str]]: + if isinstance(value, Mapping): + items: list[tuple[str, str]] = [] + nested_format = opts.nested_format + for subkey, subvalue in value.items(): + items.extend( + self._stringify_item( + # TODO: error if unknown format + f"{key}.{subkey}" if nested_format == "dots" else f"{key}[{subkey}]", + subvalue, + opts, + ) + ) + return items + + if isinstance(value, (list, tuple)): + array_format = opts.array_format + if array_format == "comma": + return [ + ( + key, + ",".join(self._primitive_value_to_str(item) for item in value if item is not None), + ), + ] + elif array_format == "repeat": + items = [] + for item in value: + items.extend(self._stringify_item(key, item, opts)) + return items + elif array_format == "indices": + raise NotImplementedError("The array indices format is not supported yet") + elif array_format == "brackets": + items = [] + key = key + "[]" + for item in value: + items.extend(self._stringify_item(key, item, opts)) + return items + else: + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + serialised = self._primitive_value_to_str(value) + if not serialised: + return [] + return [(key, serialised)] + + def _primitive_value_to_str(self, value: PrimitiveData) -> str: + # copied from httpx + if value is True: + return "true" + elif value is False: + return "false" + elif value is None: + return "" + return str(value) + + +_qs = Querystring() +parse = _qs.parse +stringify = _qs.stringify +stringify_items = _qs.stringify_items + + +class Options: + array_format: ArrayFormat + nested_format: NestedFormat + + def __init__( + self, + qs: Querystring = _qs, + *, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, + ) -> None: + self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format + self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format diff --git a/src/imagekitio/_resource.py b/src/imagekitio/_resource.py new file mode 100644 index 00000000..f830660f --- /dev/null +++ b/src/imagekitio/_resource.py @@ -0,0 +1,43 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import anyio + +if TYPE_CHECKING: + from ._client import ImageKit, AsyncImageKit + + +class SyncAPIResource: + _client: ImageKit + + def __init__(self, client: ImageKit) -> None: + self._client = client + self._get = client.get + self._post = client.post + self._patch = client.patch + self._put = client.put + self._delete = client.delete + self._get_api_list = client.get_api_list + + def _sleep(self, seconds: float) -> None: + time.sleep(seconds) + + +class AsyncAPIResource: + _client: AsyncImageKit + + def __init__(self, client: AsyncImageKit) -> None: + self._client = client + self._get = client.get + self._post = client.post + self._patch = client.patch + self._put = client.put + self._delete = client.delete + self._get_api_list = client.get_api_list + + async def _sleep(self, seconds: float) -> None: + await anyio.sleep(seconds) diff --git a/src/imagekitio/_response.py b/src/imagekitio/_response.py new file mode 100644 index 00000000..ff8fc4f0 --- /dev/null +++ b/src/imagekitio/_response.py @@ -0,0 +1,832 @@ +from __future__ import annotations + +import os +import inspect +import logging +import datetime +import functools +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Any, + Union, + Generic, + TypeVar, + Callable, + Iterator, + AsyncIterator, + cast, + overload, +) +from typing_extensions import Awaitable, ParamSpec, override, get_origin + +import anyio +import httpx +import pydantic + +from ._types import NoneType +from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type, extract_type_var_from_base +from ._models import BaseModel, is_basemodel +from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER +from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type +from ._exceptions import ImageKitError, APIResponseValidationError + +if TYPE_CHECKING: + from ._models import FinalRequestOptions + from ._base_client import BaseClient + + +P = ParamSpec("P") +R = TypeVar("R") +_T = TypeVar("_T") +_APIResponseT = TypeVar("_APIResponseT", bound="APIResponse[Any]") +_AsyncAPIResponseT = TypeVar("_AsyncAPIResponseT", bound="AsyncAPIResponse[Any]") + +log: logging.Logger = logging.getLogger(__name__) + + +class BaseAPIResponse(Generic[R]): + _cast_to: type[R] + _client: BaseClient[Any, Any] + _parsed_by_type: dict[type[Any], Any] + _is_sse_stream: bool + _stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None + _options: FinalRequestOptions + + http_response: httpx.Response + + retries_taken: int + """The number of retries made. If no retries happened this will be `0`""" + + def __init__( + self, + *, + raw: httpx.Response, + cast_to: type[R], + client: BaseClient[Any, Any], + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + options: FinalRequestOptions, + retries_taken: int = 0, + ) -> None: + self._cast_to = cast_to + self._client = client + self._parsed_by_type = {} + self._is_sse_stream = stream + self._stream_cls = stream_cls + self._options = options + self.http_response = raw + self.retries_taken = retries_taken + + @property + def headers(self) -> httpx.Headers: + return self.http_response.headers + + @property + def http_request(self) -> httpx.Request: + """Returns the httpx Request instance associated with the current response.""" + return self.http_response.request + + @property + def status_code(self) -> int: + return self.http_response.status_code + + @property + def url(self) -> httpx.URL: + """Returns the URL for which the request was made.""" + return self.http_response.url + + @property + def method(self) -> str: + return self.http_request.method + + @property + def http_version(self) -> str: + return self.http_response.http_version + + @property + def elapsed(self) -> datetime.timedelta: + """The time taken for the complete request/response cycle to complete.""" + return self.http_response.elapsed + + @property + def is_closed(self) -> bool: + """Whether or not the response body has been closed. + + If this is False then there is response data that has not been read yet. + You must either fully consume the response body or call `.close()` + before discarding the response to prevent resource leaks. + """ + return self.http_response.is_closed + + @override + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} [{self.status_code} {self.http_response.reason_phrase}] type={self._cast_to}>" + ) + + def _parse(self, *, to: type[_T] | None = None) -> R | _T: + cast_to = to if to is not None else self._cast_to + + # unwrap `TypeAlias('Name', T)` -> `T` + if is_type_alias_type(cast_to): + cast_to = cast_to.__value__ # type: ignore[unreachable] + + # unwrap `Annotated[T, ...]` -> `T` + if cast_to and is_annotated_type(cast_to): + cast_to = extract_type_arg(cast_to, 0) + + origin = get_origin(cast_to) or cast_to + + if self._is_sse_stream: + if to: + if not is_stream_class_type(to): + raise TypeError(f"Expected custom parse type to be a subclass of {Stream} or {AsyncStream}") + + return cast( + _T, + to( + cast_to=extract_stream_chunk_type( + to, + failure_message="Expected custom stream type to be passed with a type argument, e.g. Stream[ChunkType]", + ), + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + if self._stream_cls: + return cast( + R, + self._stream_cls( + cast_to=extract_stream_chunk_type(self._stream_cls), + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + stream_cls = cast("type[Stream[Any]] | type[AsyncStream[Any]] | None", self._client._default_stream_cls) + if stream_cls is None: + raise MissingStreamClassError() + + return cast( + R, + stream_cls( + cast_to=cast_to, + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + if cast_to is NoneType: + return cast(R, None) + + response = self.http_response + if cast_to == str: + return cast(R, response.text) + + if cast_to == bytes: + return cast(R, response.content) + + if cast_to == int: + return cast(R, int(response.text)) + + if cast_to == float: + return cast(R, float(response.text)) + + if cast_to == bool: + return cast(R, response.text.lower() == "true") + + if origin == APIResponse: + raise RuntimeError("Unexpected state - cast_to is `APIResponse`") + + if inspect.isclass(origin) and issubclass(origin, httpx.Response): + # Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response + # and pass that class to our request functions. We cannot change the variance to be either + # covariant or contravariant as that makes our usage of ResponseT illegal. We could construct + # the response class ourselves but that is something that should be supported directly in httpx + # as it would be easy to incorrectly construct the Response object due to the multitude of arguments. + if cast_to != httpx.Response: + raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`") + return cast(R, response) + + if ( + inspect.isclass( + origin # pyright: ignore[reportUnknownArgumentType] + ) + and not issubclass(origin, BaseModel) + and issubclass(origin, pydantic.BaseModel) + ): + raise TypeError( + "Pydantic models must subclass our base model type, e.g. `from imagekitio import BaseModel`" + ) + + if ( + cast_to is not object + and not origin is list + and not origin is dict + and not origin is Union + and not issubclass(origin, BaseModel) + ): + raise RuntimeError( + f"Unsupported type, expected {cast_to} to be a subclass of {BaseModel}, {dict}, {list}, {Union}, {NoneType}, {str} or {httpx.Response}." + ) + + # split is required to handle cases where additional information is included + # in the response, e.g. application/json; charset=utf-8 + content_type, *_ = response.headers.get("content-type", "*").split(";") + if not content_type.endswith("json"): + if is_basemodel(cast_to): + try: + data = response.json() + except Exception as exc: + log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc) + else: + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + + if self._client._strict_response_validation: + raise APIResponseValidationError( + response=response, + message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.", + body=response.text, + ) + + # If the API responds with content that isn't JSON then we just return + # the (decoded) text without performing any parsing so that you can still + # handle the response however you need to. + return response.text # type: ignore + + data = response.json() + + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + + +class APIResponse(BaseAPIResponse[R]): + @overload + def parse(self, *, to: type[_T]) -> _T: ... + + @overload + def parse(self) -> R: ... + + def parse(self, *, to: type[_T] | None = None) -> R | _T: + """Returns the rich python representation of this response's data. + + For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. + + You can customise the type that the response is parsed into through + the `to` argument, e.g. + + ```py + from imagekitio import BaseModel + + + class MyModel(BaseModel): + foo: str + + + obj = response.parse(to=MyModel) + print(obj.foo) + ``` + + We support parsing: + - `BaseModel` + - `dict` + - `list` + - `Union` + - `str` + - `int` + - `float` + - `httpx.Response` + """ + cache_key = to if to is not None else self._cast_to + cached = self._parsed_by_type.get(cache_key) + if cached is not None: + return cached # type: ignore[no-any-return] + + if not self._is_sse_stream: + self.read() + + parsed = self._parse(to=to) + if is_given(self._options.post_parser): + parsed = self._options.post_parser(parsed) + + self._parsed_by_type[cache_key] = parsed + return parsed + + def read(self) -> bytes: + """Read and return the binary response content.""" + try: + return self.http_response.read() + except httpx.StreamConsumed as exc: + # The default error raised by httpx isn't very + # helpful in our case so we re-raise it with + # a different error message. + raise StreamAlreadyConsumed() from exc + + def text(self) -> str: + """Read and decode the response content into a string.""" + self.read() + return self.http_response.text + + def json(self) -> object: + """Read and decode the JSON response content.""" + self.read() + return self.http_response.json() + + def close(self) -> None: + """Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + self.http_response.close() + + def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]: + """ + A byte-iterator over the decoded response content. + + This automatically handles gzip, deflate and brotli encoded responses. + """ + for chunk in self.http_response.iter_bytes(chunk_size): + yield chunk + + def iter_text(self, chunk_size: int | None = None) -> Iterator[str]: + """A str-iterator over the decoded response content + that handles both gzip, deflate, etc but also detects the content's + string encoding. + """ + for chunk in self.http_response.iter_text(chunk_size): + yield chunk + + def iter_lines(self) -> Iterator[str]: + """Like `iter_text()` but will only yield chunks for each line""" + for chunk in self.http_response.iter_lines(): + yield chunk + + +class AsyncAPIResponse(BaseAPIResponse[R]): + @overload + async def parse(self, *, to: type[_T]) -> _T: ... + + @overload + async def parse(self) -> R: ... + + async def parse(self, *, to: type[_T] | None = None) -> R | _T: + """Returns the rich python representation of this response's data. + + For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. + + You can customise the type that the response is parsed into through + the `to` argument, e.g. + + ```py + from imagekitio import BaseModel + + + class MyModel(BaseModel): + foo: str + + + obj = response.parse(to=MyModel) + print(obj.foo) + ``` + + We support parsing: + - `BaseModel` + - `dict` + - `list` + - `Union` + - `str` + - `httpx.Response` + """ + cache_key = to if to is not None else self._cast_to + cached = self._parsed_by_type.get(cache_key) + if cached is not None: + return cached # type: ignore[no-any-return] + + if not self._is_sse_stream: + await self.read() + + parsed = self._parse(to=to) + if is_given(self._options.post_parser): + parsed = self._options.post_parser(parsed) + + self._parsed_by_type[cache_key] = parsed + return parsed + + async def read(self) -> bytes: + """Read and return the binary response content.""" + try: + return await self.http_response.aread() + except httpx.StreamConsumed as exc: + # the default error raised by httpx isn't very + # helpful in our case so we re-raise it with + # a different error message + raise StreamAlreadyConsumed() from exc + + async def text(self) -> str: + """Read and decode the response content into a string.""" + await self.read() + return self.http_response.text + + async def json(self) -> object: + """Read and decode the JSON response content.""" + await self.read() + return self.http_response.json() + + async def close(self) -> None: + """Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + await self.http_response.aclose() + + async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: + """ + A byte-iterator over the decoded response content. + + This automatically handles gzip, deflate and brotli encoded responses. + """ + async for chunk in self.http_response.aiter_bytes(chunk_size): + yield chunk + + async def iter_text(self, chunk_size: int | None = None) -> AsyncIterator[str]: + """A str-iterator over the decoded response content + that handles both gzip, deflate, etc but also detects the content's + string encoding. + """ + async for chunk in self.http_response.aiter_text(chunk_size): + yield chunk + + async def iter_lines(self) -> AsyncIterator[str]: + """Like `iter_text()` but will only yield chunks for each line""" + async for chunk in self.http_response.aiter_lines(): + yield chunk + + +class BinaryAPIResponse(APIResponse[bytes]): + """Subclass of APIResponse providing helpers for dealing with binary data. + + Note: If you want to stream the response data instead of eagerly reading it + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + + def write_to_file( + self, + file: str | os.PathLike[str], + ) -> None: + """Write the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + + Note: if you want to stream the data to the file instead of writing + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + with open(file, mode="wb") as f: + for data in self.iter_bytes(): + f.write(data) + + +class AsyncBinaryAPIResponse(AsyncAPIResponse[bytes]): + """Subclass of APIResponse providing helpers for dealing with binary data. + + Note: If you want to stream the response data instead of eagerly reading it + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + + async def write_to_file( + self, + file: str | os.PathLike[str], + ) -> None: + """Write the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + + Note: if you want to stream the data to the file instead of writing + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + path = anyio.Path(file) + async with await path.open(mode="wb") as f: + async for data in self.iter_bytes(): + await f.write(data) + + +class StreamedBinaryAPIResponse(APIResponse[bytes]): + def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: + """Streams the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + """ + with open(file, mode="wb") as f: + for data in self.iter_bytes(chunk_size): + f.write(data) + + +class AsyncStreamedBinaryAPIResponse(AsyncAPIResponse[bytes]): + async def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: + """Streams the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + """ + path = anyio.Path(file) + async with await path.open(mode="wb") as f: + async for data in self.iter_bytes(chunk_size): + await f.write(data) + + +class MissingStreamClassError(TypeError): + def __init__(self) -> None: + super().__init__( + "The `stream` argument was set to `True` but the `stream_cls` argument was not given. See `imagekitio._streaming` for reference", + ) + + +class StreamAlreadyConsumed(ImageKitError): + """ + Attempted to read or stream content, but the content has already + been streamed. + + This can happen if you use a method like `.iter_lines()` and then attempt + to read th entire response body afterwards, e.g. + + ```py + response = await client.post(...) + async for line in response.iter_lines(): + ... # do something with `line` + + content = await response.read() + # ^ error + ``` + + If you want this behaviour you'll need to either manually accumulate the response + content or call `await response.read()` before iterating over the stream. + """ + + def __init__(self) -> None: + message = ( + "Attempted to read or stream some content, but the content has " + "already been streamed. " + "This could be due to attempting to stream the response " + "content more than once." + "\n\n" + "You can fix this by manually accumulating the response content while streaming " + "or by calling `.read()` before starting to stream." + ) + super().__init__(message) + + +class ResponseContextManager(Generic[_APIResponseT]): + """Context manager for ensuring that a request is not made + until it is entered and that the response will always be closed + when the context manager exits + """ + + def __init__(self, request_func: Callable[[], _APIResponseT]) -> None: + self._request_func = request_func + self.__response: _APIResponseT | None = None + + def __enter__(self) -> _APIResponseT: + self.__response = self._request_func() + return self.__response + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__response is not None: + self.__response.close() + + +class AsyncResponseContextManager(Generic[_AsyncAPIResponseT]): + """Context manager for ensuring that a request is not made + until it is entered and that the response will always be closed + when the context manager exits + """ + + def __init__(self, api_request: Awaitable[_AsyncAPIResponseT]) -> None: + self._api_request = api_request + self.__response: _AsyncAPIResponseT | None = None + + async def __aenter__(self) -> _AsyncAPIResponseT: + self.__response = await self._api_request + return self.__response + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__response is not None: + await self.__response.close() + + +def to_streamed_response_wrapper(func: Callable[P, R]) -> Callable[P, ResponseContextManager[APIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support streaming and returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[APIResponse[R]]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + + kwargs["extra_headers"] = extra_headers + + make_request = functools.partial(func, *args, **kwargs) + + return ResponseContextManager(cast(Callable[[], APIResponse[R]], make_request)) + + return wrapped + + +def async_to_streamed_response_wrapper( + func: Callable[P, Awaitable[R]], +) -> Callable[P, AsyncResponseContextManager[AsyncAPIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support streaming and returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[AsyncAPIResponse[R]]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + + kwargs["extra_headers"] = extra_headers + + make_request = func(*args, **kwargs) + + return AsyncResponseContextManager(cast(Awaitable[AsyncAPIResponse[R]], make_request)) + + return wrapped + + +def to_custom_streamed_response_wrapper( + func: Callable[P, object], + response_cls: type[_APIResponseT], +) -> Callable[P, ResponseContextManager[_APIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support streaming and returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[_APIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + make_request = functools.partial(func, *args, **kwargs) + + return ResponseContextManager(cast(Callable[[], _APIResponseT], make_request)) + + return wrapped + + +def async_to_custom_streamed_response_wrapper( + func: Callable[P, Awaitable[object]], + response_cls: type[_AsyncAPIResponseT], +) -> Callable[P, AsyncResponseContextManager[_AsyncAPIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support streaming and returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[_AsyncAPIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + make_request = func(*args, **kwargs) + + return AsyncResponseContextManager(cast(Awaitable[_AsyncAPIResponseT], make_request)) + + return wrapped + + +def to_raw_response_wrapper(func: Callable[P, R]) -> Callable[P, APIResponse[R]]: + """Higher order function that takes one of our bound API methods and wraps it + to support returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> APIResponse[R]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + + kwargs["extra_headers"] = extra_headers + + return cast(APIResponse[R], func(*args, **kwargs)) + + return wrapped + + +def async_to_raw_response_wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[AsyncAPIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + async def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncAPIResponse[R]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + + kwargs["extra_headers"] = extra_headers + + return cast(AsyncAPIResponse[R], await func(*args, **kwargs)) + + return wrapped + + +def to_custom_raw_response_wrapper( + func: Callable[P, object], + response_cls: type[_APIResponseT], +) -> Callable[P, _APIResponseT]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> _APIResponseT: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + return cast(_APIResponseT, func(*args, **kwargs)) + + return wrapped + + +def async_to_custom_raw_response_wrapper( + func: Callable[P, Awaitable[object]], + response_cls: type[_AsyncAPIResponseT], +) -> Callable[P, Awaitable[_AsyncAPIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> Awaitable[_AsyncAPIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + return cast(Awaitable[_AsyncAPIResponseT], func(*args, **kwargs)) + + return wrapped + + +def extract_response_type(typ: type[BaseAPIResponse[Any]]) -> type: + """Given a type like `APIResponse[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyResponse(APIResponse[bytes]): + ... + + extract_response_type(MyResponse) -> bytes + ``` + """ + return extract_type_var_from_base( + typ, + generic_bases=cast("tuple[type, ...]", (BaseAPIResponse, APIResponse, AsyncAPIResponse)), + index=0, + ) diff --git a/src/imagekitio/_streaming.py b/src/imagekitio/_streaming.py new file mode 100644 index 00000000..c4a0e31e --- /dev/null +++ b/src/imagekitio/_streaming.py @@ -0,0 +1,333 @@ +# Note: initially copied from https://github.com/florimondmanca/httpx-sse/blob/master/src/httpx_sse/_decoders.py +from __future__ import annotations + +import json +import inspect +from types import TracebackType +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast +from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable + +import httpx + +from ._utils import extract_type_var_from_base + +if TYPE_CHECKING: + from ._client import ImageKit, AsyncImageKit + + +_T = TypeVar("_T") + + +class Stream(Generic[_T]): + """Provides the core interface to iterate over a synchronous stream response.""" + + response: httpx.Response + + _decoder: SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: ImageKit, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._decoder = client._make_sse_decoder() + self._iterator = self.__stream__() + + def __next__(self) -> _T: + return self._iterator.__next__() + + def __iter__(self) -> Iterator[_T]: + for item in self._iterator: + yield item + + def _iter_events(self) -> Iterator[ServerSentEvent]: + yield from self._decoder.iter_bytes(self.response.iter_bytes()) + + def __stream__(self) -> Iterator[_T]: + cast_to = cast(Any, self._cast_to) + response = self.response + process_data = self._client._process_response_data + iterator = self._iter_events() + + try: + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + response.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + """ + Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + self.response.close() + + +class AsyncStream(Generic[_T]): + """Provides the core interface to iterate over an asynchronous stream response.""" + + response: httpx.Response + + _decoder: SSEDecoder | SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: AsyncImageKit, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._decoder = client._make_sse_decoder() + self._iterator = self.__stream__() + + async def __anext__(self) -> _T: + return await self._iterator.__anext__() + + async def __aiter__(self) -> AsyncIterator[_T]: + async for item in self._iterator: + yield item + + async def _iter_events(self) -> AsyncIterator[ServerSentEvent]: + async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()): + yield sse + + async def __stream__(self) -> AsyncIterator[_T]: + cast_to = cast(Any, self._cast_to) + response = self.response + process_data = self._client._process_response_data + iterator = self._iter_events() + + try: + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + await response.aclose() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def close(self) -> None: + """ + Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + await self.response.aclose() + + +class ServerSentEvent: + def __init__( + self, + *, + event: str | None = None, + data: str | None = None, + id: str | None = None, + retry: int | None = None, + ) -> None: + if data is None: + data = "" + + self._id = id + self._data = data + self._event = event or None + self._retry = retry + + @property + def event(self) -> str | None: + return self._event + + @property + def id(self) -> str | None: + return self._id + + @property + def retry(self) -> int | None: + return self._retry + + @property + def data(self) -> str: + return self._data + + def json(self) -> Any: + return json.loads(self.data) + + @override + def __repr__(self) -> str: + return f"ServerSentEvent(event={self.event}, data={self.data}, id={self.id}, retry={self.retry})" + + +class SSEDecoder: + _data: list[str] + _event: str | None + _retry: int | None + _last_event_id: str | None + + def __init__(self) -> None: + self._event = None + self._data = [] + self._last_event_id = None + self._retry = None + + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + for chunk in self._iter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + def _iter_chunks(self, iterator: Iterator[bytes]) -> Iterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + async for chunk in self._aiter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + async def _aiter_chunks(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + async for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + def decode(self, line: str) -> ServerSentEvent | None: + # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 + + if not line: + if not self._event and not self._data and not self._last_event_id and self._retry is None: + return None + + sse = ServerSentEvent( + event=self._event, + data="\n".join(self._data), + id=self._last_event_id, + retry=self._retry, + ) + + # NOTE: as per the SSE spec, do not reset last_event_id. + self._event = None + self._data = [] + self._retry = None + + return sse + + if line.startswith(":"): + return None + + fieldname, _, value = line.partition(":") + + if value.startswith(" "): + value = value[1:] + + if fieldname == "event": + self._event = value + elif fieldname == "data": + self._data.append(value) + elif fieldname == "id": + if "\0" in value: + pass + else: + self._last_event_id = value + elif fieldname == "retry": + try: + self._retry = int(value) + except (TypeError, ValueError): + pass + else: + pass # Field is ignored. + + return None + + +@runtime_checkable +class SSEBytesDecoder(Protocol): + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an async iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + +def is_stream_class_type(typ: type) -> TypeGuard[type[Stream[object]] | type[AsyncStream[object]]]: + """TypeGuard for determining whether or not the given type is a subclass of `Stream` / `AsyncStream`""" + origin = get_origin(typ) or typ + return inspect.isclass(origin) and issubclass(origin, (Stream, AsyncStream)) + + +def extract_stream_chunk_type( + stream_cls: type, + *, + failure_message: str | None = None, +) -> type: + """Given a type like `Stream[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyStream(Stream[bytes]): + ... + + extract_stream_chunk_type(MyStream) -> bytes + ``` + """ + from ._base_client import Stream, AsyncStream + + return extract_type_var_from_base( + stream_cls, + index=0, + generic_bases=cast("tuple[type, ...]", (Stream, AsyncStream)), + failure_message=failure_message, + ) diff --git a/src/imagekitio/_types.py b/src/imagekitio/_types.py new file mode 100644 index 00000000..714fee27 --- /dev/null +++ b/src/imagekitio/_types.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +from os import PathLike +from typing import ( + IO, + TYPE_CHECKING, + Any, + Dict, + List, + Type, + Tuple, + Union, + Mapping, + TypeVar, + Callable, + Iterator, + Optional, + Sequence, +) +from typing_extensions import ( + Set, + Literal, + Protocol, + TypeAlias, + TypedDict, + SupportsIndex, + overload, + override, + runtime_checkable, +) + +import httpx +import pydantic +from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport + +if TYPE_CHECKING: + from ._models import BaseModel + from ._response import APIResponse, AsyncAPIResponse + +Transport = BaseTransport +AsyncTransport = AsyncBaseTransport +Query = Mapping[str, object] +Body = object +AnyMapping = Mapping[str, object] +ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) +_T = TypeVar("_T") + + +# Approximates httpx internal ProxiesTypes and RequestFiles types +# while adding support for `PathLike` instances +ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]] +ProxiesTypes = Union[str, Proxy, ProxiesDict] +if TYPE_CHECKING: + Base64FileInput = Union[IO[bytes], PathLike[str]] + FileContent = Union[IO[bytes], bytes, PathLike[str]] +else: + Base64FileInput = Union[IO[bytes], PathLike] + FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. +FileTypes = Union[ + # file (or bytes) + FileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], FileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], FileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], +] +RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] + +# duplicate of the above but without our custom file support +HttpxFileContent = Union[IO[bytes], bytes] +HttpxFileTypes = Union[ + # file (or bytes) + HttpxFileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], HttpxFileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], HttpxFileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], HttpxFileContent, Optional[str], Mapping[str, str]], +] +HttpxRequestFiles = Union[Mapping[str, HttpxFileTypes], Sequence[Tuple[str, HttpxFileTypes]]] + +# Workaround to support (cast_to: Type[ResponseT]) -> ResponseT +# where ResponseT includes `None`. In order to support directly +# passing `None`, overloads would have to be defined for every +# method that uses `ResponseT` which would lead to an unacceptable +# amount of code duplication and make it unreadable. See _base_client.py +# for example usage. +# +# This unfortunately means that you will either have +# to import this type and pass it explicitly: +# +# from imagekitio import NoneType +# client.get('/foo', cast_to=NoneType) +# +# or build it yourself: +# +# client.get('/foo', cast_to=type(None)) +if TYPE_CHECKING: + NoneType: Type[None] +else: + NoneType = type(None) + + +class RequestOptions(TypedDict, total=False): + headers: Headers + max_retries: int + timeout: float | Timeout | None + params: Query + extra_json: AnyMapping + idempotency_key: str + follow_redirects: bool + + +# Sentinel class used until PEP 0661 is accepted +class NotGiven: + """ + For parameters with a meaningful None value, we need to distinguish between + the user explicitly passing None, and the user not passing the parameter at + all. + + User code shouldn't need to use not_given directly. + + For example: + + ```py + def create(timeout: Timeout | None | NotGiven = not_given): ... + + + create(timeout=1) # 1s timeout + create(timeout=None) # No timeout + create() # Default timeout behavior + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + @override + def __repr__(self) -> str: + return "NOT_GIVEN" + + +not_given = NotGiven() +# for backwards compatibility: +NOT_GIVEN = NotGiven() + + +class Omit: + """ + To explicitly omit something from being sent in a request, use `omit`. + + ```py + # as the default `Content-Type` header is `application/json` that will be sent + client.post("/upload/files", files={"file": b"my raw file content"}) + + # you can't explicitly override the header as it has to be dynamically generated + # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' + client.post(..., headers={"Content-Type": "multipart/form-data"}) + + # instead you can remove the default `application/json` header by passing omit + client.post(..., headers={"Content-Type": omit}) + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + +omit = Omit() + + +@runtime_checkable +class ModelBuilderProtocol(Protocol): + @classmethod + def build( + cls: type[_T], + *, + response: Response, + data: object, + ) -> _T: ... + + +Headers = Mapping[str, Union[str, Omit]] + + +class HeadersLikeProtocol(Protocol): + def get(self, __key: str) -> str | None: ... + + +HeadersLike = Union[Headers, HeadersLikeProtocol] + +ResponseT = TypeVar( + "ResponseT", + bound=Union[ + object, + str, + None, + "BaseModel", + List[Any], + Dict[str, Any], + Response, + ModelBuilderProtocol, + "APIResponse[Any]", + "AsyncAPIResponse[Any]", + ], +) + +StrBytesIntFloat = Union[str, bytes, int, float] + +# Note: copied from Pydantic +# https://github.com/pydantic/pydantic/blob/6f31f8f68ef011f84357330186f603ff295312fd/pydantic/main.py#L79 +IncEx: TypeAlias = Union[Set[int], Set[str], Mapping[int, Union["IncEx", bool]], Mapping[str, Union["IncEx", bool]]] + +PostParser = Callable[[Any], Any] + + +@runtime_checkable +class InheritsGeneric(Protocol): + """Represents a type that has inherited from `Generic` + + The `__orig_bases__` property can be used to determine the resolved + type variable for a given base class. + """ + + __orig_bases__: tuple[_GenericAlias] + + +class _GenericAlias(Protocol): + __origin__: type[object] + + +class HttpxSendArgs(TypedDict, total=False): + auth: httpx.Auth + follow_redirects: bool + + +_T_co = TypeVar("_T_co", covariant=True) + + +if TYPE_CHECKING: + # This works because str.__contains__ does not accept object (either in typeshed or at runtime) + # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. + class SequenceNotStr(Protocol[_T_co]): + @overload + def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... + @overload + def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... + def __contains__(self, value: object, /) -> bool: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[_T_co]: ... + def __reversed__(self) -> Iterator[_T_co]: ... +else: + # just point this to a normal `Sequence` at runtime to avoid having to special case + # deserializing our custom sequence type + SequenceNotStr = Sequence diff --git a/src/imagekitio/_utils/__init__.py b/src/imagekitio/_utils/__init__.py new file mode 100644 index 00000000..dc64e29a --- /dev/null +++ b/src/imagekitio/_utils/__init__.py @@ -0,0 +1,64 @@ +from ._sync import asyncify as asyncify +from ._proxy import LazyProxy as LazyProxy +from ._utils import ( + flatten as flatten, + is_dict as is_dict, + is_list as is_list, + is_given as is_given, + is_tuple as is_tuple, + json_safe as json_safe, + lru_cache as lru_cache, + is_mapping as is_mapping, + is_tuple_t as is_tuple_t, + is_iterable as is_iterable, + is_sequence as is_sequence, + coerce_float as coerce_float, + is_mapping_t as is_mapping_t, + removeprefix as removeprefix, + removesuffix as removesuffix, + extract_files as extract_files, + is_sequence_t as is_sequence_t, + required_args as required_args, + coerce_boolean as coerce_boolean, + coerce_integer as coerce_integer, + file_from_path as file_from_path, + strip_not_given as strip_not_given, + deepcopy_minimal as deepcopy_minimal, + get_async_library as get_async_library, + maybe_coerce_float as maybe_coerce_float, + get_required_header as get_required_header, + maybe_coerce_boolean as maybe_coerce_boolean, + maybe_coerce_integer as maybe_coerce_integer, +) +from ._compat import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, +) +from ._typing import ( + is_list_type as is_list_type, + is_union_type as is_union_type, + extract_type_arg as extract_type_arg, + is_iterable_type as is_iterable_type, + is_required_type as is_required_type, + is_sequence_type as is_sequence_type, + is_annotated_type as is_annotated_type, + is_type_alias_type as is_type_alias_type, + strip_annotated_type as strip_annotated_type, + extract_type_var_from_base as extract_type_var_from_base, +) +from ._streams import consume_sync_iterator as consume_sync_iterator, consume_async_iterator as consume_async_iterator +from ._transform import ( + PropertyInfo as PropertyInfo, + transform as transform, + async_transform as async_transform, + maybe_transform as maybe_transform, + async_maybe_transform as async_maybe_transform, +) +from ._reflection import ( + function_has_argument as function_has_argument, + assert_signatures_in_sync as assert_signatures_in_sync, +) +from ._datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime diff --git a/src/imagekitio/_utils/_compat.py b/src/imagekitio/_utils/_compat.py new file mode 100644 index 00000000..dd703233 --- /dev/null +++ b/src/imagekitio/_utils/_compat.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +import typing_extensions +from typing import Any, Type, Union, Literal, Optional +from datetime import date, datetime +from typing_extensions import get_args as _get_args, get_origin as _get_origin + +from .._types import StrBytesIntFloat +from ._datetime_parse import parse_date as _parse_date, parse_datetime as _parse_datetime + +_LITERAL_TYPES = {Literal, typing_extensions.Literal} + + +def get_args(tp: type[Any]) -> tuple[Any, ...]: + return _get_args(tp) + + +def get_origin(tp: type[Any]) -> type[Any] | None: + return _get_origin(tp) + + +def is_union(tp: Optional[Type[Any]]) -> bool: + if sys.version_info < (3, 10): + return tp is Union # type: ignore[comparison-overlap] + else: + import types + + return tp is Union or tp is types.UnionType + + +def is_typeddict(tp: Type[Any]) -> bool: + return typing_extensions.is_typeddict(tp) + + +def is_literal_type(tp: Type[Any]) -> bool: + return get_origin(tp) in _LITERAL_TYPES + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + return _parse_date(value) + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + return _parse_datetime(value) diff --git a/src/imagekitio/_utils/_datetime_parse.py b/src/imagekitio/_utils/_datetime_parse.py new file mode 100644 index 00000000..7cb9d9e6 --- /dev/null +++ b/src/imagekitio/_utils/_datetime_parse.py @@ -0,0 +1,136 @@ +""" +This file contains code from https://github.com/pydantic/pydantic/blob/main/pydantic/v1/datetime_parse.py +without the Pydantic v1 specific errors. +""" + +from __future__ import annotations + +import re +from typing import Dict, Union, Optional +from datetime import date, datetime, timezone, timedelta + +from .._types import StrBytesIntFloat + +date_expr = r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" +time_expr = ( + r"(?P\d{1,2}):(?P\d{1,2})" + r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?" + r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" +) + +date_re = re.compile(f"{date_expr}$") +datetime_re = re.compile(f"{date_expr}[T ]{time_expr}") + + +EPOCH = datetime(1970, 1, 1) +# if greater than this, the number is in ms, if less than or equal it's in seconds +# (in seconds this is 11th October 2603, in ms it's 20th August 1970) +MS_WATERSHED = int(2e10) +# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9 +MAX_NUMBER = int(3e20) + + +def _get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]: + if isinstance(value, (int, float)): + return value + try: + return float(value) + except ValueError: + return None + except TypeError: + raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from None + + +def _from_unix_seconds(seconds: Union[int, float]) -> datetime: + if seconds > MAX_NUMBER: + return datetime.max + elif seconds < -MAX_NUMBER: + return datetime.min + + while abs(seconds) > MS_WATERSHED: + seconds /= 1000 + dt = EPOCH + timedelta(seconds=seconds) + return dt.replace(tzinfo=timezone.utc) + + +def _parse_timezone(value: Optional[str]) -> Union[None, int, timezone]: + if value == "Z": + return timezone.utc + elif value is not None: + offset_mins = int(value[-2:]) if len(value) > 3 else 0 + offset = 60 * int(value[1:3]) + offset_mins + if value[0] == "-": + offset = -offset + return timezone(timedelta(minutes=offset)) + else: + return None + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + """ + Parse a datetime/int/float/string and return a datetime.datetime. + + This function supports time zone offsets. When the input contains one, + the output uses a timezone with a fixed offset from UTC. + + Raise ValueError if the input is well formatted but not a valid datetime. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, datetime): + return value + + number = _get_numeric(value, "datetime") + if number is not None: + return _from_unix_seconds(number) + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + + match = datetime_re.match(value) + if match is None: + raise ValueError("invalid datetime format") + + kw = match.groupdict() + if kw["microsecond"]: + kw["microsecond"] = kw["microsecond"].ljust(6, "0") + + tzinfo = _parse_timezone(kw.pop("tzinfo")) + kw_: Dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None} + kw_["tzinfo"] = tzinfo + + return datetime(**kw_) # type: ignore + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + """ + Parse a date/int/float/string and return a datetime.date. + + Raise ValueError if the input is well formatted but not a valid date. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, date): + if isinstance(value, datetime): + return value.date() + else: + return value + + number = _get_numeric(value, "date") + if number is not None: + return _from_unix_seconds(number).date() + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + match = date_re.match(value) + if match is None: + raise ValueError("invalid date format") + + kw = {k: int(v) for k, v in match.groupdict().items()} + + try: + return date(**kw) + except ValueError: + raise ValueError("invalid date format") from None diff --git a/src/imagekitio/_utils/_logs.py b/src/imagekitio/_utils/_logs.py new file mode 100644 index 00000000..c383e3ea --- /dev/null +++ b/src/imagekitio/_utils/_logs.py @@ -0,0 +1,25 @@ +import os +import logging + +logger: logging.Logger = logging.getLogger("imagekitio") +httpx_logger: logging.Logger = logging.getLogger("httpx") + + +def _basic_config() -> None: + # e.g. [2023-10-05 14:12:26 - imagekitio._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK" + logging.basicConfig( + format="[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def setup_logging() -> None: + env = os.environ.get("IMAGE_KIT_LOG") + if env == "debug": + _basic_config() + logger.setLevel(logging.DEBUG) + httpx_logger.setLevel(logging.DEBUG) + elif env == "info": + _basic_config() + logger.setLevel(logging.INFO) + httpx_logger.setLevel(logging.INFO) diff --git a/src/imagekitio/_utils/_proxy.py b/src/imagekitio/_utils/_proxy.py new file mode 100644 index 00000000..0f239a33 --- /dev/null +++ b/src/imagekitio/_utils/_proxy.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, Iterable, cast +from typing_extensions import override + +T = TypeVar("T") + + +class LazyProxy(Generic[T], ABC): + """Implements data methods to pretend that an instance is another instance. + + This includes forwarding attribute access and other methods. + """ + + # Note: we have to special case proxies that themselves return proxies + # to support using a proxy as a catch-all for any random access, e.g. `proxy.foo.bar.baz` + + def __getattr__(self, attr: str) -> object: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied # pyright: ignore + return getattr(proxied, attr) + + @override + def __repr__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return repr(self.__get_proxied__()) + + @override + def __str__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return str(proxied) + + @override + def __dir__(self) -> Iterable[str]: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return [] + return proxied.__dir__() + + @property # type: ignore + @override + def __class__(self) -> type: # pyright: ignore + try: + proxied = self.__get_proxied__() + except Exception: + return type(self) + if issubclass(type(proxied), LazyProxy): + return type(proxied) + return proxied.__class__ + + def __get_proxied__(self) -> T: + return self.__load__() + + def __as_proxied__(self) -> T: + """Helper method that returns the current proxy, typed as the loaded object""" + return cast(T, self) + + @abstractmethod + def __load__(self) -> T: ... diff --git a/src/imagekitio/_utils/_reflection.py b/src/imagekitio/_utils/_reflection.py new file mode 100644 index 00000000..89aa712a --- /dev/null +++ b/src/imagekitio/_utils/_reflection.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import inspect +from typing import Any, Callable + + +def function_has_argument(func: Callable[..., Any], arg_name: str) -> bool: + """Returns whether or not the given function has a specific parameter""" + sig = inspect.signature(func) + return arg_name in sig.parameters + + +def assert_signatures_in_sync( + source_func: Callable[..., Any], + check_func: Callable[..., Any], + *, + exclude_params: set[str] = set(), +) -> None: + """Ensure that the signature of the second function matches the first.""" + + check_sig = inspect.signature(check_func) + source_sig = inspect.signature(source_func) + + errors: list[str] = [] + + for name, source_param in source_sig.parameters.items(): + if name in exclude_params: + continue + + custom_param = check_sig.parameters.get(name) + if not custom_param: + errors.append(f"the `{name}` param is missing") + continue + + if custom_param.annotation != source_param.annotation: + errors.append( + f"types for the `{name}` param are do not match; source={repr(source_param.annotation)} checking={repr(custom_param.annotation)}" + ) + continue + + if errors: + raise AssertionError(f"{len(errors)} errors encountered when comparing signatures:\n\n" + "\n\n".join(errors)) diff --git a/src/imagekitio/_utils/_resources_proxy.py b/src/imagekitio/_utils/_resources_proxy.py new file mode 100644 index 00000000..5ba91421 --- /dev/null +++ b/src/imagekitio/_utils/_resources_proxy.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any +from typing_extensions import override + +from ._proxy import LazyProxy + + +class ResourcesProxy(LazyProxy[Any]): + """A proxy for the `imagekitio.resources` module. + + This is used so that we can lazily import `imagekitio.resources` only when + needed *and* so that users can just import `imagekitio` and reference `imagekitio.resources` + """ + + @override + def __load__(self) -> Any: + import importlib + + mod = importlib.import_module("imagekitio.resources") + return mod + + +resources = ResourcesProxy().__as_proxied__() diff --git a/src/imagekitio/_utils/_streams.py b/src/imagekitio/_utils/_streams.py new file mode 100644 index 00000000..f4a0208f --- /dev/null +++ b/src/imagekitio/_utils/_streams.py @@ -0,0 +1,12 @@ +from typing import Any +from typing_extensions import Iterator, AsyncIterator + + +def consume_sync_iterator(iterator: Iterator[Any]) -> None: + for _ in iterator: + ... + + +async def consume_async_iterator(iterator: AsyncIterator[Any]) -> None: + async for _ in iterator: + ... diff --git a/src/imagekitio/_utils/_sync.py b/src/imagekitio/_utils/_sync.py new file mode 100644 index 00000000..f6027c18 --- /dev/null +++ b/src/imagekitio/_utils/_sync.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import asyncio +import functools +from typing import TypeVar, Callable, Awaitable +from typing_extensions import ParamSpec + +import anyio +import sniffio +import anyio.to_thread + +T_Retval = TypeVar("T_Retval") +T_ParamSpec = ParamSpec("T_ParamSpec") + + +async def to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs +) -> T_Retval: + if sniffio.current_async_library() == "asyncio": + return await asyncio.to_thread(func, *args, **kwargs) + + return await anyio.to_thread.run_sync( + functools.partial(func, *args, **kwargs), + ) + + +# inspired by `asyncer`, https://github.com/tiangolo/asyncer +def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: + """ + Take a blocking function and create an async one that receives the same + positional and keyword arguments. + + Usage: + + ```python + def blocking_func(arg1, arg2, kwarg1=None): + # blocking code + return result + + + result = asyncify(blocking_function)(arg1, arg2, kwarg1=value1) + ``` + + ## Arguments + + `function`: a blocking regular callable (e.g. a function) + + ## Return + + An async function that takes the same positional and keyword arguments as the + original one, that when called runs the same original function in a thread worker + and returns the result. + """ + + async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: + return await to_thread(function, *args, **kwargs) + + return wrapper diff --git a/src/imagekitio/_utils/_transform.py b/src/imagekitio/_utils/_transform.py new file mode 100644 index 00000000..52075492 --- /dev/null +++ b/src/imagekitio/_utils/_transform.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import io +import base64 +import pathlib +from typing import Any, Mapping, TypeVar, cast +from datetime import date, datetime +from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints + +import anyio +import pydantic + +from ._utils import ( + is_list, + is_given, + lru_cache, + is_mapping, + is_iterable, + is_sequence, +) +from .._files import is_base64_file_input +from ._compat import get_origin, is_typeddict +from ._typing import ( + is_list_type, + is_union_type, + extract_type_arg, + is_iterable_type, + is_required_type, + is_sequence_type, + is_annotated_type, + strip_annotated_type, +) + +_T = TypeVar("_T") + + +# TODO: support for drilling globals() and locals() +# TODO: ensure works correctly with forward references in all cases + + +PropertyFormat = Literal["iso8601", "base64", "custom"] + + +class PropertyInfo: + """Metadata class to be used in Annotated types to provide information about a given type. + + For example: + + class MyParams(TypedDict): + account_holder_name: Annotated[str, PropertyInfo(alias='accountHolderName')] + + This means that {'account_holder_name': 'Robert'} will be transformed to {'accountHolderName': 'Robert'} before being sent to the API. + """ + + alias: str | None + format: PropertyFormat | None + format_template: str | None + discriminator: str | None + + def __init__( + self, + *, + alias: str | None = None, + format: PropertyFormat | None = None, + format_template: str | None = None, + discriminator: str | None = None, + ) -> None: + self.alias = alias + self.format = format + self.format_template = format_template + self.discriminator = discriminator + + @override + def __repr__(self) -> str: + return f"{self.__class__.__name__}(alias='{self.alias}', format={self.format}, format_template='{self.format_template}', discriminator='{self.discriminator}')" + + +def maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `transform()` that allows `None` to be passed. + + See `transform()` for more details. + """ + if data is None: + return None + return transform(data, expected_type) + + +# Wrapper over _transform_recursive providing fake types +def transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = _transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +@lru_cache(maxsize=8096) +def _get_annotated_type(type_: type) -> type | None: + """If the given type is an `Annotated` type then it is returned, if not `None` is returned. + + This also unwraps the type when applicable, e.g. `Required[Annotated[T, ...]]` + """ + if is_required_type(type_): + # Unwrap `Required[Annotated[T, ...]]` to `Annotated[T, ...]` + type_ = get_args(type_)[0] + + if is_annotated_type(type_): + return type_ + + return None + + +def _maybe_transform_key(key: str, type_: type) -> str: + """Transform the given `data` based on the annotations provided in `type_`. + + Note: this function only looks at `Annotated` types that contain `PropertyInfo` metadata. + """ + annotated_type = _get_annotated_type(type_) + if annotated_type is None: + # no `Annotated` definition for this type, no transformation needed + return key + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.alias is not None: + return annotation.alias + + return key + + +def _no_transform_needed(annotation: type) -> bool: + return annotation == float or annotation == int + + +def _transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + from .._compat import model_dump + + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type + if is_typeddict(stripped_type) and is_mapping(data): + return _transform_typeddict(data, stripped_type) + + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) + ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + + inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + + return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = _transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True, mode="json") + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return _format_data(data, annotation.format, annotation.format_template) + + return data + + +def _format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = data.read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +def _transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + if not is_given(value): + # we don't need to include omitted values here as they'll + # be stripped out before the request is sent anyway + continue + + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = _transform_recursive(value, annotation=type_) + return result + + +async def async_maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `async_transform()` that allows `None` to be passed. + + See `async_transform()` for more details. + """ + if data is None: + return None + return await async_transform(data, expected_type) + + +async def async_transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = await _async_transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +async def _async_transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + from .._compat import model_dump + + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type + if is_typeddict(stripped_type) and is_mapping(data): + return await _async_transform_typeddict(data, stripped_type) + + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) + ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + + inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + + return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = await _async_transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True, mode="json") + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return await _async_format_data(data, annotation.format, annotation.format_template) + + return data + + +async def _async_format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = await anyio.Path(data).read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +async def _async_transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + if not is_given(value): + # we don't need to include omitted values here as they'll + # be stripped out before the request is sent anyway + continue + + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_) + return result + + +@lru_cache(maxsize=8096) +def get_type_hints( + obj: Any, + globalns: dict[str, Any] | None = None, + localns: Mapping[str, Any] | None = None, + include_extras: bool = False, +) -> dict[str, Any]: + return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras) diff --git a/src/imagekitio/_utils/_typing.py b/src/imagekitio/_utils/_typing.py new file mode 100644 index 00000000..193109f3 --- /dev/null +++ b/src/imagekitio/_utils/_typing.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import sys +import typing +import typing_extensions +from typing import Any, TypeVar, Iterable, cast +from collections import abc as _c_abc +from typing_extensions import ( + TypeIs, + Required, + Annotated, + get_args, + get_origin, +) + +from ._utils import lru_cache +from .._types import InheritsGeneric +from ._compat import is_union as _is_union + + +def is_annotated_type(typ: type) -> bool: + return get_origin(typ) == Annotated + + +def is_list_type(typ: type) -> bool: + return (get_origin(typ) or typ) == list + + +def is_sequence_type(typ: type) -> bool: + origin = get_origin(typ) or typ + return origin == typing_extensions.Sequence or origin == typing.Sequence or origin == _c_abc.Sequence + + +def is_iterable_type(typ: type) -> bool: + """If the given type is `typing.Iterable[T]`""" + origin = get_origin(typ) or typ + return origin == Iterable or origin == _c_abc.Iterable + + +def is_union_type(typ: type) -> bool: + return _is_union(get_origin(typ)) + + +def is_required_type(typ: type) -> bool: + return get_origin(typ) == Required + + +def is_typevar(typ: type) -> bool: + # type ignore is required because type checkers + # think this expression will always return False + return type(typ) == TypeVar # type: ignore + + +_TYPE_ALIAS_TYPES: tuple[type[typing_extensions.TypeAliasType], ...] = (typing_extensions.TypeAliasType,) +if sys.version_info >= (3, 12): + _TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType) + + +def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: + """Return whether the provided argument is an instance of `TypeAliasType`. + + ```python + type Int = int + is_type_alias_type(Int) + # > True + Str = TypeAliasType("Str", str) + is_type_alias_type(Str) + # > True + ``` + """ + return isinstance(tp, _TYPE_ALIAS_TYPES) + + +# Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]] +@lru_cache(maxsize=8096) +def strip_annotated_type(typ: type) -> type: + if is_required_type(typ) or is_annotated_type(typ): + return strip_annotated_type(cast(type, get_args(typ)[0])) + + return typ + + +def extract_type_arg(typ: type, index: int) -> type: + args = get_args(typ) + try: + return cast(type, args[index]) + except IndexError as err: + raise RuntimeError(f"Expected type {typ} to have a type argument at index {index} but it did not") from err + + +def extract_type_var_from_base( + typ: type, + *, + generic_bases: tuple[type, ...], + index: int, + failure_message: str | None = None, +) -> type: + """Given a type like `Foo[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyResponse(Foo[bytes]): + ... + + extract_type_var(MyResponse, bases=(Foo,), index=0) -> bytes + ``` + + And where a generic subclass is given: + ```py + _T = TypeVar('_T') + class MyResponse(Foo[_T]): + ... + + extract_type_var(MyResponse[bytes], bases=(Foo,), index=0) -> bytes + ``` + """ + cls = cast(object, get_origin(typ) or typ) + if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains] + # we're given the class directly + return extract_type_arg(typ, index) + + # if a subclass is given + # --- + # this is needed as __orig_bases__ is not present in the typeshed stubs + # because it is intended to be for internal use only, however there does + # not seem to be a way to resolve generic TypeVars for inherited subclasses + # without using it. + if isinstance(cls, InheritsGeneric): + target_base_class: Any | None = None + for base in cls.__orig_bases__: + if base.__origin__ in generic_bases: + target_base_class = base + break + + if target_base_class is None: + raise RuntimeError( + "Could not find the generic base class;\n" + "This should never happen;\n" + f"Does {cls} inherit from one of {generic_bases} ?" + ) + + extracted = extract_type_arg(target_base_class, index) + if is_typevar(extracted): + # If the extracted type argument is itself a type variable + # then that means the subclass itself is generic, so we have + # to resolve the type argument from the class itself, not + # the base class. + # + # Note: if there is more than 1 type argument, the subclass could + # change the ordering of the type arguments, this is not currently + # supported. + return extract_type_arg(typ, index) + + return extracted + + raise RuntimeError(failure_message or f"Could not resolve inner type variable at index {index} for {typ}") diff --git a/src/imagekitio/_utils/_utils.py b/src/imagekitio/_utils/_utils.py new file mode 100644 index 00000000..eec7f4a1 --- /dev/null +++ b/src/imagekitio/_utils/_utils.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +import os +import re +import inspect +import functools +from typing import ( + Any, + Tuple, + Mapping, + TypeVar, + Callable, + Iterable, + Sequence, + cast, + overload, +) +from pathlib import Path +from datetime import date, datetime +from typing_extensions import TypeGuard + +import sniffio + +from .._types import Omit, NotGiven, FileTypes, HeadersLike + +_T = TypeVar("_T") +_TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) +_MappingT = TypeVar("_MappingT", bound=Mapping[str, object]) +_SequenceT = TypeVar("_SequenceT", bound=Sequence[object]) +CallableT = TypeVar("CallableT", bound=Callable[..., Any]) + + +def flatten(t: Iterable[Iterable[_T]]) -> list[_T]: + return [item for sublist in t for item in sublist] + + +def extract_files( + # TODO: this needs to take Dict but variance issues..... + # create protocol type ? + query: Mapping[str, object], + *, + paths: Sequence[Sequence[str]], +) -> list[tuple[str, FileTypes]]: + """Recursively extract files from the given dictionary based on specified paths. + + A path may look like this ['foo', 'files', '', 'data']. + + Note: this mutates the given dictionary. + """ + files: list[tuple[str, FileTypes]] = [] + for path in paths: + files.extend(_extract_items(query, path, index=0, flattened_key=None)) + return files + + +def _extract_items( + obj: object, + path: Sequence[str], + *, + index: int, + flattened_key: str | None, +) -> list[tuple[str, FileTypes]]: + try: + key = path[index] + except IndexError: + if not is_given(obj): + # no value was provided - we can safely ignore + return [] + + # cyclical import + from .._files import assert_is_file_content + + # We have exhausted the path, return the entry we found. + assert flattened_key is not None + + if is_list(obj): + files: list[tuple[str, FileTypes]] = [] + for entry in obj: + assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") + files.append((flattened_key + "[]", cast(FileTypes, entry))) + return files + + assert_is_file_content(obj, key=flattened_key) + return [(flattened_key, cast(FileTypes, obj))] + + index += 1 + if is_dict(obj): + try: + # We are at the last entry in the path so we must remove the field + if (len(path)) == index: + item = obj.pop(key) + else: + item = obj[key] + except KeyError: + # Key was not present in the dictionary, this is not indicative of an error + # as the given path may not point to a required field. We also do not want + # to enforce required fields as the API may differ from the spec in some cases. + return [] + if flattened_key is None: + flattened_key = key + else: + flattened_key += f"[{key}]" + return _extract_items( + item, + path, + index=index, + flattened_key=flattened_key, + ) + elif is_list(obj): + if key != "": + return [] + + return flatten( + [ + _extract_items( + item, + path, + index=index, + flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", + ) + for item in obj + ] + ) + + # Something unexpected was passed, just ignore it. + return [] + + +def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: + return not isinstance(obj, NotGiven) and not isinstance(obj, Omit) + + +# Type safe methods for narrowing types with TypeVars. +# The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], +# however this cause Pyright to rightfully report errors. As we know we don't +# care about the contained types we can safely use `object` in its place. +# +# There are two separate functions defined, `is_*` and `is_*_t` for different use cases. +# `is_*` is for when you're dealing with an unknown input +# `is_*_t` is for when you're narrowing a known union type to a specific subset + + +def is_tuple(obj: object) -> TypeGuard[tuple[object, ...]]: + return isinstance(obj, tuple) + + +def is_tuple_t(obj: _TupleT | object) -> TypeGuard[_TupleT]: + return isinstance(obj, tuple) + + +def is_sequence(obj: object) -> TypeGuard[Sequence[object]]: + return isinstance(obj, Sequence) + + +def is_sequence_t(obj: _SequenceT | object) -> TypeGuard[_SequenceT]: + return isinstance(obj, Sequence) + + +def is_mapping(obj: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(obj, Mapping) + + +def is_mapping_t(obj: _MappingT | object) -> TypeGuard[_MappingT]: + return isinstance(obj, Mapping) + + +def is_dict(obj: object) -> TypeGuard[dict[object, object]]: + return isinstance(obj, dict) + + +def is_list(obj: object) -> TypeGuard[list[object]]: + return isinstance(obj, list) + + +def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: + return isinstance(obj, Iterable) + + +def deepcopy_minimal(item: _T) -> _T: + """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: + + - mappings, e.g. `dict` + - list + + This is done for performance reasons. + """ + if is_mapping(item): + return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) + if is_list(item): + return cast(_T, [deepcopy_minimal(entry) for entry in item]) + return item + + +# copied from https://github.com/Rapptz/RoboDanny +def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: + size = len(seq) + if size == 0: + return "" + + if size == 1: + return seq[0] + + if size == 2: + return f"{seq[0]} {final} {seq[1]}" + + return delim.join(seq[:-1]) + f" {final} {seq[-1]}" + + +def quote(string: str) -> str: + """Add single quotation marks around the given string. Does *not* do any escaping.""" + return f"'{string}'" + + +def required_args(*variants: Sequence[str]) -> Callable[[CallableT], CallableT]: + """Decorator to enforce a given set of arguments or variants of arguments are passed to the decorated function. + + Useful for enforcing runtime validation of overloaded functions. + + Example usage: + ```py + @overload + def foo(*, a: str) -> str: ... + + + @overload + def foo(*, b: bool) -> str: ... + + + # This enforces the same constraints that a static type checker would + # i.e. that either a or b must be passed to the function + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: bool | None = None) -> str: ... + ``` + """ + + def inner(func: CallableT) -> CallableT: + params = inspect.signature(func).parameters + positional = [ + name + for name, param in params.items() + if param.kind + in { + param.POSITIONAL_ONLY, + param.POSITIONAL_OR_KEYWORD, + } + ] + + @functools.wraps(func) + def wrapper(*args: object, **kwargs: object) -> object: + given_params: set[str] = set() + for i, _ in enumerate(args): + try: + given_params.add(positional[i]) + except IndexError: + raise TypeError( + f"{func.__name__}() takes {len(positional)} argument(s) but {len(args)} were given" + ) from None + + for key in kwargs.keys(): + given_params.add(key) + + for variant in variants: + matches = all((param in given_params for param in variant)) + if matches: + break + else: # no break + if len(variants) > 1: + variations = human_join( + ["(" + human_join([quote(arg) for arg in variant], final="and") + ")" for variant in variants] + ) + msg = f"Missing required arguments; Expected either {variations} arguments to be given" + else: + assert len(variants) > 0 + + # TODO: this error message is not deterministic + missing = list(set(variants[0]) - given_params) + if len(missing) > 1: + msg = f"Missing required arguments: {human_join([quote(arg) for arg in missing])}" + else: + msg = f"Missing required argument: {quote(missing[0])}" + raise TypeError(msg) + return func(*args, **kwargs) + + return wrapper # type: ignore + + return inner + + +_K = TypeVar("_K") +_V = TypeVar("_V") + + +@overload +def strip_not_given(obj: None) -> None: ... + + +@overload +def strip_not_given(obj: Mapping[_K, _V | NotGiven]) -> dict[_K, _V]: ... + + +@overload +def strip_not_given(obj: object) -> object: ... + + +def strip_not_given(obj: object | None) -> object: + """Remove all top-level keys where their values are instances of `NotGiven`""" + if obj is None: + return None + + if not is_mapping(obj): + return obj + + return {key: value for key, value in obj.items() if not isinstance(value, NotGiven)} + + +def coerce_integer(val: str) -> int: + return int(val, base=10) + + +def coerce_float(val: str) -> float: + return float(val) + + +def coerce_boolean(val: str) -> bool: + return val == "true" or val == "1" or val == "on" + + +def maybe_coerce_integer(val: str | None) -> int | None: + if val is None: + return None + return coerce_integer(val) + + +def maybe_coerce_float(val: str | None) -> float | None: + if val is None: + return None + return coerce_float(val) + + +def maybe_coerce_boolean(val: str | None) -> bool | None: + if val is None: + return None + return coerce_boolean(val) + + +def removeprefix(string: str, prefix: str) -> str: + """Remove a prefix from a string. + + Backport of `str.removeprefix` for Python < 3.9 + """ + if string.startswith(prefix): + return string[len(prefix) :] + return string + + +def removesuffix(string: str, suffix: str) -> str: + """Remove a suffix from a string. + + Backport of `str.removesuffix` for Python < 3.9 + """ + if string.endswith(suffix): + return string[: -len(suffix)] + return string + + +def file_from_path(path: str) -> FileTypes: + contents = Path(path).read_bytes() + file_name = os.path.basename(path) + return (file_name, contents) + + +def get_required_header(headers: HeadersLike, header: str) -> str: + lower_header = header.lower() + if is_mapping_t(headers): + # mypy doesn't understand the type narrowing here + for k, v in headers.items(): # type: ignore + if k.lower() == lower_header and isinstance(v, str): + return v + + # to deal with the case where the header looks like Stainless-Event-Id + intercaps_header = re.sub(r"([^\w])(\w)", lambda pat: pat.group(1) + pat.group(2).upper(), header.capitalize()) + + for normalized_header in [header, lower_header, header.upper(), intercaps_header]: + value = headers.get(normalized_header) + if value: + return value + + raise ValueError(f"Could not find {header} header") + + +def get_async_library() -> str: + try: + return sniffio.current_async_library() + except Exception: + return "false" + + +def lru_cache(*, maxsize: int | None = 128) -> Callable[[CallableT], CallableT]: + """A version of functools.lru_cache that retains the type signature + for the wrapped function arguments. + """ + wrapper = functools.lru_cache( # noqa: TID251 + maxsize=maxsize, + ) + return cast(Any, wrapper) # type: ignore[no-any-return] + + +def json_safe(data: object) -> object: + """Translates a mapping / sequence recursively in the same fashion + as `pydantic` v2's `model_dump(mode="json")`. + """ + if is_mapping(data): + return {json_safe(key): json_safe(value) for key, value in data.items()} + + if is_iterable(data) and not isinstance(data, (str, bytes, bytearray)): + return [json_safe(item) for item in data] + + if isinstance(data, (datetime, date)): + return data.isoformat() + + return data diff --git a/src/imagekitio/_version.py b/src/imagekitio/_version.py new file mode 100644 index 00000000..e67f4eb2 --- /dev/null +++ b/src/imagekitio/_version.py @@ -0,0 +1,4 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +__title__ = "imagekitio" +__version__ = "0.0.1" diff --git a/src/imagekitio/lib/.keep b/src/imagekitio/lib/.keep new file mode 100644 index 00000000..5e2c99fd --- /dev/null +++ b/src/imagekitio/lib/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store custom files to expand the SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/src/imagekitio/lib/__init__.py b/src/imagekitio/lib/__init__.py new file mode 100644 index 00000000..5ba9d0db --- /dev/null +++ b/src/imagekitio/lib/__init__.py @@ -0,0 +1,11 @@ +# Custom helper functions - not generated from OpenAPI spec + +from .helper import ( + HelperResource, + AsyncHelperResource, +) + +__all__ = [ + "HelperResource", + "AsyncHelperResource", +] diff --git a/src/imagekitio/lib/helper.py b/src/imagekitio/lib/helper.py new file mode 100644 index 00000000..ed57436a --- /dev/null +++ b/src/imagekitio/lib/helper.py @@ -0,0 +1,808 @@ +# File manually created for helper functions - not generated from OpenAPI spec + +from __future__ import annotations + +import re +import hmac +import time +import uuid +import base64 +import hashlib +from typing import Any, Dict, List, Union, Iterable, Optional, Sequence, cast +from urllib.parse import quote, parse_qs, urlparse, urlunparse +from typing_extensions import Unpack + +from .._resource import SyncAPIResource, AsyncAPIResource +from ..types.shared_params.overlay import Overlay +from ..types.shared_params.src_options import SrcOptions +from ..types.shared_params.transformation import Transformation +from ..types.shared_params.text_overlay_transformation import TextOverlayTransformation +from ..types.shared_params.subtitle_overlay_transformation import SubtitleOverlayTransformation +from ..types.shared_params.solid_color_overlay_transformation import SolidColorOverlayTransformation + +# Type alias for any transformation type (main or overlay-specific) +AnyTransformation = Union[ + Transformation, TextOverlayTransformation, SubtitleOverlayTransformation, SolidColorOverlayTransformation +] + +__all__ = ["HelperResource", "AsyncHelperResource"] + +# Constants +TRANSFORMATION_PARAMETER = "tr" +SIGNATURE_PARAMETER = "ik-s" +TIMESTAMP_PARAMETER = "ik-t" +DEFAULT_TIMESTAMP = 9999999999 +SIMPLE_OVERLAY_PATH_REGEX = re.compile(r"^[a-zA-Z0-9-._/ ]*$") +SIMPLE_OVERLAY_TEXT_REGEX = re.compile(r"^[a-zA-Z0-9-._ ]*$") + +# Transformation key mapping +SUPPORTED_TRANSFORMS = { + # Basic sizing & layout + "width": "w", + "height": "h", + "aspect_ratio": "ar", + "background": "bg", + "border": "b", + "crop": "c", + "crop_mode": "cm", + "dpr": "dpr", + "focus": "fo", + "quality": "q", + "x": "x", + "x_center": "xc", + "y": "y", + "y_center": "yc", + "format": "f", + "video_codec": "vc", + "audio_codec": "ac", + "radius": "r", + "rotation": "rt", + "blur": "bl", + "named": "n", + "default_image": "di", + "flip": "fl", + "original": "orig", + "start_offset": "so", + "end_offset": "eo", + "duration": "du", + "streaming_resolutions": "sr", + # AI & advanced effects + "grayscale": "e-grayscale", + "ai_upscale": "e-upscale", + "ai_retouch": "e-retouch", + "ai_variation": "e-genvar", + "ai_drop_shadow": "e-dropshadow", + "ai_change_background": "e-changebg", + "ai_remove_background": "e-bgremove", + "ai_remove_background_external": "e-removedotbg", + "ai_edit": "e-edit", + "contrast_stretch": "e-contrast", + "shadow": "e-shadow", + "sharpen": "e-sharpen", + "unsharp_mask": "e-usm", + "gradient": "e-gradient", + # Other flags & finishing + "progressive": "pr", + "lossless": "lo", + "color_profile": "cp", + "metadata": "md", + "opacity": "o", + "trim": "t", + "zoom": "z", + "page": "pg", + # Text overlay transformations + "font_size": "fs", + "font_family": "ff", + "font_color": "co", + "inner_alignment": "ia", + "padding": "pa", + "alpha": "al", + "typography": "tg", + "line_height": "lh", + # Subtitles transformations + "font_outline": "fol", + "font_shadow": "fsh", + "color": "co", + # Raw pass-through + "raw": "raw", +} + +CHAIN_TRANSFORM_DELIMITER = ":" +TRANSFORM_DELIMITER = "," +TRANSFORM_KEY_VALUE_DELIMITER = "-" + +# RFC 3986 section 3.3 defines 'pchar' (path characters) that are safe to use unencoded: +# pchar = unreserved / pct-encoded / sub-delims / ":" / "@" +# unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" +# sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" +# This matches what Node.js URL.pathname uses and ensures compatibility across SDKs +RFC3986_PATH_SAFE_CHARS = "/:@!$&'()*+,;=-._~" + + +def _get_transform_key(transform: str) -> str: + """Get the short transformation key from the long form.""" + if not transform: + return "" + return SUPPORTED_TRANSFORMS.get(transform, transform) + + +def _add_trailing_slash(s: str) -> str: + """Add trailing slash if not present.""" + if s and not s.endswith("/"): + return s + "/" + return s + + +def _remove_trailing_slash(s: str) -> str: + """Remove trailing slash if present.""" + if s and s.endswith("/"): + return s[:-1] + return s + + +def _remove_leading_slash(s: str) -> str: + """Remove leading slash if present.""" + if s and s.startswith("/"): + return s[1:] + return s + + +def _format_number(value: Any) -> str: + """ + Format a numeric value as a string, removing unnecessary decimal points. + + Examples: + 5.0 -> "5" + 5.5 -> "5.5" + 5 -> "5" + "5" -> "5" + """ + if isinstance(value, (int, float)): + # Check if it's a whole number + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + return str(value) + + +def _path_join(parts: List[str], sep: str = "/") -> str: + """Join path parts, handling slashes correctly.""" + cleaned_parts: List[str] = [] + for part in parts: + if part: + # Remove leading and trailing slashes from parts + cleaned_part = part.strip("/") + if cleaned_part: + cleaned_parts.append(cleaned_part) + return sep + sep.join(cleaned_parts) if cleaned_parts else "" + + +def _safe_btoa(s: str) -> str: + """ + Base64 encode a string and then URL-encode it. + This matches Node.js behavior: safeBtoa() + encodeURIComponent(). + + In Node.js: + - encodeURIComponent() encodes: / as %2F, + as %2B, = as %3D + - Python's quote() with default safe='/' doesn't encode / + - So we need to explicitly set safe='' to encode everything + """ + encoded = base64.b64encode(s.encode("utf-8")).decode("utf-8") + # URL encode the entire base64 string (/, +, =, etc.) + # quote() with safe='' will encode all special characters to match encodeURIComponent + return quote(encoded, safe="") + + +def _process_input_path(s: str, encoding: str) -> str: + """ + Process input path for overlays. + Returns the full parameter string including the i- or ie- prefix. + """ + if not s: + return "" + + # Remove leading and trailing slashes + s = _remove_trailing_slash(_remove_leading_slash(s)) + + if encoding == "plain": + return f"i-{s.replace('/', '@@')}" + + if encoding == "base64": + # safeBtoa already encodes = as %3D, no need for further encoding + return f"ie-{_safe_btoa(s)}" + + # Auto encoding: use plain for simple paths, base64 for special characters + if SIMPLE_OVERLAY_PATH_REGEX.match(s): + return f"i-{s.replace('/', '@@')}" + else: + # safeBtoa already encodes = as %3D, no need for further encoding + return f"ie-{_safe_btoa(s)}" + + +def _process_text(s: str, encoding: str) -> str: + """ + Process text for overlays. + Returns the full parameter string including the i- or ie- prefix. + """ + if not s: + return "" + + if encoding == "plain": + return f"i-{quote(s, safe='')}" + + if encoding == "base64": + # safeBtoa already encodes = as %3D, no need for further encoding + return f"ie-{_safe_btoa(s)}" + + # Auto encoding: use plain for simple text, base64 for special characters + if SIMPLE_OVERLAY_TEXT_REGEX.match(s): + return f"i-{quote(s, safe='')}" + + # safeBtoa already encodes = as %3D, no need for further encoding + return f"ie-{_safe_btoa(s)}" + + +def _process_overlay(overlay: Overlay) -> str: + """Process overlay transformations.""" + if not overlay: + return "" + + # Extract type, position, timing, and transformation from overlay + overlay_type: str = cast(str, overlay.get("type", "")) + position: Dict[str, Any] = cast(Dict[str, Any], overlay.get("position", {})) + timing: Dict[str, Any] = cast(Dict[str, Any], overlay.get("timing", {})) + transformation: List[Any] = cast(List[Any], overlay.get("transformation", [])) + + if not overlay_type: + return "" + + parsed_overlay: List[str] = [] + + if overlay_type == "text": + text: str = cast(str, overlay.get("text", "")) + if not text: + return "" + + encoding: str = cast(str, overlay.get("encoding", "auto")) + parsed_overlay.append("l-text") + + # Process the text - returns full string with i- or ie- prefix + parsed_overlay.append(_process_text(text, encoding)) + + elif overlay_type == "image": + parsed_overlay.append("l-image") + + input_val: str = cast(str, overlay.get("input", "")) + if not input_val: + return "" + + img_encoding = cast(str, overlay.get("encoding", "auto")) + + # Process the input path - returns full string with i- or ie- prefix + parsed_overlay.append(_process_input_path(input_val, img_encoding)) + + elif overlay_type == "video": + parsed_overlay.append("l-video") + + video_input = cast(str, overlay.get("input", "")) + if not video_input: + return "" + + video_encoding = cast(str, overlay.get("encoding", "auto")) + + # Process the input path - returns full string with i- or ie- prefix + parsed_overlay.append(_process_input_path(video_input, video_encoding)) + + elif overlay_type == "subtitle": + parsed_overlay.append("l-subtitle") + + subtitle_input = cast(str, overlay.get("input", "")) + if not subtitle_input: + return "" + + subtitle_encoding = cast(str, overlay.get("encoding", "auto")) + + # Process the input path - returns full string with i- or ie- prefix + parsed_overlay.append(_process_input_path(subtitle_input, subtitle_encoding)) + + elif overlay_type == "solidColor": + parsed_overlay.append("l-image") + parsed_overlay.append("i-ik_canvas") + + color: str = cast(str, overlay.get("color", "")) + if not color: + return "" + + parsed_overlay.append(f"bg-{color}") + + # Handle position properties (x, y, focus) + # Node.js uses if (x) which skips falsy values like 0, '', false, null, undefined + x = position.get("x") + if x: + parsed_overlay.append(f"lx-{x}") + + y = position.get("y") + if y: + parsed_overlay.append(f"ly-{y}") + + focus = position.get("focus") + if focus: + parsed_overlay.append(f"lfo-{focus}") + + # Handle timing properties (start, end, duration) + # Node.js uses if (start) which skips falsy values + start = timing.get("start") + if start: + parsed_overlay.append(f"lso-{_format_number(start)}") + + end = timing.get("end") + if end: + parsed_overlay.append(f"leo-{_format_number(end)}") + + duration = timing.get("duration") + if duration: + parsed_overlay.append(f"ldu-{duration}") + + # Handle nested transformations for image/video overlays + if transformation: + transformation_string: str = _build_transformation_string(transformation) + if transformation_string and transformation_string.strip(): + parsed_overlay.append(transformation_string) + + # Close overlay + parsed_overlay.append("l-end") + + return TRANSFORM_DELIMITER.join(parsed_overlay) + + +def _build_transformation_string(transformation: Optional[Sequence[AnyTransformation]]) -> str: + """Build transformation string from transformation objects.""" + if not transformation: + return "" + + parsed_transforms: List[str] = [] + + for current_transform in transformation: + if not current_transform: + continue + + parsed_transform_step: List[str] = [] + + for key, value in current_transform.items(): + if value is None: + continue + + # Handle overlay separately + if key == "overlay" and isinstance(value, dict): + raw_string: str = _process_overlay(cast(Overlay, value)) + if raw_string and raw_string.strip(): + parsed_transform_step.append(raw_string) + continue + + # Get the transformation key + transform_key: str = _get_transform_key(key) + if not transform_key: + transform_key = key + + if not transform_key: + continue + + # Handle boolean transformations that should only output key + if transform_key in [ + "e-grayscale", + "e-contrast", + "e-removedotbg", + "e-bgremove", + "e-upscale", + "e-retouch", + "e-genvar", + ]: + if value is True or value == "-" or value == "true": + parsed_transform_step.append(transform_key) + # Any other value means that the effect should not be applied + continue + + # Handle transformations that can be true or have values + if transform_key in ["e-sharpen", "e-shadow", "e-gradient", "e-usm", "e-dropshadow"] and ( + str(value).strip() == "" or value is True or value == "true" + ): + parsed_transform_step.append(transform_key) + continue + + # Handle raw transformation + if key == "raw": + if isinstance(value, str) and value.strip(): + parsed_transform_step.append(value) + continue + + # Handle default_image and font_family - replace slashes + if transform_key in ["di", "ff"]: + value = _remove_trailing_slash(_remove_leading_slash(str(value) if value else "")) + value = value.replace("/", "@@") + + # Handle streaming_resolutions array + if transform_key == "sr" and isinstance(value, list): + value = "_".join(str(v) for v in cast(List[Any], value)) + + # Special case for trim with empty string + if transform_key == "t" and str(value).strip() == "": + value = "true" + + # Skip false values + if value is False: + continue + + # Skip empty strings (except for special keys that allow empty values) + if isinstance(value, str) and value.strip() == "": + continue + + # Convert boolean True to lowercase "true" + if value is True: + value = "true" + + # Format numeric values to avoid unnecessary .0 for integers + if isinstance(value, (int, float)): + value = _format_number(value) + + # Add the transformation + parsed_transform_step.append(f"{transform_key}{TRANSFORM_KEY_VALUE_DELIMITER}{value}") + + if parsed_transform_step: + parsed_transforms.append(TRANSFORM_DELIMITER.join(parsed_transform_step)) + + return CHAIN_TRANSFORM_DELIMITER.join(parsed_transforms) + + +def _get_signature_timestamp(seconds: Optional[float]) -> int: + """Calculate expiry timestamp for URL signing.""" + if not seconds or seconds <= 0: + return DEFAULT_TIMESTAMP + + # Try to parse as int, return DEFAULT_TIMESTAMP if invalid + try: + sec = int(seconds) + if sec <= 0: + return DEFAULT_TIMESTAMP + except (ValueError, TypeError): + return DEFAULT_TIMESTAMP + + return int(time.time()) + sec + + +def _get_signature(private_key: str, url: str, url_endpoint: str, expiry_timestamp: int) -> str: + """Generate HMAC-SHA1 signature for URL signing.""" + if not private_key or not url or not url_endpoint: + return "" + + # Create the string to sign: relative path + expiry timestamp + # This matches Node.js: url.replace(addTrailingSlash(urlEndpoint), '') + String(expiryTimestamp) + url_endpoint_with_slash = _add_trailing_slash(url_endpoint) + string_to_sign = url.replace(url_endpoint_with_slash, "") + str(expiry_timestamp) + + # Generate HMAC-SHA1 signature + signature = hmac.new(private_key.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha1).hexdigest() + + return signature + + +def _get_authentication_parameters(token: str, expire: int, private_key: str) -> Dict[str, Any]: + """Generate authentication parameters for uploads.""" + auth_parameters = { + "token": token, + "expire": expire, + "signature": "", + } + + signature = hmac.new(private_key.encode("utf-8"), f"{token}{expire}".encode("utf-8"), hashlib.sha1).hexdigest() + + auth_parameters["signature"] = signature + return auth_parameters + + +def _build_url( + src: str, + url_endpoint: str, + transformation_position: str, + transformation: Any, + query_parameters: Dict[str, Any], + signed: bool, + expires_in: Optional[float], + private_key: str, +) -> str: + """ + Internal implementation of build_url. + + Args: + src: Accepts a relative or absolute path of the resource. + url_endpoint: Get your urlEndpoint from the ImageKit dashboard. + transformation_position: By default, the transformation string is added as a query parameter. + transformation: An array of objects specifying the transformations to be applied in the URL. + query_parameters: Additional query parameters to add to the final URL. + signed: Whether to sign the URL or not. + expires_in: When you want the signed URL to expire, specified in seconds. + private_key: Private key for signing URLs. + + Returns: + The constructed source URL. + """ + if not src: + return "" + + # Check if src is absolute URL + is_absolute_url = src.startswith("http://") or src.startswith("https://") + + # Track if src parameter is used for URL (matches Node.js isSrcParameterUsedForURL) + is_src_parameter_used_for_url = False + + # Parse URL + try: + if not is_absolute_url: + parsed_url = urlparse(url_endpoint) + else: + parsed_url = urlparse(src) + is_src_parameter_used_for_url = True + except Exception: + return "" + + # Build query parameters + query_dict_raw = dict(parse_qs(parsed_url.query)) + # Flatten lists from parse_qs + query_dict: Dict[str, str] = {k: v[0] if len(v) == 1 else ",".join(v) for k, v in query_dict_raw.items()} + + # Add additional query parameters - convert values to strings like Node.js does + if query_parameters: + for k, v in query_parameters.items(): + query_dict[k] = str(v) + + # Build transformation string + transformation_string = _build_transformation_string(transformation) + + # Determine if transformation should be in query or path + # Matches Node.js: addAsQuery = transformationUtils.addAsQueryParameter(opts) || isSrcParameterUsedForURL + add_as_query = transformation_position == "query" or is_src_parameter_used_for_url + + # Placeholder for transformation to avoid URL encoding issues + TRANSFORMATION_PLACEHOLDER = "PLEASEREPLACEJUSTBEFORESIGN" + + # Build the path + if not is_absolute_url: + # For relative URLs + endpoint_path = urlparse(url_endpoint).path + path_parts = [endpoint_path] if endpoint_path else [] + + # Add transformation in path if needed + if transformation_string and not add_as_query: + path_parts.append(f"{TRANSFORMATION_PARAMETER}{CHAIN_TRANSFORM_DELIMITER}{TRANSFORMATION_PLACEHOLDER}") + + # Add src path with RFC 3986 compliant encoding + # Python's urlunparse() doesn't auto-encode Unicode like Node.js URL does, + # so we must manually encode the path while preserving RFC 3986 safe chars + encoded_src = quote(src, safe=RFC3986_PATH_SAFE_CHARS) + path_parts.append(encoded_src) + + path = _path_join(path_parts) + else: + path = parsed_url.path + + # Add transformation to query if needed + if transformation_string and add_as_query: + query_dict[TRANSFORMATION_PARAMETER] = TRANSFORMATION_PLACEHOLDER + + # Build the URL + scheme = parsed_url.scheme or "https" + netloc = parsed_url.netloc if is_absolute_url else urlparse(url_endpoint).netloc + + # Build query string manually to avoid encoding transformation string + query_string = "" + if query_dict: + query_parts: List[str] = [] + for k, v in query_dict.items(): + query_parts.append(f"{k}={v}") + query_string = "&".join(query_parts) + + final_url = urlunparse((scheme, netloc, path, "", query_string, "")) + + # Replace placeholder with actual transformation string + if transformation_string: + final_url = final_url.replace(TRANSFORMATION_PLACEHOLDER, transformation_string) + + # Sign URL if needed + if signed or (expires_in and expires_in > 0): + expiry_timestamp = _get_signature_timestamp(expires_in) + + url_signature = _get_signature( + private_key=private_key, url=final_url, url_endpoint=url_endpoint, expiry_timestamp=expiry_timestamp + ) + + # Add signature parameters + parsed_final = urlparse(final_url) + has_existing_params = bool(parsed_final.query) + separator = "&" if has_existing_params else "?" + + if expiry_timestamp and expiry_timestamp != DEFAULT_TIMESTAMP: + final_url += f"{separator}{TIMESTAMP_PARAMETER}={expiry_timestamp}" + final_url += f"&{SIGNATURE_PARAMETER}={url_signature}" + else: + final_url += f"{separator}{SIGNATURE_PARAMETER}={url_signature}" + + return final_url + + +def _get_authentication_parameters_with_defaults( + token: Optional[str], expire: Optional[int], private_key: str +) -> Dict[str, Any]: + """ + Internal implementation of get_authentication_parameters with default value handling. + + Args: + token: Custom token for the upload session. If not provided, a UUID v4 will be generated automatically. + expire: Expiration time in seconds from now. If not provided, defaults to 1800 seconds (30 minutes). + private_key: Private key for generating authentication parameters. + + Returns: + Authentication parameters object containing token, expire, and signature. + """ + if not private_key: + raise ValueError("Private key is required for generating authentication parameters") + + # Generate token if not provided + if not token: + token = str(uuid.uuid4()) + + # Set default expiry if not provided + if expire is None: + expire = int(time.time()) + 1800 # 30 minutes default + + return _get_authentication_parameters(token, expire, private_key) + + +class HelperResource(SyncAPIResource): + """ + Helper resource for additional utility functions like URL building and authentication. + """ + + def build_url(self, **options: Unpack[SrcOptions]) -> str: + """ + Builds a source URL with the given options. + + Args: + src: Accepts a relative or absolute path of the resource. If a relative path is provided, + it is appended to the `url_endpoint`. If an absolute path is provided, `url_endpoint` is ignored. + url_endpoint: Get your urlEndpoint from the ImageKit dashboard. + transformation: An array of objects specifying the transformations to be applied in the URL. + transformation_position: By default, the transformation string is added as a query parameter. + Set to `path` to add it in the URL path instead. + signed: Whether to sign the URL or not. Set to `true` to generate a signed URL. + expires_in: When you want the signed URL to expire, specified in seconds. + query_parameters: Additional query parameters to add to the final URL. + + Returns: + The constructed source URL. + """ + return _build_url( + src=options.get("src", ""), + url_endpoint=options.get("url_endpoint", ""), + transformation_position=options.get("transformation_position", "query"), + transformation=options.get("transformation"), + query_parameters=options.get("query_parameters", {}), + signed=options.get("signed", False), + expires_in=options.get("expires_in"), + private_key=self._client.private_key, + ) + + def get_authentication_parameters( + self, + token: Optional[str] = None, + expire: Optional[int] = None, + ) -> Dict[str, Any]: + """ + Generates authentication parameters for client-side file uploads using ImageKit's Upload API. + + Args: + token: Custom token for the upload session. If not provided, a UUID v4 will be generated automatically. + expire: Expiration time in seconds from now. If not provided, defaults to 1800 seconds (30 minutes). + + Returns: + Authentication parameters object containing: + - token: Unique identifier for this upload session + - expire: Unix timestamp when these parameters expire + - signature: HMAC-SHA1 signature for authenticating the upload + """ + return _get_authentication_parameters_with_defaults( + token=token, expire=expire, private_key=self._client.private_key + ) + + def build_transformation_string(self, transformation: Optional[Iterable[Transformation]] = None) -> str: + """ + Builds a transformation string from an array of transformation objects. + + Args: + transformation: List of transformation dictionaries. + + Returns: + The transformation string in ImageKit format. + """ + if transformation is None: + return "" + + # Convert to list if it's an iterable + if not isinstance(transformation, list): + transformation = list(transformation) + + return _build_transformation_string(transformation) + + +class AsyncHelperResource(AsyncAPIResource): + """ + Async version of helper resource for additional utility functions. + """ + + async def build_url(self, **options: Unpack[SrcOptions]) -> str: + """ + Async version of build_url. + + Args: + src: Accepts a relative or absolute path of the resource. If a relative path is provided, + it is appended to the `url_endpoint`. If an absolute path is provided, `url_endpoint` is ignored. + url_endpoint: Get your urlEndpoint from the ImageKit dashboard. + transformation: An array of objects specifying the transformations to be applied in the URL. + transformation_position: By default, the transformation string is added as a query parameter. + Set to `path` to add it in the URL path instead. + signed: Whether to sign the URL or not. Set to `true` to generate a signed URL. + expires_in: When you want the signed URL to expire, specified in seconds. + query_parameters: Additional query parameters to add to the final URL. + + Returns: + The constructed source URL. + """ + return _build_url( + src=options.get("src", ""), + url_endpoint=options.get("url_endpoint", ""), + transformation_position=options.get("transformation_position", "query"), + transformation=options.get("transformation"), + query_parameters=options.get("query_parameters", {}), + signed=options.get("signed", False), + expires_in=options.get("expires_in"), + private_key=self._client.private_key, + ) + + async def get_authentication_parameters( + self, + token: Optional[str] = None, + expire: Optional[int] = None, + ) -> Dict[str, Any]: + """ + Async version of get_authentication_parameters. + + Args: + token: Custom token for the upload session. If not provided, a UUID v4 will be generated automatically. + expire: Expiration time in seconds from now. If not provided, defaults to 1800 seconds (30 minutes). + + Returns: + Authentication parameters object containing: + - token: Unique identifier for this upload session + - expire: Unix timestamp when these parameters expire + - signature: HMAC-SHA1 signature for authenticating the upload + """ + return _get_authentication_parameters_with_defaults( + token=token, expire=expire, private_key=self._client.private_key + ) + + async def build_transformation_string(self, transformation: Optional[Iterable[Transformation]] = None) -> str: + """ + Async version of build_transformation_string. + + Args: + transformation: List of transformation dictionaries. + + Returns: + The transformation string in ImageKit format. + """ + if transformation is None: + return "" + + # Convert to list if it's an iterable + if not isinstance(transformation, list): + transformation = list(transformation) + + return _build_transformation_string(transformation) diff --git a/src/imagekitio/lib/serialization_utils.py b/src/imagekitio/lib/serialization_utils.py new file mode 100644 index 00000000..1d672f16 --- /dev/null +++ b/src/imagekitio/lib/serialization_utils.py @@ -0,0 +1,47 @@ +# Serialization utilities for upload options +# This file handles serialization of upload parameters before sending to ImageKit API + +import json +from typing import Any, Dict, Sequence, cast + + +def serialize_upload_options(upload_options: Dict[str, Any]) -> Dict[str, Any]: + """ + Serialize upload options to handle proper formatting for ImageKit backend API. + + Special cases handled: + - tags: converted to comma-separated string + - response_fields: converted to comma-separated string + - extensions: JSON stringified + - custom_metadata: JSON stringified + - transformation: JSON stringified + + Args: + upload_options: Dictionary containing upload parameters + + Returns: + Dictionary with serialized values + """ + serialized: Dict[str, Any] = {**upload_options} + + for key in list(serialized.keys()): + if key and serialized[key] is not None: + value = serialized[key] + + if key == "tags" and isinstance(value, (list, tuple)): + # Tags should be comma-separated string + serialized[key] = ",".join(cast(Sequence[str], value)) + elif key == "response_fields" and isinstance(value, (list, tuple)): + # Response fields should be comma-separated string + serialized[key] = ",".join(cast(Sequence[str], value)) + elif key == "extensions" and isinstance(value, list): + # Extensions should be JSON stringified + serialized[key] = json.dumps(value) + elif key == "custom_metadata" and isinstance(value, dict) and not isinstance(value, (list, tuple)): + # Custom metadata should be JSON stringified + serialized[key] = json.dumps(value) + elif key == "transformation" and isinstance(value, dict): + # Transformation should be JSON stringified + serialized[key] = json.dumps(value) + + return serialized diff --git a/imagekitio/constants/__init__.py b/src/imagekitio/py.typed similarity index 100% rename from imagekitio/constants/__init__.py rename to src/imagekitio/py.typed diff --git a/src/imagekitio/resources/__init__.py b/src/imagekitio/resources/__init__.py new file mode 100644 index 00000000..81ba578e --- /dev/null +++ b/src/imagekitio/resources/__init__.py @@ -0,0 +1,126 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .beta import ( + BetaResource, + AsyncBetaResource, + BetaResourceWithRawResponse, + AsyncBetaResourceWithRawResponse, + BetaResourceWithStreamingResponse, + AsyncBetaResourceWithStreamingResponse, +) +from .cache import ( + CacheResource, + AsyncCacheResource, + CacheResourceWithRawResponse, + AsyncCacheResourceWithRawResponse, + CacheResourceWithStreamingResponse, + AsyncCacheResourceWithStreamingResponse, +) +from .dummy import ( + DummyResource, + AsyncDummyResource, + DummyResourceWithRawResponse, + AsyncDummyResourceWithRawResponse, + DummyResourceWithStreamingResponse, + AsyncDummyResourceWithStreamingResponse, +) +from .files import ( + FilesResource, + AsyncFilesResource, + FilesResourceWithRawResponse, + AsyncFilesResourceWithRawResponse, + FilesResourceWithStreamingResponse, + AsyncFilesResourceWithStreamingResponse, +) +from .assets import ( + AssetsResource, + AsyncAssetsResource, + AssetsResourceWithRawResponse, + AsyncAssetsResourceWithRawResponse, + AssetsResourceWithStreamingResponse, + AsyncAssetsResourceWithStreamingResponse, +) +from .folders import ( + FoldersResource, + AsyncFoldersResource, + FoldersResourceWithRawResponse, + AsyncFoldersResourceWithRawResponse, + FoldersResourceWithStreamingResponse, + AsyncFoldersResourceWithStreamingResponse, +) +from .accounts import ( + AccountsResource, + AsyncAccountsResource, + AccountsResourceWithRawResponse, + AsyncAccountsResourceWithRawResponse, + AccountsResourceWithStreamingResponse, + AsyncAccountsResourceWithStreamingResponse, +) +from .webhooks import WebhooksResource, AsyncWebhooksResource +from ..lib.helper import ( + HelperResource, + AsyncHelperResource, +) +from .custom_metadata_fields import ( + CustomMetadataFieldsResource, + AsyncCustomMetadataFieldsResource, + CustomMetadataFieldsResourceWithRawResponse, + AsyncCustomMetadataFieldsResourceWithRawResponse, + CustomMetadataFieldsResourceWithStreamingResponse, + AsyncCustomMetadataFieldsResourceWithStreamingResponse, +) + +__all__ = [ + "DummyResource", + "AsyncDummyResource", + "DummyResourceWithRawResponse", + "AsyncDummyResourceWithRawResponse", + "DummyResourceWithStreamingResponse", + "AsyncDummyResourceWithStreamingResponse", + "CustomMetadataFieldsResource", + "AsyncCustomMetadataFieldsResource", + "CustomMetadataFieldsResourceWithRawResponse", + "AsyncCustomMetadataFieldsResourceWithRawResponse", + "CustomMetadataFieldsResourceWithStreamingResponse", + "AsyncCustomMetadataFieldsResourceWithStreamingResponse", + "FilesResource", + "AsyncFilesResource", + "FilesResourceWithRawResponse", + "AsyncFilesResourceWithRawResponse", + "FilesResourceWithStreamingResponse", + "AsyncFilesResourceWithStreamingResponse", + "AssetsResource", + "AsyncAssetsResource", + "AssetsResourceWithRawResponse", + "AsyncAssetsResourceWithRawResponse", + "AssetsResourceWithStreamingResponse", + "AsyncAssetsResourceWithStreamingResponse", + "CacheResource", + "AsyncCacheResource", + "CacheResourceWithRawResponse", + "AsyncCacheResourceWithRawResponse", + "CacheResourceWithStreamingResponse", + "AsyncCacheResourceWithStreamingResponse", + "FoldersResource", + "AsyncFoldersResource", + "FoldersResourceWithRawResponse", + "AsyncFoldersResourceWithRawResponse", + "FoldersResourceWithStreamingResponse", + "AsyncFoldersResourceWithStreamingResponse", + "AccountsResource", + "AsyncAccountsResource", + "AccountsResourceWithRawResponse", + "AsyncAccountsResourceWithRawResponse", + "AccountsResourceWithStreamingResponse", + "AsyncAccountsResourceWithStreamingResponse", + "BetaResource", + "AsyncBetaResource", + "BetaResourceWithRawResponse", + "AsyncBetaResourceWithRawResponse", + "BetaResourceWithStreamingResponse", + "AsyncBetaResourceWithStreamingResponse", + "WebhooksResource", + "AsyncWebhooksResource", + "HelperResource", + "AsyncHelperResource", +] diff --git a/src/imagekitio/resources/accounts/__init__.py b/src/imagekitio/resources/accounts/__init__.py new file mode 100644 index 00000000..fc56413d --- /dev/null +++ b/src/imagekitio/resources/accounts/__init__.py @@ -0,0 +1,61 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .usage import ( + UsageResource, + AsyncUsageResource, + UsageResourceWithRawResponse, + AsyncUsageResourceWithRawResponse, + UsageResourceWithStreamingResponse, + AsyncUsageResourceWithStreamingResponse, +) +from .origins import ( + OriginsResource, + AsyncOriginsResource, + OriginsResourceWithRawResponse, + AsyncOriginsResourceWithRawResponse, + OriginsResourceWithStreamingResponse, + AsyncOriginsResourceWithStreamingResponse, +) +from .accounts import ( + AccountsResource, + AsyncAccountsResource, + AccountsResourceWithRawResponse, + AsyncAccountsResourceWithRawResponse, + AccountsResourceWithStreamingResponse, + AsyncAccountsResourceWithStreamingResponse, +) +from .url_endpoints import ( + URLEndpointsResource, + AsyncURLEndpointsResource, + URLEndpointsResourceWithRawResponse, + AsyncURLEndpointsResourceWithRawResponse, + URLEndpointsResourceWithStreamingResponse, + AsyncURLEndpointsResourceWithStreamingResponse, +) + +__all__ = [ + "UsageResource", + "AsyncUsageResource", + "UsageResourceWithRawResponse", + "AsyncUsageResourceWithRawResponse", + "UsageResourceWithStreamingResponse", + "AsyncUsageResourceWithStreamingResponse", + "OriginsResource", + "AsyncOriginsResource", + "OriginsResourceWithRawResponse", + "AsyncOriginsResourceWithRawResponse", + "OriginsResourceWithStreamingResponse", + "AsyncOriginsResourceWithStreamingResponse", + "URLEndpointsResource", + "AsyncURLEndpointsResource", + "URLEndpointsResourceWithRawResponse", + "AsyncURLEndpointsResourceWithRawResponse", + "URLEndpointsResourceWithStreamingResponse", + "AsyncURLEndpointsResourceWithStreamingResponse", + "AccountsResource", + "AsyncAccountsResource", + "AccountsResourceWithRawResponse", + "AsyncAccountsResourceWithRawResponse", + "AccountsResourceWithStreamingResponse", + "AsyncAccountsResourceWithStreamingResponse", +] diff --git a/src/imagekitio/resources/accounts/accounts.py b/src/imagekitio/resources/accounts/accounts.py new file mode 100644 index 00000000..dba376c5 --- /dev/null +++ b/src/imagekitio/resources/accounts/accounts.py @@ -0,0 +1,166 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .usage import ( + UsageResource, + AsyncUsageResource, + UsageResourceWithRawResponse, + AsyncUsageResourceWithRawResponse, + UsageResourceWithStreamingResponse, + AsyncUsageResourceWithStreamingResponse, +) +from .origins import ( + OriginsResource, + AsyncOriginsResource, + OriginsResourceWithRawResponse, + AsyncOriginsResourceWithRawResponse, + OriginsResourceWithStreamingResponse, + AsyncOriginsResourceWithStreamingResponse, +) +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from .url_endpoints import ( + URLEndpointsResource, + AsyncURLEndpointsResource, + URLEndpointsResourceWithRawResponse, + AsyncURLEndpointsResourceWithRawResponse, + URLEndpointsResourceWithStreamingResponse, + AsyncURLEndpointsResourceWithStreamingResponse, +) + +__all__ = ["AccountsResource", "AsyncAccountsResource"] + + +class AccountsResource(SyncAPIResource): + @cached_property + def usage(self) -> UsageResource: + return UsageResource(self._client) + + @cached_property + def origins(self) -> OriginsResource: + return OriginsResource(self._client) + + @cached_property + def url_endpoints(self) -> URLEndpointsResource: + return URLEndpointsResource(self._client) + + @cached_property + def with_raw_response(self) -> AccountsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AccountsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AccountsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AccountsResourceWithStreamingResponse(self) + + +class AsyncAccountsResource(AsyncAPIResource): + @cached_property + def usage(self) -> AsyncUsageResource: + return AsyncUsageResource(self._client) + + @cached_property + def origins(self) -> AsyncOriginsResource: + return AsyncOriginsResource(self._client) + + @cached_property + def url_endpoints(self) -> AsyncURLEndpointsResource: + return AsyncURLEndpointsResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncAccountsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncAccountsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAccountsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncAccountsResourceWithStreamingResponse(self) + + +class AccountsResourceWithRawResponse: + def __init__(self, accounts: AccountsResource) -> None: + self._accounts = accounts + + @cached_property + def usage(self) -> UsageResourceWithRawResponse: + return UsageResourceWithRawResponse(self._accounts.usage) + + @cached_property + def origins(self) -> OriginsResourceWithRawResponse: + return OriginsResourceWithRawResponse(self._accounts.origins) + + @cached_property + def url_endpoints(self) -> URLEndpointsResourceWithRawResponse: + return URLEndpointsResourceWithRawResponse(self._accounts.url_endpoints) + + +class AsyncAccountsResourceWithRawResponse: + def __init__(self, accounts: AsyncAccountsResource) -> None: + self._accounts = accounts + + @cached_property + def usage(self) -> AsyncUsageResourceWithRawResponse: + return AsyncUsageResourceWithRawResponse(self._accounts.usage) + + @cached_property + def origins(self) -> AsyncOriginsResourceWithRawResponse: + return AsyncOriginsResourceWithRawResponse(self._accounts.origins) + + @cached_property + def url_endpoints(self) -> AsyncURLEndpointsResourceWithRawResponse: + return AsyncURLEndpointsResourceWithRawResponse(self._accounts.url_endpoints) + + +class AccountsResourceWithStreamingResponse: + def __init__(self, accounts: AccountsResource) -> None: + self._accounts = accounts + + @cached_property + def usage(self) -> UsageResourceWithStreamingResponse: + return UsageResourceWithStreamingResponse(self._accounts.usage) + + @cached_property + def origins(self) -> OriginsResourceWithStreamingResponse: + return OriginsResourceWithStreamingResponse(self._accounts.origins) + + @cached_property + def url_endpoints(self) -> URLEndpointsResourceWithStreamingResponse: + return URLEndpointsResourceWithStreamingResponse(self._accounts.url_endpoints) + + +class AsyncAccountsResourceWithStreamingResponse: + def __init__(self, accounts: AsyncAccountsResource) -> None: + self._accounts = accounts + + @cached_property + def usage(self) -> AsyncUsageResourceWithStreamingResponse: + return AsyncUsageResourceWithStreamingResponse(self._accounts.usage) + + @cached_property + def origins(self) -> AsyncOriginsResourceWithStreamingResponse: + return AsyncOriginsResourceWithStreamingResponse(self._accounts.origins) + + @cached_property + def url_endpoints(self) -> AsyncURLEndpointsResourceWithStreamingResponse: + return AsyncURLEndpointsResourceWithStreamingResponse(self._accounts.url_endpoints) diff --git a/src/imagekitio/resources/accounts/origins.py b/src/imagekitio/resources/accounts/origins.py new file mode 100644 index 00000000..15354aad --- /dev/null +++ b/src/imagekitio/resources/accounts/origins.py @@ -0,0 +1,2233 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Any, cast +from typing_extensions import Literal, overload + +import httpx + +from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from ..._utils import required_args, maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.accounts import origin_create_params, origin_update_params +from ...types.accounts.origin_response import OriginResponse +from ...types.accounts.origin_list_response import OriginListResponse + +__all__ = ["OriginsResource", "AsyncOriginsResource"] + + +class OriginsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> OriginsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return OriginsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> OriginsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return OriginsResourceWithStreamingResponse(self) + + @overload + def create( + self, + *, + access_key: str, + bucket: str, + name: str, + secret_key: str, + type: Literal["S3"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def create( + self, + *, + access_key: str, + bucket: str, + endpoint: str, + name: str, + secret_key: str, + type: Literal["S3_COMPATIBLE"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + s3_force_path_style: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + endpoint: Custom S3-compatible endpoint. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + s3_force_path_style: Use path-style S3 URLs? + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def create( + self, + *, + access_key: str, + bucket: str, + name: str, + secret_key: str, + type: Literal["CLOUDINARY_BACKUP"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def create( + self, + *, + base_url: str, + name: str, + type: Literal["WEB_FOLDER"], + base_url_for_canonical_header: str | Omit = omit, + forward_host_header_to_origin: bool | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + base_url: Root URL for the web folder origin. + + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + forward_host_header_to_origin: Forward the Host header to origin? + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def create( + self, + *, + name: str, + type: Literal["WEB_PROXY"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def create( + self, + *, + bucket: str, + client_email: str, + name: str, + private_key: str, + type: Literal["GCS"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def create( + self, + *, + account_name: str, + container: str, + name: str, + sas_token: str, + type: Literal["AZURE_BLOB"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def create( + self, + *, + base_url: str, + client_id: str, + client_secret: str, + name: str, + password: str, + type: Literal["AKENEO_PIM"], + username: str, + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + base_url: Akeneo instance base URL. + + client_id: Akeneo API client ID. + + client_secret: Akeneo API client secret. + + name: Display name of the origin. + + password: Akeneo API password. + + username: Akeneo API username. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @required_args( + ["access_key", "bucket", "name", "secret_key", "type"], + ["access_key", "bucket", "endpoint", "name", "secret_key", "type"], + ["base_url", "name", "type"], + ["name", "type"], + ["bucket", "client_email", "name", "private_key", "type"], + ["account_name", "container", "name", "sas_token", "type"], + ["base_url", "client_id", "client_secret", "name", "password", "type", "username"], + ) + def create( + self, + *, + access_key: str | Omit = omit, + bucket: str | Omit = omit, + name: str, + secret_key: str | Omit = omit, + type: Literal["S3"] + | Literal["S3_COMPATIBLE"] + | Literal["CLOUDINARY_BACKUP"] + | Literal["WEB_FOLDER"] + | Literal["WEB_PROXY"] + | Literal["GCS"] + | Literal["AZURE_BLOB"] + | Literal["AKENEO_PIM"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + endpoint: str | Omit = omit, + s3_force_path_style: bool | Omit = omit, + base_url: str | Omit = omit, + forward_host_header_to_origin: bool | Omit = omit, + client_email: str | Omit = omit, + private_key: str | Omit = omit, + account_name: str | Omit = omit, + container: str | Omit = omit, + sas_token: str | Omit = omit, + client_id: str | Omit = omit, + client_secret: str | Omit = omit, + password: str | Omit = omit, + username: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + return cast( + OriginResponse, + self._post( + "/v1/accounts/origins", + body=maybe_transform( + { + "access_key": access_key, + "bucket": bucket, + "name": name, + "secret_key": secret_key, + "type": type, + "base_url_for_canonical_header": base_url_for_canonical_header, + "include_canonical_header": include_canonical_header, + "prefix": prefix, + "endpoint": endpoint, + "s3_force_path_style": s3_force_path_style, + "base_url": base_url, + "forward_host_header_to_origin": forward_host_header_to_origin, + "client_email": client_email, + "private_key": private_key, + "account_name": account_name, + "container": container, + "sas_token": sas_token, + "client_id": client_id, + "client_secret": client_secret, + "password": password, + "username": username, + }, + origin_create_params.OriginCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=cast(Any, OriginResponse), # Union types cannot be passed in as arguments in the type system + ), + ) + + @overload + def update( + self, + id: str, + *, + access_key: str, + bucket: str, + name: str, + secret_key: str, + type: Literal["S3"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def update( + self, + id: str, + *, + access_key: str, + bucket: str, + endpoint: str, + name: str, + secret_key: str, + type: Literal["S3_COMPATIBLE"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + s3_force_path_style: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + endpoint: Custom S3-compatible endpoint. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + s3_force_path_style: Use path-style S3 URLs? + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def update( + self, + id: str, + *, + access_key: str, + bucket: str, + name: str, + secret_key: str, + type: Literal["CLOUDINARY_BACKUP"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def update( + self, + id: str, + *, + base_url: str, + name: str, + type: Literal["WEB_FOLDER"], + base_url_for_canonical_header: str | Omit = omit, + forward_host_header_to_origin: bool | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + base_url: Root URL for the web folder origin. + + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + forward_host_header_to_origin: Forward the Host header to origin? + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def update( + self, + id: str, + *, + name: str, + type: Literal["WEB_PROXY"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def update( + self, + id: str, + *, + bucket: str, + client_email: str, + name: str, + private_key: str, + type: Literal["GCS"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def update( + self, + id: str, + *, + account_name: str, + container: str, + name: str, + sas_token: str, + type: Literal["AZURE_BLOB"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def update( + self, + id: str, + *, + base_url: str, + client_id: str, + client_secret: str, + name: str, + password: str, + type: Literal["AKENEO_PIM"], + username: str, + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + base_url: Akeneo instance base URL. + + client_id: Akeneo API client ID. + + client_secret: Akeneo API client secret. + + name: Display name of the origin. + + password: Akeneo API password. + + username: Akeneo API username. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @required_args( + ["access_key", "bucket", "name", "secret_key", "type"], + ["access_key", "bucket", "endpoint", "name", "secret_key", "type"], + ["base_url", "name", "type"], + ["name", "type"], + ["bucket", "client_email", "name", "private_key", "type"], + ["account_name", "container", "name", "sas_token", "type"], + ["base_url", "client_id", "client_secret", "name", "password", "type", "username"], + ) + def update( + self, + id: str, + *, + access_key: str | Omit = omit, + bucket: str | Omit = omit, + name: str, + secret_key: str | Omit = omit, + type: Literal["S3"] + | Literal["S3_COMPATIBLE"] + | Literal["CLOUDINARY_BACKUP"] + | Literal["WEB_FOLDER"] + | Literal["WEB_PROXY"] + | Literal["GCS"] + | Literal["AZURE_BLOB"] + | Literal["AKENEO_PIM"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + endpoint: str | Omit = omit, + s3_force_path_style: bool | Omit = omit, + base_url: str | Omit = omit, + forward_host_header_to_origin: bool | Omit = omit, + client_email: str | Omit = omit, + private_key: str | Omit = omit, + account_name: str | Omit = omit, + container: str | Omit = omit, + sas_token: str | Omit = omit, + client_id: str | Omit = omit, + client_secret: str | Omit = omit, + password: str | Omit = omit, + username: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return cast( + OriginResponse, + self._put( + f"/v1/accounts/origins/{id}", + body=maybe_transform( + { + "access_key": access_key, + "bucket": bucket, + "name": name, + "secret_key": secret_key, + "type": type, + "base_url_for_canonical_header": base_url_for_canonical_header, + "include_canonical_header": include_canonical_header, + "prefix": prefix, + "endpoint": endpoint, + "s3_force_path_style": s3_force_path_style, + "base_url": base_url, + "forward_host_header_to_origin": forward_host_header_to_origin, + "client_email": client_email, + "private_key": private_key, + "account_name": account_name, + "container": container, + "sas_token": sas_token, + "client_id": client_id, + "client_secret": client_secret, + "password": password, + "username": username, + }, + origin_update_params.OriginUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=cast(Any, OriginResponse), # Union types cannot be passed in as arguments in the type system + ), + ) + + def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginListResponse: + """**Note:** This API is currently in beta. + + + Returns an array of all configured origins for the current account. + """ + return self._get( + "/v1/accounts/origins", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=OriginListResponse, + ) + + def delete( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """**Note:** This API is currently in beta. + + + Permanently removes the origin identified by `id`. If the origin is in use by + any URL‑endpoints, the API will return an error. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + f"/v1/accounts/origins/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def get( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Retrieves the origin identified by `id`. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return cast( + OriginResponse, + self._get( + f"/v1/accounts/origins/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=cast(Any, OriginResponse), # Union types cannot be passed in as arguments in the type system + ), + ) + + +class AsyncOriginsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncOriginsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncOriginsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncOriginsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncOriginsResourceWithStreamingResponse(self) + + @overload + async def create( + self, + *, + access_key: str, + bucket: str, + name: str, + secret_key: str, + type: Literal["S3"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def create( + self, + *, + access_key: str, + bucket: str, + endpoint: str, + name: str, + secret_key: str, + type: Literal["S3_COMPATIBLE"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + s3_force_path_style: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + endpoint: Custom S3-compatible endpoint. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + s3_force_path_style: Use path-style S3 URLs? + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def create( + self, + *, + access_key: str, + bucket: str, + name: str, + secret_key: str, + type: Literal["CLOUDINARY_BACKUP"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def create( + self, + *, + base_url: str, + name: str, + type: Literal["WEB_FOLDER"], + base_url_for_canonical_header: str | Omit = omit, + forward_host_header_to_origin: bool | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + base_url: Root URL for the web folder origin. + + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + forward_host_header_to_origin: Forward the Host header to origin? + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def create( + self, + *, + name: str, + type: Literal["WEB_PROXY"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def create( + self, + *, + bucket: str, + client_email: str, + name: str, + private_key: str, + type: Literal["GCS"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def create( + self, + *, + account_name: str, + container: str, + name: str, + sas_token: str, + type: Literal["AZURE_BLOB"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def create( + self, + *, + base_url: str, + client_id: str, + client_secret: str, + name: str, + password: str, + type: Literal["AKENEO_PIM"], + username: str, + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Creates a new origin and returns the origin object. + + Args: + base_url: Akeneo instance base URL. + + client_id: Akeneo API client ID. + + client_secret: Akeneo API client secret. + + name: Display name of the origin. + + password: Akeneo API password. + + username: Akeneo API username. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @required_args( + ["access_key", "bucket", "name", "secret_key", "type"], + ["access_key", "bucket", "endpoint", "name", "secret_key", "type"], + ["base_url", "name", "type"], + ["name", "type"], + ["bucket", "client_email", "name", "private_key", "type"], + ["account_name", "container", "name", "sas_token", "type"], + ["base_url", "client_id", "client_secret", "name", "password", "type", "username"], + ) + async def create( + self, + *, + access_key: str | Omit = omit, + bucket: str | Omit = omit, + name: str, + secret_key: str | Omit = omit, + type: Literal["S3"] + | Literal["S3_COMPATIBLE"] + | Literal["CLOUDINARY_BACKUP"] + | Literal["WEB_FOLDER"] + | Literal["WEB_PROXY"] + | Literal["GCS"] + | Literal["AZURE_BLOB"] + | Literal["AKENEO_PIM"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + endpoint: str | Omit = omit, + s3_force_path_style: bool | Omit = omit, + base_url: str | Omit = omit, + forward_host_header_to_origin: bool | Omit = omit, + client_email: str | Omit = omit, + private_key: str | Omit = omit, + account_name: str | Omit = omit, + container: str | Omit = omit, + sas_token: str | Omit = omit, + client_id: str | Omit = omit, + client_secret: str | Omit = omit, + password: str | Omit = omit, + username: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + return cast( + OriginResponse, + await self._post( + "/v1/accounts/origins", + body=await async_maybe_transform( + { + "access_key": access_key, + "bucket": bucket, + "name": name, + "secret_key": secret_key, + "type": type, + "base_url_for_canonical_header": base_url_for_canonical_header, + "include_canonical_header": include_canonical_header, + "prefix": prefix, + "endpoint": endpoint, + "s3_force_path_style": s3_force_path_style, + "base_url": base_url, + "forward_host_header_to_origin": forward_host_header_to_origin, + "client_email": client_email, + "private_key": private_key, + "account_name": account_name, + "container": container, + "sas_token": sas_token, + "client_id": client_id, + "client_secret": client_secret, + "password": password, + "username": username, + }, + origin_create_params.OriginCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=cast(Any, OriginResponse), # Union types cannot be passed in as arguments in the type system + ), + ) + + @overload + async def update( + self, + id: str, + *, + access_key: str, + bucket: str, + name: str, + secret_key: str, + type: Literal["S3"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def update( + self, + id: str, + *, + access_key: str, + bucket: str, + endpoint: str, + name: str, + secret_key: str, + type: Literal["S3_COMPATIBLE"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + s3_force_path_style: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + endpoint: Custom S3-compatible endpoint. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + s3_force_path_style: Use path-style S3 URLs? + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def update( + self, + id: str, + *, + access_key: str, + bucket: str, + name: str, + secret_key: str, + type: Literal["CLOUDINARY_BACKUP"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + access_key: Access key for the bucket. + + bucket: S3 bucket name. + + name: Display name of the origin. + + secret_key: Secret key for the bucket. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + prefix: Path prefix inside the bucket. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def update( + self, + id: str, + *, + base_url: str, + name: str, + type: Literal["WEB_FOLDER"], + base_url_for_canonical_header: str | Omit = omit, + forward_host_header_to_origin: bool | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + base_url: Root URL for the web folder origin. + + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + forward_host_header_to_origin: Forward the Host header to origin? + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def update( + self, + id: str, + *, + name: str, + type: Literal["WEB_PROXY"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def update( + self, + id: str, + *, + bucket: str, + client_email: str, + name: str, + private_key: str, + type: Literal["GCS"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def update( + self, + id: str, + *, + account_name: str, + container: str, + name: str, + sas_token: str, + type: Literal["AZURE_BLOB"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + name: Display name of the origin. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def update( + self, + id: str, + *, + base_url: str, + client_id: str, + client_secret: str, + name: str, + password: str, + type: Literal["AKENEO_PIM"], + username: str, + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Updates the origin identified by `id` and returns the updated origin object. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + base_url: Akeneo instance base URL. + + client_id: Akeneo API client ID. + + client_secret: Akeneo API client secret. + + name: Display name of the origin. + + password: Akeneo API password. + + username: Akeneo API username. + + base_url_for_canonical_header: URL used in the Canonical header (if enabled). + + include_canonical_header: Whether to send a Canonical header. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @required_args( + ["access_key", "bucket", "name", "secret_key", "type"], + ["access_key", "bucket", "endpoint", "name", "secret_key", "type"], + ["base_url", "name", "type"], + ["name", "type"], + ["bucket", "client_email", "name", "private_key", "type"], + ["account_name", "container", "name", "sas_token", "type"], + ["base_url", "client_id", "client_secret", "name", "password", "type", "username"], + ) + async def update( + self, + id: str, + *, + access_key: str | Omit = omit, + bucket: str | Omit = omit, + name: str, + secret_key: str | Omit = omit, + type: Literal["S3"] + | Literal["S3_COMPATIBLE"] + | Literal["CLOUDINARY_BACKUP"] + | Literal["WEB_FOLDER"] + | Literal["WEB_PROXY"] + | Literal["GCS"] + | Literal["AZURE_BLOB"] + | Literal["AKENEO_PIM"], + base_url_for_canonical_header: str | Omit = omit, + include_canonical_header: bool | Omit = omit, + prefix: str | Omit = omit, + endpoint: str | Omit = omit, + s3_force_path_style: bool | Omit = omit, + base_url: str | Omit = omit, + forward_host_header_to_origin: bool | Omit = omit, + client_email: str | Omit = omit, + private_key: str | Omit = omit, + account_name: str | Omit = omit, + container: str | Omit = omit, + sas_token: str | Omit = omit, + client_id: str | Omit = omit, + client_secret: str | Omit = omit, + password: str | Omit = omit, + username: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return cast( + OriginResponse, + await self._put( + f"/v1/accounts/origins/{id}", + body=await async_maybe_transform( + { + "access_key": access_key, + "bucket": bucket, + "name": name, + "secret_key": secret_key, + "type": type, + "base_url_for_canonical_header": base_url_for_canonical_header, + "include_canonical_header": include_canonical_header, + "prefix": prefix, + "endpoint": endpoint, + "s3_force_path_style": s3_force_path_style, + "base_url": base_url, + "forward_host_header_to_origin": forward_host_header_to_origin, + "client_email": client_email, + "private_key": private_key, + "account_name": account_name, + "container": container, + "sas_token": sas_token, + "client_id": client_id, + "client_secret": client_secret, + "password": password, + "username": username, + }, + origin_update_params.OriginUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=cast(Any, OriginResponse), # Union types cannot be passed in as arguments in the type system + ), + ) + + async def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginListResponse: + """**Note:** This API is currently in beta. + + + Returns an array of all configured origins for the current account. + """ + return await self._get( + "/v1/accounts/origins", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=OriginListResponse, + ) + + async def delete( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """**Note:** This API is currently in beta. + + + Permanently removes the origin identified by `id`. If the origin is in use by + any URL‑endpoints, the API will return an error. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + f"/v1/accounts/origins/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def get( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OriginResponse: + """**Note:** This API is currently in beta. + + + Retrieves the origin identified by `id`. + + Args: + id: Unique identifier for the origin. This is generated by ImageKit when you create + a new origin. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return cast( + OriginResponse, + await self._get( + f"/v1/accounts/origins/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=cast(Any, OriginResponse), # Union types cannot be passed in as arguments in the type system + ), + ) + + +class OriginsResourceWithRawResponse: + def __init__(self, origins: OriginsResource) -> None: + self._origins = origins + + self.create = to_raw_response_wrapper( + origins.create, + ) + self.update = to_raw_response_wrapper( + origins.update, + ) + self.list = to_raw_response_wrapper( + origins.list, + ) + self.delete = to_raw_response_wrapper( + origins.delete, + ) + self.get = to_raw_response_wrapper( + origins.get, + ) + + +class AsyncOriginsResourceWithRawResponse: + def __init__(self, origins: AsyncOriginsResource) -> None: + self._origins = origins + + self.create = async_to_raw_response_wrapper( + origins.create, + ) + self.update = async_to_raw_response_wrapper( + origins.update, + ) + self.list = async_to_raw_response_wrapper( + origins.list, + ) + self.delete = async_to_raw_response_wrapper( + origins.delete, + ) + self.get = async_to_raw_response_wrapper( + origins.get, + ) + + +class OriginsResourceWithStreamingResponse: + def __init__(self, origins: OriginsResource) -> None: + self._origins = origins + + self.create = to_streamed_response_wrapper( + origins.create, + ) + self.update = to_streamed_response_wrapper( + origins.update, + ) + self.list = to_streamed_response_wrapper( + origins.list, + ) + self.delete = to_streamed_response_wrapper( + origins.delete, + ) + self.get = to_streamed_response_wrapper( + origins.get, + ) + + +class AsyncOriginsResourceWithStreamingResponse: + def __init__(self, origins: AsyncOriginsResource) -> None: + self._origins = origins + + self.create = async_to_streamed_response_wrapper( + origins.create, + ) + self.update = async_to_streamed_response_wrapper( + origins.update, + ) + self.list = async_to_streamed_response_wrapper( + origins.list, + ) + self.delete = async_to_streamed_response_wrapper( + origins.delete, + ) + self.get = async_to_streamed_response_wrapper( + origins.get, + ) diff --git a/src/imagekitio/resources/accounts/url_endpoints.py b/src/imagekitio/resources/accounts/url_endpoints.py new file mode 100644 index 00000000..b5d9f368 --- /dev/null +++ b/src/imagekitio/resources/accounts/url_endpoints.py @@ -0,0 +1,594 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.accounts import url_endpoint_create_params, url_endpoint_update_params +from ...types.accounts.url_endpoint_response import URLEndpointResponse +from ...types.accounts.url_endpoint_list_response import URLEndpointListResponse + +__all__ = ["URLEndpointsResource", "AsyncURLEndpointsResource"] + + +class URLEndpointsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> URLEndpointsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return URLEndpointsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> URLEndpointsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return URLEndpointsResourceWithStreamingResponse(self) + + def create( + self, + *, + description: str, + origins: SequenceNotStr[str] | Omit = omit, + url_prefix: str | Omit = omit, + url_rewriter: url_endpoint_create_params.URLRewriter | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> URLEndpointResponse: + """**Note:** This API is currently in beta. + + + Creates a new URL‑endpoint and returns the resulting object. + + Args: + description: Description of the URL endpoint. + + origins: Ordered list of origin IDs to try when the file isn’t in the Media Library; + ImageKit checks them in the sequence provided. Origin must be created before it + can be used in a URL endpoint. + + url_prefix: Path segment appended to your base URL to form the endpoint (letters, digits, + and hyphens only — or empty for the default endpoint). + + url_rewriter: Configuration for third-party URL rewriting. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/accounts/url-endpoints", + body=maybe_transform( + { + "description": description, + "origins": origins, + "url_prefix": url_prefix, + "url_rewriter": url_rewriter, + }, + url_endpoint_create_params.URLEndpointCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=URLEndpointResponse, + ) + + def update( + self, + id: str, + *, + description: str, + origins: SequenceNotStr[str] | Omit = omit, + url_prefix: str | Omit = omit, + url_rewriter: url_endpoint_update_params.URLRewriter | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> URLEndpointResponse: + """**Note:** This API is currently in beta. + + + Updates the URL‑endpoint identified by `id` and returns the updated object. + + Args: + id: Unique identifier for the URL-endpoint. This is generated by ImageKit when you + create a new URL-endpoint. For the default URL-endpoint, this is always + `default`. + + description: Description of the URL endpoint. + + origins: Ordered list of origin IDs to try when the file isn’t in the Media Library; + ImageKit checks them in the sequence provided. Origin must be created before it + can be used in a URL endpoint. + + url_prefix: Path segment appended to your base URL to form the endpoint (letters, digits, + and hyphens only — or empty for the default endpoint). + + url_rewriter: Configuration for third-party URL rewriting. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._put( + f"/v1/accounts/url-endpoints/{id}", + body=maybe_transform( + { + "description": description, + "origins": origins, + "url_prefix": url_prefix, + "url_rewriter": url_rewriter, + }, + url_endpoint_update_params.URLEndpointUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=URLEndpointResponse, + ) + + def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> URLEndpointListResponse: + """**Note:** This API is currently in beta. + + + Returns an array of all URL‑endpoints configured including the default + URL-endpoint generated by ImageKit during account creation. + """ + return self._get( + "/v1/accounts/url-endpoints", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=URLEndpointListResponse, + ) + + def delete( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """**Note:** This API is currently in beta. + + + Deletes the URL‑endpoint identified by `id`. You cannot delete the default + URL‑endpoint created by ImageKit during account creation. + + Args: + id: Unique identifier for the URL-endpoint. This is generated by ImageKit when you + create a new URL-endpoint. For the default URL-endpoint, this is always + `default`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + f"/v1/accounts/url-endpoints/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def get( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> URLEndpointResponse: + """**Note:** This API is currently in beta. + + + Retrieves the URL‑endpoint identified by `id`. + + Args: + id: Unique identifier for the URL-endpoint. This is generated by ImageKit when you + create a new URL-endpoint. For the default URL-endpoint, this is always + `default`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get( + f"/v1/accounts/url-endpoints/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=URLEndpointResponse, + ) + + +class AsyncURLEndpointsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncURLEndpointsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncURLEndpointsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncURLEndpointsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncURLEndpointsResourceWithStreamingResponse(self) + + async def create( + self, + *, + description: str, + origins: SequenceNotStr[str] | Omit = omit, + url_prefix: str | Omit = omit, + url_rewriter: url_endpoint_create_params.URLRewriter | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> URLEndpointResponse: + """**Note:** This API is currently in beta. + + + Creates a new URL‑endpoint and returns the resulting object. + + Args: + description: Description of the URL endpoint. + + origins: Ordered list of origin IDs to try when the file isn’t in the Media Library; + ImageKit checks them in the sequence provided. Origin must be created before it + can be used in a URL endpoint. + + url_prefix: Path segment appended to your base URL to form the endpoint (letters, digits, + and hyphens only — or empty for the default endpoint). + + url_rewriter: Configuration for third-party URL rewriting. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/accounts/url-endpoints", + body=await async_maybe_transform( + { + "description": description, + "origins": origins, + "url_prefix": url_prefix, + "url_rewriter": url_rewriter, + }, + url_endpoint_create_params.URLEndpointCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=URLEndpointResponse, + ) + + async def update( + self, + id: str, + *, + description: str, + origins: SequenceNotStr[str] | Omit = omit, + url_prefix: str | Omit = omit, + url_rewriter: url_endpoint_update_params.URLRewriter | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> URLEndpointResponse: + """**Note:** This API is currently in beta. + + + Updates the URL‑endpoint identified by `id` and returns the updated object. + + Args: + id: Unique identifier for the URL-endpoint. This is generated by ImageKit when you + create a new URL-endpoint. For the default URL-endpoint, this is always + `default`. + + description: Description of the URL endpoint. + + origins: Ordered list of origin IDs to try when the file isn’t in the Media Library; + ImageKit checks them in the sequence provided. Origin must be created before it + can be used in a URL endpoint. + + url_prefix: Path segment appended to your base URL to form the endpoint (letters, digits, + and hyphens only — or empty for the default endpoint). + + url_rewriter: Configuration for third-party URL rewriting. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._put( + f"/v1/accounts/url-endpoints/{id}", + body=await async_maybe_transform( + { + "description": description, + "origins": origins, + "url_prefix": url_prefix, + "url_rewriter": url_rewriter, + }, + url_endpoint_update_params.URLEndpointUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=URLEndpointResponse, + ) + + async def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> URLEndpointListResponse: + """**Note:** This API is currently in beta. + + + Returns an array of all URL‑endpoints configured including the default + URL-endpoint generated by ImageKit during account creation. + """ + return await self._get( + "/v1/accounts/url-endpoints", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=URLEndpointListResponse, + ) + + async def delete( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """**Note:** This API is currently in beta. + + + Deletes the URL‑endpoint identified by `id`. You cannot delete the default + URL‑endpoint created by ImageKit during account creation. + + Args: + id: Unique identifier for the URL-endpoint. This is generated by ImageKit when you + create a new URL-endpoint. For the default URL-endpoint, this is always + `default`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + f"/v1/accounts/url-endpoints/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def get( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> URLEndpointResponse: + """**Note:** This API is currently in beta. + + + Retrieves the URL‑endpoint identified by `id`. + + Args: + id: Unique identifier for the URL-endpoint. This is generated by ImageKit when you + create a new URL-endpoint. For the default URL-endpoint, this is always + `default`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._get( + f"/v1/accounts/url-endpoints/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=URLEndpointResponse, + ) + + +class URLEndpointsResourceWithRawResponse: + def __init__(self, url_endpoints: URLEndpointsResource) -> None: + self._url_endpoints = url_endpoints + + self.create = to_raw_response_wrapper( + url_endpoints.create, + ) + self.update = to_raw_response_wrapper( + url_endpoints.update, + ) + self.list = to_raw_response_wrapper( + url_endpoints.list, + ) + self.delete = to_raw_response_wrapper( + url_endpoints.delete, + ) + self.get = to_raw_response_wrapper( + url_endpoints.get, + ) + + +class AsyncURLEndpointsResourceWithRawResponse: + def __init__(self, url_endpoints: AsyncURLEndpointsResource) -> None: + self._url_endpoints = url_endpoints + + self.create = async_to_raw_response_wrapper( + url_endpoints.create, + ) + self.update = async_to_raw_response_wrapper( + url_endpoints.update, + ) + self.list = async_to_raw_response_wrapper( + url_endpoints.list, + ) + self.delete = async_to_raw_response_wrapper( + url_endpoints.delete, + ) + self.get = async_to_raw_response_wrapper( + url_endpoints.get, + ) + + +class URLEndpointsResourceWithStreamingResponse: + def __init__(self, url_endpoints: URLEndpointsResource) -> None: + self._url_endpoints = url_endpoints + + self.create = to_streamed_response_wrapper( + url_endpoints.create, + ) + self.update = to_streamed_response_wrapper( + url_endpoints.update, + ) + self.list = to_streamed_response_wrapper( + url_endpoints.list, + ) + self.delete = to_streamed_response_wrapper( + url_endpoints.delete, + ) + self.get = to_streamed_response_wrapper( + url_endpoints.get, + ) + + +class AsyncURLEndpointsResourceWithStreamingResponse: + def __init__(self, url_endpoints: AsyncURLEndpointsResource) -> None: + self._url_endpoints = url_endpoints + + self.create = async_to_streamed_response_wrapper( + url_endpoints.create, + ) + self.update = async_to_streamed_response_wrapper( + url_endpoints.update, + ) + self.list = async_to_streamed_response_wrapper( + url_endpoints.list, + ) + self.delete = async_to_streamed_response_wrapper( + url_endpoints.delete, + ) + self.get = async_to_streamed_response_wrapper( + url_endpoints.get, + ) diff --git a/src/imagekitio/resources/accounts/usage.py b/src/imagekitio/resources/accounts/usage.py new file mode 100644 index 00000000..c044c9b8 --- /dev/null +++ b/src/imagekitio/resources/accounts/usage.py @@ -0,0 +1,206 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from datetime import date + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.accounts import usage_get_params +from ...types.accounts.usage_get_response import UsageGetResponse + +__all__ = ["UsageResource", "AsyncUsageResource"] + + +class UsageResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> UsageResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return UsageResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> UsageResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return UsageResourceWithStreamingResponse(self) + + def get( + self, + *, + end_date: Union[str, date], + start_date: Union[str, date], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> UsageGetResponse: + """Get the account usage information between two dates. + + Note that the API response + includes data from the start date while excluding data from the end date. In + other words, the data covers the period starting from the specified start date + up to, but not including, the end date. + + Args: + end_date: Specify a `endDate` in `YYYY-MM-DD` format. It should be after the `startDate`. + The difference between `startDate` and `endDate` should be less than 90 days. + + start_date: Specify a `startDate` in `YYYY-MM-DD` format. It should be before the `endDate`. + The difference between `startDate` and `endDate` should be less than 90 days. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/v1/accounts/usage", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "end_date": end_date, + "start_date": start_date, + }, + usage_get_params.UsageGetParams, + ), + ), + cast_to=UsageGetResponse, + ) + + +class AsyncUsageResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncUsageResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncUsageResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncUsageResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncUsageResourceWithStreamingResponse(self) + + async def get( + self, + *, + end_date: Union[str, date], + start_date: Union[str, date], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> UsageGetResponse: + """Get the account usage information between two dates. + + Note that the API response + includes data from the start date while excluding data from the end date. In + other words, the data covers the period starting from the specified start date + up to, but not including, the end date. + + Args: + end_date: Specify a `endDate` in `YYYY-MM-DD` format. It should be after the `startDate`. + The difference between `startDate` and `endDate` should be less than 90 days. + + start_date: Specify a `startDate` in `YYYY-MM-DD` format. It should be before the `endDate`. + The difference between `startDate` and `endDate` should be less than 90 days. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/v1/accounts/usage", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "end_date": end_date, + "start_date": start_date, + }, + usage_get_params.UsageGetParams, + ), + ), + cast_to=UsageGetResponse, + ) + + +class UsageResourceWithRawResponse: + def __init__(self, usage: UsageResource) -> None: + self._usage = usage + + self.get = to_raw_response_wrapper( + usage.get, + ) + + +class AsyncUsageResourceWithRawResponse: + def __init__(self, usage: AsyncUsageResource) -> None: + self._usage = usage + + self.get = async_to_raw_response_wrapper( + usage.get, + ) + + +class UsageResourceWithStreamingResponse: + def __init__(self, usage: UsageResource) -> None: + self._usage = usage + + self.get = to_streamed_response_wrapper( + usage.get, + ) + + +class AsyncUsageResourceWithStreamingResponse: + def __init__(self, usage: AsyncUsageResource) -> None: + self._usage = usage + + self.get = async_to_streamed_response_wrapper( + usage.get, + ) diff --git a/src/imagekitio/resources/assets.py b/src/imagekitio/resources/assets.py new file mode 100644 index 00000000..18828fb4 --- /dev/null +++ b/src/imagekitio/resources/assets.py @@ -0,0 +1,325 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal + +import httpx + +from ..types import asset_list_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.asset_list_response import AssetListResponse + +__all__ = ["AssetsResource", "AsyncAssetsResource"] + + +class AssetsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> AssetsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AssetsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AssetsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AssetsResourceWithStreamingResponse(self) + + def list( + self, + *, + file_type: Literal["all", "image", "non-image"] | Omit = omit, + limit: int | Omit = omit, + path: str | Omit = omit, + search_query: str | Omit = omit, + skip: int | Omit = omit, + sort: Literal[ + "ASC_NAME", + "DESC_NAME", + "ASC_CREATED", + "DESC_CREATED", + "ASC_UPDATED", + "DESC_UPDATED", + "ASC_HEIGHT", + "DESC_HEIGHT", + "ASC_WIDTH", + "DESC_WIDTH", + "ASC_SIZE", + "DESC_SIZE", + "ASC_RELEVANCE", + "DESC_RELEVANCE", + ] + | Omit = omit, + type: Literal["file", "file-version", "folder", "all"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AssetListResponse: + """ + This API can list all the uploaded files and folders in your ImageKit.io media + library. In addition, you can fine-tune your query by specifying various filters + by generating a query string in a Lucene-like syntax and provide this generated + string as the value of the `searchQuery`. + + Args: + file_type: Filter results by file type. + + - `all` — include all file types + - `image` — include only image files + - `non-image` — include only non-image files (e.g., JS, CSS, video) + + limit: The maximum number of results to return in response. + + path: Folder path if you want to limit the search within a specific folder. For + example, `/sales-banner/` will only search in folder sales-banner. + + Note : If your use case involves searching within a folder as well as its + subfolders, you can use `path` parameter in `searchQuery` with appropriate + operator. Checkout + [Supported parameters](/docs/api-reference/digital-asset-management-dam/list-and-search-assets#supported-parameters) + for more information. + + search_query: Query string in a Lucene-like query language e.g. `createdAt > "7d"`. + + Note : When the searchQuery parameter is present, the following query parameters + will have no effect on the result: + + 1. `tags` + 2. `type` + 3. `name` + + [Learn more](/docs/api-reference/digital-asset-management-dam/list-and-search-assets#advanced-search-queries) + from examples. + + skip: The number of results to skip before returning results. + + sort: Sort the results by one of the supported fields in ascending or descending + order. + + type: Filter results by asset type. + + - `file` — returns only files + - `file-version` — returns specific file versions + - `folder` — returns only folders + - `all` — returns both files and folders (excludes `file-version`) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/v1/files", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "file_type": file_type, + "limit": limit, + "path": path, + "search_query": search_query, + "skip": skip, + "sort": sort, + "type": type, + }, + asset_list_params.AssetListParams, + ), + ), + cast_to=AssetListResponse, + ) + + +class AsyncAssetsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncAssetsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncAssetsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAssetsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncAssetsResourceWithStreamingResponse(self) + + async def list( + self, + *, + file_type: Literal["all", "image", "non-image"] | Omit = omit, + limit: int | Omit = omit, + path: str | Omit = omit, + search_query: str | Omit = omit, + skip: int | Omit = omit, + sort: Literal[ + "ASC_NAME", + "DESC_NAME", + "ASC_CREATED", + "DESC_CREATED", + "ASC_UPDATED", + "DESC_UPDATED", + "ASC_HEIGHT", + "DESC_HEIGHT", + "ASC_WIDTH", + "DESC_WIDTH", + "ASC_SIZE", + "DESC_SIZE", + "ASC_RELEVANCE", + "DESC_RELEVANCE", + ] + | Omit = omit, + type: Literal["file", "file-version", "folder", "all"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AssetListResponse: + """ + This API can list all the uploaded files and folders in your ImageKit.io media + library. In addition, you can fine-tune your query by specifying various filters + by generating a query string in a Lucene-like syntax and provide this generated + string as the value of the `searchQuery`. + + Args: + file_type: Filter results by file type. + + - `all` — include all file types + - `image` — include only image files + - `non-image` — include only non-image files (e.g., JS, CSS, video) + + limit: The maximum number of results to return in response. + + path: Folder path if you want to limit the search within a specific folder. For + example, `/sales-banner/` will only search in folder sales-banner. + + Note : If your use case involves searching within a folder as well as its + subfolders, you can use `path` parameter in `searchQuery` with appropriate + operator. Checkout + [Supported parameters](/docs/api-reference/digital-asset-management-dam/list-and-search-assets#supported-parameters) + for more information. + + search_query: Query string in a Lucene-like query language e.g. `createdAt > "7d"`. + + Note : When the searchQuery parameter is present, the following query parameters + will have no effect on the result: + + 1. `tags` + 2. `type` + 3. `name` + + [Learn more](/docs/api-reference/digital-asset-management-dam/list-and-search-assets#advanced-search-queries) + from examples. + + skip: The number of results to skip before returning results. + + sort: Sort the results by one of the supported fields in ascending or descending + order. + + type: Filter results by asset type. + + - `file` — returns only files + - `file-version` — returns specific file versions + - `folder` — returns only folders + - `all` — returns both files and folders (excludes `file-version`) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/v1/files", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "file_type": file_type, + "limit": limit, + "path": path, + "search_query": search_query, + "skip": skip, + "sort": sort, + "type": type, + }, + asset_list_params.AssetListParams, + ), + ), + cast_to=AssetListResponse, + ) + + +class AssetsResourceWithRawResponse: + def __init__(self, assets: AssetsResource) -> None: + self._assets = assets + + self.list = to_raw_response_wrapper( + assets.list, + ) + + +class AsyncAssetsResourceWithRawResponse: + def __init__(self, assets: AsyncAssetsResource) -> None: + self._assets = assets + + self.list = async_to_raw_response_wrapper( + assets.list, + ) + + +class AssetsResourceWithStreamingResponse: + def __init__(self, assets: AssetsResource) -> None: + self._assets = assets + + self.list = to_streamed_response_wrapper( + assets.list, + ) + + +class AsyncAssetsResourceWithStreamingResponse: + def __init__(self, assets: AsyncAssetsResource) -> None: + self._assets = assets + + self.list = async_to_streamed_response_wrapper( + assets.list, + ) diff --git a/src/imagekitio/resources/beta/__init__.py b/src/imagekitio/resources/beta/__init__.py new file mode 100644 index 00000000..08cb3fbc --- /dev/null +++ b/src/imagekitio/resources/beta/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .v2 import ( + V2Resource, + AsyncV2Resource, + V2ResourceWithRawResponse, + AsyncV2ResourceWithRawResponse, + V2ResourceWithStreamingResponse, + AsyncV2ResourceWithStreamingResponse, +) +from .beta import ( + BetaResource, + AsyncBetaResource, + BetaResourceWithRawResponse, + AsyncBetaResourceWithRawResponse, + BetaResourceWithStreamingResponse, + AsyncBetaResourceWithStreamingResponse, +) + +__all__ = [ + "V2Resource", + "AsyncV2Resource", + "V2ResourceWithRawResponse", + "AsyncV2ResourceWithRawResponse", + "V2ResourceWithStreamingResponse", + "AsyncV2ResourceWithStreamingResponse", + "BetaResource", + "AsyncBetaResource", + "BetaResourceWithRawResponse", + "AsyncBetaResourceWithRawResponse", + "BetaResourceWithStreamingResponse", + "AsyncBetaResourceWithStreamingResponse", +] diff --git a/src/imagekitio/resources/beta/beta.py b/src/imagekitio/resources/beta/beta.py new file mode 100644 index 00000000..e37c117e --- /dev/null +++ b/src/imagekitio/resources/beta/beta.py @@ -0,0 +1,102 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .v2.v2 import ( + V2Resource, + AsyncV2Resource, + V2ResourceWithRawResponse, + AsyncV2ResourceWithRawResponse, + V2ResourceWithStreamingResponse, + AsyncV2ResourceWithStreamingResponse, +) +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource + +__all__ = ["BetaResource", "AsyncBetaResource"] + + +class BetaResource(SyncAPIResource): + @cached_property + def v2(self) -> V2Resource: + return V2Resource(self._client) + + @cached_property + def with_raw_response(self) -> BetaResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return BetaResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BetaResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return BetaResourceWithStreamingResponse(self) + + +class AsyncBetaResource(AsyncAPIResource): + @cached_property + def v2(self) -> AsyncV2Resource: + return AsyncV2Resource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncBetaResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncBetaResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBetaResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncBetaResourceWithStreamingResponse(self) + + +class BetaResourceWithRawResponse: + def __init__(self, beta: BetaResource) -> None: + self._beta = beta + + @cached_property + def v2(self) -> V2ResourceWithRawResponse: + return V2ResourceWithRawResponse(self._beta.v2) + + +class AsyncBetaResourceWithRawResponse: + def __init__(self, beta: AsyncBetaResource) -> None: + self._beta = beta + + @cached_property + def v2(self) -> AsyncV2ResourceWithRawResponse: + return AsyncV2ResourceWithRawResponse(self._beta.v2) + + +class BetaResourceWithStreamingResponse: + def __init__(self, beta: BetaResource) -> None: + self._beta = beta + + @cached_property + def v2(self) -> V2ResourceWithStreamingResponse: + return V2ResourceWithStreamingResponse(self._beta.v2) + + +class AsyncBetaResourceWithStreamingResponse: + def __init__(self, beta: AsyncBetaResource) -> None: + self._beta = beta + + @cached_property + def v2(self) -> AsyncV2ResourceWithStreamingResponse: + return AsyncV2ResourceWithStreamingResponse(self._beta.v2) diff --git a/src/imagekitio/resources/beta/v2/__init__.py b/src/imagekitio/resources/beta/v2/__init__.py new file mode 100644 index 00000000..6f4e8f3e --- /dev/null +++ b/src/imagekitio/resources/beta/v2/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .v2 import ( + V2Resource, + AsyncV2Resource, + V2ResourceWithRawResponse, + AsyncV2ResourceWithRawResponse, + V2ResourceWithStreamingResponse, + AsyncV2ResourceWithStreamingResponse, +) +from .files import ( + FilesResource, + AsyncFilesResource, + FilesResourceWithRawResponse, + AsyncFilesResourceWithRawResponse, + FilesResourceWithStreamingResponse, + AsyncFilesResourceWithStreamingResponse, +) + +__all__ = [ + "FilesResource", + "AsyncFilesResource", + "FilesResourceWithRawResponse", + "AsyncFilesResourceWithRawResponse", + "FilesResourceWithStreamingResponse", + "AsyncFilesResourceWithStreamingResponse", + "V2Resource", + "AsyncV2Resource", + "V2ResourceWithRawResponse", + "AsyncV2ResourceWithRawResponse", + "V2ResourceWithStreamingResponse", + "AsyncV2ResourceWithStreamingResponse", +] diff --git a/src/imagekitio/resources/beta/v2/files.py b/src/imagekitio/resources/beta/v2/files.py new file mode 100644 index 00000000..16c9b659 --- /dev/null +++ b/src/imagekitio/resources/beta/v2/files.py @@ -0,0 +1,580 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, List, Mapping, cast +from typing_extensions import Literal + +import httpx + +from ...._types import ( + Body, + Omit, + Query, + Headers, + NotGiven, + FileTypes, + SequenceNotStr, + omit, + not_given, +) +from ...._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from ...._compat import cached_property +from ...._resource import SyncAPIResource, AsyncAPIResource +from ...._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...._base_client import make_request_options +from ....types.beta.v2 import file_upload_params +from ....lib.serialization_utils import serialize_upload_options +from ....types.shared_params.extensions import Extensions +from ....types.beta.v2.file_upload_response import FileUploadResponse + +__all__ = ["FilesResource", "AsyncFilesResource"] + + +class FilesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> FilesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return FilesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> FilesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return FilesResourceWithStreamingResponse(self) + + def upload( + self, + *, + file: FileTypes, + file_name: str, + token: str | Omit = omit, + checks: str | Omit = omit, + custom_coordinates: str | Omit = omit, + custom_metadata: Dict[str, object] | Omit = omit, + description: str | Omit = omit, + extensions: Extensions | Omit = omit, + folder: str | Omit = omit, + is_private_file: bool | Omit = omit, + is_published: bool | Omit = omit, + overwrite_ai_tags: bool | Omit = omit, + overwrite_custom_metadata: bool | Omit = omit, + overwrite_file: bool | Omit = omit, + overwrite_tags: bool | Omit = omit, + response_fields: List[ + Literal[ + "tags", + "customCoordinates", + "isPrivateFile", + "embeddedMetadata", + "isPublished", + "customMetadata", + "metadata", + "selectedFieldsSchema", + ] + ] + | Omit = omit, + tags: SequenceNotStr[str] | Omit = omit, + transformation: file_upload_params.Transformation | Omit = omit, + use_unique_file_name: bool | Omit = omit, + webhook_url: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileUploadResponse: + """The V2 API enhances security by verifying the entire payload using JWT. + + This API + is in beta. + + ImageKit.io allows you to upload files directly from both the server and client + sides. For server-side uploads, private API key authentication is used. For + client-side uploads, generate a one-time `token` from your secure backend using + private API. + [Learn more](/docs/api-reference/upload-file/upload-file-v2#how-to-implement-secure-client-side-file-upload) + about how to implement secure client-side file upload. + + **File size limit** \\ + On the free plan, the maximum upload file sizes are 20MB for images, audio, and raw + files, and 100MB for videos. On the paid plan, these limits increase to 40MB for + images, audio, and raw files, and 2GB for videos. These limits can be further increased + with higher-tier plans. + + **Version limit** \\ + A file can have a maximum of 100 versions. + + **Demo applications** + + - A full-fledged + [upload widget using Uppy](https://github.com/imagekit-samples/uppy-uploader), + supporting file selections from local storage, URL, Dropbox, Google Drive, + Instagram, and more. + - [Quick start guides](/docs/quick-start-guides) for various frameworks and + technologies. + + Args: + file: + The API accepts any of the following: + + - **Binary data** – send the raw bytes as `multipart/form-data`. + - **HTTP / HTTPS URL** – a publicly reachable URL that ImageKit’s servers can + fetch. + - **Base64 string** – the file encoded as a Base64 data URI or plain Base64. + + When supplying a URL, the server must receive the response headers within 8 + seconds; otherwise the request fails with 400 Bad Request. + + file_name: The name with which the file has to be uploaded. + + token: This is the client-generated JSON Web Token (JWT). The ImageKit.io server uses + it to authenticate and check that the upload request parameters have not been + tampered with after the token has been generated. Learn how to create the token + on the page below. This field is only required for authentication when uploading + a file from the client side. + + **Note**: Sending a JWT that has been used in the past will result in a + validation error. Even if your previous request resulted in an error, you should + always send a new token. + + **⚠️Warning**: JWT must be generated on the server-side because it is generated + using your account's private API key. This field is required for authentication + when uploading a file from the client-side. + + checks: Server-side checks to run on the asset. Read more about + [Upload API checks](/docs/api-reference/upload-file/upload-file-v2#upload-api-checks). + + custom_coordinates: Define an important area in the image. This is only relevant for image type + files. + + - To be passed as a string with the x and y coordinates of the top-left corner, + and width and height of the area of interest in the format `x,y,width,height`. + For example - `10,10,100,100` + - Can be used with fo-customtransformation. + - If this field is not specified and the file is overwritten, then + customCoordinates will be removed. + + custom_metadata: JSON key-value pairs to associate with the asset. Create the custom metadata + fields before setting these values. + + description: Optional text to describe the contents of the file. + + extensions: Array of extensions to be applied to the asset. Each extension can be configured + with specific parameters based on the extension type. + + folder: The folder path in which the image has to be uploaded. If the folder(s) didn't + exist before, a new folder(s) is created. Using multiple `/` creates a nested + folder. + + is_private_file: Whether to mark the file as private or not. + + If `true`, the file is marked as private and is accessible only using named + transformation or signed URL. + + is_published: Whether to upload file as published or not. + + If `false`, the file is marked as unpublished, which restricts access to the + file only via the media library. Files in draft or unpublished state can only be + publicly accessed after being published. + + The option to upload in draft state is only available in custom enterprise + pricing plans. + + overwrite_ai_tags: If set to `true` and a file already exists at the exact location, its AITags + will be removed. Set `overwriteAITags` to `false` to preserve AITags. + + overwrite_custom_metadata: If the request does not have `customMetadata`, and a file already exists at the + exact location, existing customMetadata will be removed. + + overwrite_file: If `false` and `useUniqueFileName` is also `false`, and a file already exists at + the exact location, upload API will return an error immediately. + + overwrite_tags: If the request does not have `tags`, and a file already exists at the exact + location, existing tags will be removed. + + response_fields: Array of response field keys to include in the API response body. + + tags: Set the tags while uploading the file. Provide an array of tag strings (e.g. + `["tag1", "tag2", "tag3"]`). The combined length of all tag characters must not + exceed 500, and the `%` character is not allowed. If this field is not specified + and the file is overwritten, the existing tags will be removed. + + transformation: Configure pre-processing (`pre`) and post-processing (`post`) transformations. + + - `pre` — applied before the file is uploaded to the Media Library. + Useful for reducing file size or applying basic optimizations upfront (e.g., + resize, compress). + + - `post` — applied immediately after upload. + Ideal for generating transformed versions (like video encodes or thumbnails) + in advance, so they're ready for delivery without delay. + + You can mix and match any combination of post-processing types. + + use_unique_file_name: Whether to use a unique filename for this file or not. + + If `true`, ImageKit.io will add a unique suffix to the filename parameter to get + a unique filename. + + If `false`, then the image is uploaded with the provided filename parameter, and + any existing file with the same name is replaced. + + webhook_url: The final status of extensions after they have completed execution will be + delivered to this endpoint as a POST request. + [Learn more](/docs/api-reference/digital-asset-management-dam/managing-assets/update-file-details#webhook-payload-structure) + about the webhook payload structure. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + body = deepcopy_minimal( + { + "file": file, + "file_name": file_name, + "token": token, + "checks": checks, + "custom_coordinates": custom_coordinates, + "custom_metadata": custom_metadata, + "description": description, + "extensions": extensions, + "folder": folder, + "is_private_file": is_private_file, + "is_published": is_published, + "overwrite_ai_tags": overwrite_ai_tags, + "overwrite_custom_metadata": overwrite_custom_metadata, + "overwrite_file": overwrite_file, + "overwrite_tags": overwrite_tags, + "response_fields": response_fields, + "tags": tags, + "transformation": transformation, + "use_unique_file_name": use_unique_file_name, + "webhook_url": webhook_url, + } + ) + body = serialize_upload_options(body) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return self._post( + "/api/v2/files/upload" + if self._client._base_url_overridden + else "https://upload.imagekit.io/api/v2/files/upload", + body=maybe_transform(body, file_upload_params.FileUploadParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileUploadResponse, + ) + + +class AsyncFilesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncFilesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncFilesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncFilesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncFilesResourceWithStreamingResponse(self) + + async def upload( + self, + *, + file: FileTypes, + file_name: str, + token: str | Omit = omit, + checks: str | Omit = omit, + custom_coordinates: str | Omit = omit, + custom_metadata: Dict[str, object] | Omit = omit, + description: str | Omit = omit, + extensions: Extensions | Omit = omit, + folder: str | Omit = omit, + is_private_file: bool | Omit = omit, + is_published: bool | Omit = omit, + overwrite_ai_tags: bool | Omit = omit, + overwrite_custom_metadata: bool | Omit = omit, + overwrite_file: bool | Omit = omit, + overwrite_tags: bool | Omit = omit, + response_fields: List[ + Literal[ + "tags", + "customCoordinates", + "isPrivateFile", + "embeddedMetadata", + "isPublished", + "customMetadata", + "metadata", + "selectedFieldsSchema", + ] + ] + | Omit = omit, + tags: SequenceNotStr[str] | Omit = omit, + transformation: file_upload_params.Transformation | Omit = omit, + use_unique_file_name: bool | Omit = omit, + webhook_url: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileUploadResponse: + """The V2 API enhances security by verifying the entire payload using JWT. + + This API + is in beta. + + ImageKit.io allows you to upload files directly from both the server and client + sides. For server-side uploads, private API key authentication is used. For + client-side uploads, generate a one-time `token` from your secure backend using + private API. + [Learn more](/docs/api-reference/upload-file/upload-file-v2#how-to-implement-secure-client-side-file-upload) + about how to implement secure client-side file upload. + + **File size limit** \\ + On the free plan, the maximum upload file sizes are 20MB for images, audio, and raw + files, and 100MB for videos. On the paid plan, these limits increase to 40MB for + images, audio, and raw files, and 2GB for videos. These limits can be further increased + with higher-tier plans. + + **Version limit** \\ + A file can have a maximum of 100 versions. + + **Demo applications** + + - A full-fledged + [upload widget using Uppy](https://github.com/imagekit-samples/uppy-uploader), + supporting file selections from local storage, URL, Dropbox, Google Drive, + Instagram, and more. + - [Quick start guides](/docs/quick-start-guides) for various frameworks and + technologies. + + Args: + file: + The API accepts any of the following: + + - **Binary data** – send the raw bytes as `multipart/form-data`. + - **HTTP / HTTPS URL** – a publicly reachable URL that ImageKit’s servers can + fetch. + - **Base64 string** – the file encoded as a Base64 data URI or plain Base64. + + When supplying a URL, the server must receive the response headers within 8 + seconds; otherwise the request fails with 400 Bad Request. + + file_name: The name with which the file has to be uploaded. + + token: This is the client-generated JSON Web Token (JWT). The ImageKit.io server uses + it to authenticate and check that the upload request parameters have not been + tampered with after the token has been generated. Learn how to create the token + on the page below. This field is only required for authentication when uploading + a file from the client side. + + **Note**: Sending a JWT that has been used in the past will result in a + validation error. Even if your previous request resulted in an error, you should + always send a new token. + + **⚠️Warning**: JWT must be generated on the server-side because it is generated + using your account's private API key. This field is required for authentication + when uploading a file from the client-side. + + checks: Server-side checks to run on the asset. Read more about + [Upload API checks](/docs/api-reference/upload-file/upload-file-v2#upload-api-checks). + + custom_coordinates: Define an important area in the image. This is only relevant for image type + files. + + - To be passed as a string with the x and y coordinates of the top-left corner, + and width and height of the area of interest in the format `x,y,width,height`. + For example - `10,10,100,100` + - Can be used with fo-customtransformation. + - If this field is not specified and the file is overwritten, then + customCoordinates will be removed. + + custom_metadata: JSON key-value pairs to associate with the asset. Create the custom metadata + fields before setting these values. + + description: Optional text to describe the contents of the file. + + extensions: Array of extensions to be applied to the asset. Each extension can be configured + with specific parameters based on the extension type. + + folder: The folder path in which the image has to be uploaded. If the folder(s) didn't + exist before, a new folder(s) is created. Using multiple `/` creates a nested + folder. + + is_private_file: Whether to mark the file as private or not. + + If `true`, the file is marked as private and is accessible only using named + transformation or signed URL. + + is_published: Whether to upload file as published or not. + + If `false`, the file is marked as unpublished, which restricts access to the + file only via the media library. Files in draft or unpublished state can only be + publicly accessed after being published. + + The option to upload in draft state is only available in custom enterprise + pricing plans. + + overwrite_ai_tags: If set to `true` and a file already exists at the exact location, its AITags + will be removed. Set `overwriteAITags` to `false` to preserve AITags. + + overwrite_custom_metadata: If the request does not have `customMetadata`, and a file already exists at the + exact location, existing customMetadata will be removed. + + overwrite_file: If `false` and `useUniqueFileName` is also `false`, and a file already exists at + the exact location, upload API will return an error immediately. + + overwrite_tags: If the request does not have `tags`, and a file already exists at the exact + location, existing tags will be removed. + + response_fields: Array of response field keys to include in the API response body. + + tags: Set the tags while uploading the file. Provide an array of tag strings (e.g. + `["tag1", "tag2", "tag3"]`). The combined length of all tag characters must not + exceed 500, and the `%` character is not allowed. If this field is not specified + and the file is overwritten, the existing tags will be removed. + + transformation: Configure pre-processing (`pre`) and post-processing (`post`) transformations. + + - `pre` — applied before the file is uploaded to the Media Library. + Useful for reducing file size or applying basic optimizations upfront (e.g., + resize, compress). + + - `post` — applied immediately after upload. + Ideal for generating transformed versions (like video encodes or thumbnails) + in advance, so they're ready for delivery without delay. + + You can mix and match any combination of post-processing types. + + use_unique_file_name: Whether to use a unique filename for this file or not. + + If `true`, ImageKit.io will add a unique suffix to the filename parameter to get + a unique filename. + + If `false`, then the image is uploaded with the provided filename parameter, and + any existing file with the same name is replaced. + + webhook_url: The final status of extensions after they have completed execution will be + delivered to this endpoint as a POST request. + [Learn more](/docs/api-reference/digital-asset-management-dam/managing-assets/update-file-details#webhook-payload-structure) + about the webhook payload structure. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + body = deepcopy_minimal( + { + "file": file, + "file_name": file_name, + "token": token, + "checks": checks, + "custom_coordinates": custom_coordinates, + "custom_metadata": custom_metadata, + "description": description, + "extensions": extensions, + "folder": folder, + "is_private_file": is_private_file, + "is_published": is_published, + "overwrite_ai_tags": overwrite_ai_tags, + "overwrite_custom_metadata": overwrite_custom_metadata, + "overwrite_file": overwrite_file, + "overwrite_tags": overwrite_tags, + "response_fields": response_fields, + "tags": tags, + "transformation": transformation, + "use_unique_file_name": use_unique_file_name, + "webhook_url": webhook_url, + } + ) + body = serialize_upload_options(body) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return await self._post( + "/api/v2/files/upload" + if self._client._base_url_overridden + else "https://upload.imagekit.io/api/v2/files/upload", + body=await async_maybe_transform(body, file_upload_params.FileUploadParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileUploadResponse, + ) + + +class FilesResourceWithRawResponse: + def __init__(self, files: FilesResource) -> None: + self._files = files + + self.upload = to_raw_response_wrapper( + files.upload, + ) + + +class AsyncFilesResourceWithRawResponse: + def __init__(self, files: AsyncFilesResource) -> None: + self._files = files + + self.upload = async_to_raw_response_wrapper( + files.upload, + ) + + +class FilesResourceWithStreamingResponse: + def __init__(self, files: FilesResource) -> None: + self._files = files + + self.upload = to_streamed_response_wrapper( + files.upload, + ) + + +class AsyncFilesResourceWithStreamingResponse: + def __init__(self, files: AsyncFilesResource) -> None: + self._files = files + + self.upload = async_to_streamed_response_wrapper( + files.upload, + ) diff --git a/src/imagekitio/resources/beta/v2/v2.py b/src/imagekitio/resources/beta/v2/v2.py new file mode 100644 index 00000000..f552e26a --- /dev/null +++ b/src/imagekitio/resources/beta/v2/v2.py @@ -0,0 +1,102 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .files import ( + FilesResource, + AsyncFilesResource, + FilesResourceWithRawResponse, + AsyncFilesResourceWithRawResponse, + FilesResourceWithStreamingResponse, + AsyncFilesResourceWithStreamingResponse, +) +from ...._compat import cached_property +from ...._resource import SyncAPIResource, AsyncAPIResource + +__all__ = ["V2Resource", "AsyncV2Resource"] + + +class V2Resource(SyncAPIResource): + @cached_property + def files(self) -> FilesResource: + return FilesResource(self._client) + + @cached_property + def with_raw_response(self) -> V2ResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return V2ResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> V2ResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return V2ResourceWithStreamingResponse(self) + + +class AsyncV2Resource(AsyncAPIResource): + @cached_property + def files(self) -> AsyncFilesResource: + return AsyncFilesResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncV2ResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncV2ResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncV2ResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncV2ResourceWithStreamingResponse(self) + + +class V2ResourceWithRawResponse: + def __init__(self, v2: V2Resource) -> None: + self._v2 = v2 + + @cached_property + def files(self) -> FilesResourceWithRawResponse: + return FilesResourceWithRawResponse(self._v2.files) + + +class AsyncV2ResourceWithRawResponse: + def __init__(self, v2: AsyncV2Resource) -> None: + self._v2 = v2 + + @cached_property + def files(self) -> AsyncFilesResourceWithRawResponse: + return AsyncFilesResourceWithRawResponse(self._v2.files) + + +class V2ResourceWithStreamingResponse: + def __init__(self, v2: V2Resource) -> None: + self._v2 = v2 + + @cached_property + def files(self) -> FilesResourceWithStreamingResponse: + return FilesResourceWithStreamingResponse(self._v2.files) + + +class AsyncV2ResourceWithStreamingResponse: + def __init__(self, v2: AsyncV2Resource) -> None: + self._v2 = v2 + + @cached_property + def files(self) -> AsyncFilesResourceWithStreamingResponse: + return AsyncFilesResourceWithStreamingResponse(self._v2.files) diff --git a/src/imagekitio/resources/cache/__init__.py b/src/imagekitio/resources/cache/__init__.py new file mode 100644 index 00000000..f7e5a700 --- /dev/null +++ b/src/imagekitio/resources/cache/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .cache import ( + CacheResource, + AsyncCacheResource, + CacheResourceWithRawResponse, + AsyncCacheResourceWithRawResponse, + CacheResourceWithStreamingResponse, + AsyncCacheResourceWithStreamingResponse, +) +from .invalidation import ( + InvalidationResource, + AsyncInvalidationResource, + InvalidationResourceWithRawResponse, + AsyncInvalidationResourceWithRawResponse, + InvalidationResourceWithStreamingResponse, + AsyncInvalidationResourceWithStreamingResponse, +) + +__all__ = [ + "InvalidationResource", + "AsyncInvalidationResource", + "InvalidationResourceWithRawResponse", + "AsyncInvalidationResourceWithRawResponse", + "InvalidationResourceWithStreamingResponse", + "AsyncInvalidationResourceWithStreamingResponse", + "CacheResource", + "AsyncCacheResource", + "CacheResourceWithRawResponse", + "AsyncCacheResourceWithRawResponse", + "CacheResourceWithStreamingResponse", + "AsyncCacheResourceWithStreamingResponse", +] diff --git a/src/imagekitio/resources/cache/cache.py b/src/imagekitio/resources/cache/cache.py new file mode 100644 index 00000000..47016b06 --- /dev/null +++ b/src/imagekitio/resources/cache/cache.py @@ -0,0 +1,102 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from .invalidation import ( + InvalidationResource, + AsyncInvalidationResource, + InvalidationResourceWithRawResponse, + AsyncInvalidationResourceWithRawResponse, + InvalidationResourceWithStreamingResponse, + AsyncInvalidationResourceWithStreamingResponse, +) + +__all__ = ["CacheResource", "AsyncCacheResource"] + + +class CacheResource(SyncAPIResource): + @cached_property + def invalidation(self) -> InvalidationResource: + return InvalidationResource(self._client) + + @cached_property + def with_raw_response(self) -> CacheResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return CacheResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CacheResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return CacheResourceWithStreamingResponse(self) + + +class AsyncCacheResource(AsyncAPIResource): + @cached_property + def invalidation(self) -> AsyncInvalidationResource: + return AsyncInvalidationResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncCacheResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncCacheResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCacheResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncCacheResourceWithStreamingResponse(self) + + +class CacheResourceWithRawResponse: + def __init__(self, cache: CacheResource) -> None: + self._cache = cache + + @cached_property + def invalidation(self) -> InvalidationResourceWithRawResponse: + return InvalidationResourceWithRawResponse(self._cache.invalidation) + + +class AsyncCacheResourceWithRawResponse: + def __init__(self, cache: AsyncCacheResource) -> None: + self._cache = cache + + @cached_property + def invalidation(self) -> AsyncInvalidationResourceWithRawResponse: + return AsyncInvalidationResourceWithRawResponse(self._cache.invalidation) + + +class CacheResourceWithStreamingResponse: + def __init__(self, cache: CacheResource) -> None: + self._cache = cache + + @cached_property + def invalidation(self) -> InvalidationResourceWithStreamingResponse: + return InvalidationResourceWithStreamingResponse(self._cache.invalidation) + + +class AsyncCacheResourceWithStreamingResponse: + def __init__(self, cache: AsyncCacheResource) -> None: + self._cache = cache + + @cached_property + def invalidation(self) -> AsyncInvalidationResourceWithStreamingResponse: + return AsyncInvalidationResourceWithStreamingResponse(self._cache.invalidation) diff --git a/src/imagekitio/resources/cache/invalidation.py b/src/imagekitio/resources/cache/invalidation.py new file mode 100644 index 00000000..f1f6c72b --- /dev/null +++ b/src/imagekitio/resources/cache/invalidation.py @@ -0,0 +1,252 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...types.cache import invalidation_create_params +from ..._base_client import make_request_options +from ...types.cache.invalidation_get_response import InvalidationGetResponse +from ...types.cache.invalidation_create_response import InvalidationCreateResponse + +__all__ = ["InvalidationResource", "AsyncInvalidationResource"] + + +class InvalidationResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> InvalidationResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return InvalidationResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> InvalidationResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return InvalidationResourceWithStreamingResponse(self) + + def create( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> InvalidationCreateResponse: + """This API will purge CDN cache and ImageKit.io's internal cache for a file. + + Note: + Purge cache is an asynchronous process and it may take some time to reflect the + changes. + + Args: + url: The full URL of the file to be purged. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/files/purge", + body=maybe_transform({"url": url}, invalidation_create_params.InvalidationCreateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvalidationCreateResponse, + ) + + def get( + self, + request_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> InvalidationGetResponse: + """ + This API returns the status of a purge cache request. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not request_id: + raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}") + return self._get( + f"/v1/files/purge/{request_id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvalidationGetResponse, + ) + + +class AsyncInvalidationResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncInvalidationResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncInvalidationResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncInvalidationResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncInvalidationResourceWithStreamingResponse(self) + + async def create( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> InvalidationCreateResponse: + """This API will purge CDN cache and ImageKit.io's internal cache for a file. + + Note: + Purge cache is an asynchronous process and it may take some time to reflect the + changes. + + Args: + url: The full URL of the file to be purged. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/files/purge", + body=await async_maybe_transform({"url": url}, invalidation_create_params.InvalidationCreateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvalidationCreateResponse, + ) + + async def get( + self, + request_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> InvalidationGetResponse: + """ + This API returns the status of a purge cache request. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not request_id: + raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}") + return await self._get( + f"/v1/files/purge/{request_id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InvalidationGetResponse, + ) + + +class InvalidationResourceWithRawResponse: + def __init__(self, invalidation: InvalidationResource) -> None: + self._invalidation = invalidation + + self.create = to_raw_response_wrapper( + invalidation.create, + ) + self.get = to_raw_response_wrapper( + invalidation.get, + ) + + +class AsyncInvalidationResourceWithRawResponse: + def __init__(self, invalidation: AsyncInvalidationResource) -> None: + self._invalidation = invalidation + + self.create = async_to_raw_response_wrapper( + invalidation.create, + ) + self.get = async_to_raw_response_wrapper( + invalidation.get, + ) + + +class InvalidationResourceWithStreamingResponse: + def __init__(self, invalidation: InvalidationResource) -> None: + self._invalidation = invalidation + + self.create = to_streamed_response_wrapper( + invalidation.create, + ) + self.get = to_streamed_response_wrapper( + invalidation.get, + ) + + +class AsyncInvalidationResourceWithStreamingResponse: + def __init__(self, invalidation: AsyncInvalidationResource) -> None: + self._invalidation = invalidation + + self.create = async_to_streamed_response_wrapper( + invalidation.create, + ) + self.get = async_to_streamed_response_wrapper( + invalidation.get, + ) diff --git a/src/imagekitio/resources/custom_metadata_fields.py b/src/imagekitio/resources/custom_metadata_fields.py new file mode 100644 index 00000000..0c56e033 --- /dev/null +++ b/src/imagekitio/resources/custom_metadata_fields.py @@ -0,0 +1,535 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import ( + custom_metadata_field_list_params, + custom_metadata_field_create_params, + custom_metadata_field_update_params, +) +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.custom_metadata_field import CustomMetadataField +from ..types.custom_metadata_field_list_response import CustomMetadataFieldListResponse +from ..types.custom_metadata_field_delete_response import CustomMetadataFieldDeleteResponse + +__all__ = ["CustomMetadataFieldsResource", "AsyncCustomMetadataFieldsResource"] + + +class CustomMetadataFieldsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> CustomMetadataFieldsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return CustomMetadataFieldsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CustomMetadataFieldsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return CustomMetadataFieldsResourceWithStreamingResponse(self) + + def create( + self, + *, + label: str, + name: str, + schema: custom_metadata_field_create_params.Schema, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CustomMetadataField: + """This API creates a new custom metadata field. + + Once a custom metadata field is + created either through this API or using the dashboard UI, its value can be set + on the assets. The value of a field for an asset can be set using the media + library UI or programmatically through upload or update assets API. + + Args: + label: Human readable name of the custom metadata field. This should be unique across + all non deleted custom metadata fields. This name is displayed as form field + label to the users while setting field value on an asset in the media library + UI. + + name: API name of the custom metadata field. This should be unique across all + (including deleted) custom metadata fields. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/customMetadataFields", + body=maybe_transform( + { + "label": label, + "name": name, + "schema": schema, + }, + custom_metadata_field_create_params.CustomMetadataFieldCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CustomMetadataField, + ) + + def update( + self, + id: str, + *, + label: str | Omit = omit, + schema: custom_metadata_field_update_params.Schema | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CustomMetadataField: + """ + This API updates the label or schema of an existing custom metadata field. + + Args: + label: Human readable name of the custom metadata field. This should be unique across + all non deleted custom metadata fields. This name is displayed as form field + label to the users while setting field value on an asset in the media library + UI. This parameter is required if `schema` is not provided. + + schema: An object that describes the rules for the custom metadata key. This parameter + is required if `label` is not provided. Note: `type` cannot be updated and will + be ignored if sent with the `schema`. The schema will be validated as per the + existing `type`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._patch( + f"/v1/customMetadataFields/{id}", + body=maybe_transform( + { + "label": label, + "schema": schema, + }, + custom_metadata_field_update_params.CustomMetadataFieldUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CustomMetadataField, + ) + + def list( + self, + *, + folder_path: str | Omit = omit, + include_deleted: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CustomMetadataFieldListResponse: + """This API returns the array of created custom metadata field objects. + + By default + the API returns only non deleted field objects, but you can include deleted + fields in the API response. + + You can also filter results by a specific folder path to retrieve custom + metadata fields applicable at that location. This path-specific filtering is + useful when using the **Path policy** feature to determine which custom metadata + fields are selected for a given path. + + Args: + folder_path: The folder path (e.g., `/path/to/folder`) for which to retrieve applicable + custom metadata fields. Useful for determining path-specific field selections + when the [Path policy](https://imagekit.io/docs/dam/path-policy) feature is in + use. + + include_deleted: Set it to `true` to include deleted field objects in the API response. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/v1/customMetadataFields", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "folder_path": folder_path, + "include_deleted": include_deleted, + }, + custom_metadata_field_list_params.CustomMetadataFieldListParams, + ), + ), + cast_to=CustomMetadataFieldListResponse, + ) + + def delete( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CustomMetadataFieldDeleteResponse: + """This API deletes a custom metadata field. + + Even after deleting a custom metadata + field, you cannot create any new custom metadata field with the same name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._delete( + f"/v1/customMetadataFields/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CustomMetadataFieldDeleteResponse, + ) + + +class AsyncCustomMetadataFieldsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncCustomMetadataFieldsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncCustomMetadataFieldsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCustomMetadataFieldsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncCustomMetadataFieldsResourceWithStreamingResponse(self) + + async def create( + self, + *, + label: str, + name: str, + schema: custom_metadata_field_create_params.Schema, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CustomMetadataField: + """This API creates a new custom metadata field. + + Once a custom metadata field is + created either through this API or using the dashboard UI, its value can be set + on the assets. The value of a field for an asset can be set using the media + library UI or programmatically through upload or update assets API. + + Args: + label: Human readable name of the custom metadata field. This should be unique across + all non deleted custom metadata fields. This name is displayed as form field + label to the users while setting field value on an asset in the media library + UI. + + name: API name of the custom metadata field. This should be unique across all + (including deleted) custom metadata fields. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/customMetadataFields", + body=await async_maybe_transform( + { + "label": label, + "name": name, + "schema": schema, + }, + custom_metadata_field_create_params.CustomMetadataFieldCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CustomMetadataField, + ) + + async def update( + self, + id: str, + *, + label: str | Omit = omit, + schema: custom_metadata_field_update_params.Schema | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CustomMetadataField: + """ + This API updates the label or schema of an existing custom metadata field. + + Args: + label: Human readable name of the custom metadata field. This should be unique across + all non deleted custom metadata fields. This name is displayed as form field + label to the users while setting field value on an asset in the media library + UI. This parameter is required if `schema` is not provided. + + schema: An object that describes the rules for the custom metadata key. This parameter + is required if `label` is not provided. Note: `type` cannot be updated and will + be ignored if sent with the `schema`. The schema will be validated as per the + existing `type`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._patch( + f"/v1/customMetadataFields/{id}", + body=await async_maybe_transform( + { + "label": label, + "schema": schema, + }, + custom_metadata_field_update_params.CustomMetadataFieldUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CustomMetadataField, + ) + + async def list( + self, + *, + folder_path: str | Omit = omit, + include_deleted: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CustomMetadataFieldListResponse: + """This API returns the array of created custom metadata field objects. + + By default + the API returns only non deleted field objects, but you can include deleted + fields in the API response. + + You can also filter results by a specific folder path to retrieve custom + metadata fields applicable at that location. This path-specific filtering is + useful when using the **Path policy** feature to determine which custom metadata + fields are selected for a given path. + + Args: + folder_path: The folder path (e.g., `/path/to/folder`) for which to retrieve applicable + custom metadata fields. Useful for determining path-specific field selections + when the [Path policy](https://imagekit.io/docs/dam/path-policy) feature is in + use. + + include_deleted: Set it to `true` to include deleted field objects in the API response. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/v1/customMetadataFields", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "folder_path": folder_path, + "include_deleted": include_deleted, + }, + custom_metadata_field_list_params.CustomMetadataFieldListParams, + ), + ), + cast_to=CustomMetadataFieldListResponse, + ) + + async def delete( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CustomMetadataFieldDeleteResponse: + """This API deletes a custom metadata field. + + Even after deleting a custom metadata + field, you cannot create any new custom metadata field with the same name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._delete( + f"/v1/customMetadataFields/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CustomMetadataFieldDeleteResponse, + ) + + +class CustomMetadataFieldsResourceWithRawResponse: + def __init__(self, custom_metadata_fields: CustomMetadataFieldsResource) -> None: + self._custom_metadata_fields = custom_metadata_fields + + self.create = to_raw_response_wrapper( + custom_metadata_fields.create, + ) + self.update = to_raw_response_wrapper( + custom_metadata_fields.update, + ) + self.list = to_raw_response_wrapper( + custom_metadata_fields.list, + ) + self.delete = to_raw_response_wrapper( + custom_metadata_fields.delete, + ) + + +class AsyncCustomMetadataFieldsResourceWithRawResponse: + def __init__(self, custom_metadata_fields: AsyncCustomMetadataFieldsResource) -> None: + self._custom_metadata_fields = custom_metadata_fields + + self.create = async_to_raw_response_wrapper( + custom_metadata_fields.create, + ) + self.update = async_to_raw_response_wrapper( + custom_metadata_fields.update, + ) + self.list = async_to_raw_response_wrapper( + custom_metadata_fields.list, + ) + self.delete = async_to_raw_response_wrapper( + custom_metadata_fields.delete, + ) + + +class CustomMetadataFieldsResourceWithStreamingResponse: + def __init__(self, custom_metadata_fields: CustomMetadataFieldsResource) -> None: + self._custom_metadata_fields = custom_metadata_fields + + self.create = to_streamed_response_wrapper( + custom_metadata_fields.create, + ) + self.update = to_streamed_response_wrapper( + custom_metadata_fields.update, + ) + self.list = to_streamed_response_wrapper( + custom_metadata_fields.list, + ) + self.delete = to_streamed_response_wrapper( + custom_metadata_fields.delete, + ) + + +class AsyncCustomMetadataFieldsResourceWithStreamingResponse: + def __init__(self, custom_metadata_fields: AsyncCustomMetadataFieldsResource) -> None: + self._custom_metadata_fields = custom_metadata_fields + + self.create = async_to_streamed_response_wrapper( + custom_metadata_fields.create, + ) + self.update = async_to_streamed_response_wrapper( + custom_metadata_fields.update, + ) + self.list = async_to_streamed_response_wrapper( + custom_metadata_fields.list, + ) + self.delete = async_to_streamed_response_wrapper( + custom_metadata_fields.delete, + ) diff --git a/src/imagekitio/resources/dummy.py b/src/imagekitio/resources/dummy.py new file mode 100644 index 00000000..aa444522 --- /dev/null +++ b/src/imagekitio/resources/dummy.py @@ -0,0 +1,345 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import dummy_create_params +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.shared_params.overlay import Overlay +from ..types.shared_params.extensions import Extensions +from ..types.shared_params.src_options import SrcOptions +from ..types.shared_params.base_overlay import BaseOverlay +from ..types.shared_params.text_overlay import TextOverlay +from ..types.shared.streaming_resolution import StreamingResolution +from ..types.shared_params.image_overlay import ImageOverlay +from ..types.shared_params.video_overlay import VideoOverlay +from ..types.shared_params.overlay_timing import OverlayTiming +from ..types.shared_params.transformation import Transformation +from ..types.shared.transformation_position import TransformationPosition +from ..types.shared_params.overlay_position import OverlayPosition +from ..types.shared_params.subtitle_overlay import SubtitleOverlay +from ..types.shared_params.solid_color_overlay import SolidColorOverlay +from ..types.shared_params.responsive_image_attributes import ResponsiveImageAttributes +from ..types.shared_params.text_overlay_transformation import TextOverlayTransformation +from ..types.shared_params.get_image_attributes_options import GetImageAttributesOptions +from ..types.shared_params.subtitle_overlay_transformation import SubtitleOverlayTransformation +from ..types.shared_params.solid_color_overlay_transformation import SolidColorOverlayTransformation + +__all__ = ["DummyResource", "AsyncDummyResource"] + + +class DummyResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> DummyResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return DummyResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> DummyResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return DummyResourceWithStreamingResponse(self) + + def create( + self, + *, + base_overlay: BaseOverlay | Omit = omit, + extensions: Extensions | Omit = omit, + get_image_attributes_options: GetImageAttributesOptions | Omit = omit, + image_overlay: ImageOverlay | Omit = omit, + overlay: Overlay | Omit = omit, + overlay_position: OverlayPosition | Omit = omit, + overlay_timing: OverlayTiming | Omit = omit, + responsive_image_attributes: ResponsiveImageAttributes | Omit = omit, + solid_color_overlay: SolidColorOverlay | Omit = omit, + solid_color_overlay_transformation: SolidColorOverlayTransformation | Omit = omit, + src_options: SrcOptions | Omit = omit, + streaming_resolution: StreamingResolution | Omit = omit, + subtitle_overlay: SubtitleOverlay | Omit = omit, + subtitle_overlay_transformation: SubtitleOverlayTransformation | Omit = omit, + text_overlay: TextOverlay | Omit = omit, + text_overlay_transformation: TextOverlayTransformation | Omit = omit, + transformation: Transformation | Omit = omit, + transformation_position: TransformationPosition | Omit = omit, + video_overlay: VideoOverlay | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """Internal test endpoint for SDK generation purposes only. + + This endpoint + demonstrates usage of all shared models defined in the Stainless configuration + and is not intended for public consumption. + + Args: + extensions: Array of extensions to be applied to the asset. Each extension can be configured + with specific parameters based on the extension type. + + get_image_attributes_options: Options for generating responsive image attributes including `src`, `srcSet`, + and `sizes` for HTML `` elements. This schema extends `SrcOptions` to add + support for responsive image generation with breakpoints. + + overlay: Specifies an overlay to be applied on the parent image or video. ImageKit + supports overlays including images, text, videos, subtitles, and solid colors. + See + [Overlay using layers](https://imagekit.io/docs/transformations#overlay-using-layers). + + responsive_image_attributes: Resulting set of attributes suitable for an HTML `` element. Useful for + enabling responsive image loading with `srcSet` and `sizes`. + + src_options: Options for generating ImageKit URLs with transformations. See the + [Transformations guide](https://imagekit.io/docs/transformations). + + streaming_resolution: Available streaming resolutions for + [adaptive bitrate streaming](https://imagekit.io/docs/adaptive-bitrate-streaming) + + subtitle_overlay_transformation: Subtitle styling options. + [Learn more](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + from the docs. + + transformation: The SDK provides easy-to-use names for transformations. These names are + converted to the corresponding transformation string before being added to the + URL. SDKs are updated regularly to support new transformations. If you want to + use a transformation that is not supported by the SDK, You can use the `raw` + parameter to pass the transformation string directly. See the + [Transformations documentation](https://imagekit.io/docs/transformations). + + transformation_position: By default, the transformation string is added as a query parameter in the URL, + e.g., `?tr=w-100,h-100`. If you want to add the transformation string in the + path of the URL, set this to `path`. Learn more in the + [Transformations guide](https://imagekit.io/docs/transformations). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + "/v1/dummy/test", + body=maybe_transform( + { + "base_overlay": base_overlay, + "extensions": extensions, + "get_image_attributes_options": get_image_attributes_options, + "image_overlay": image_overlay, + "overlay": overlay, + "overlay_position": overlay_position, + "overlay_timing": overlay_timing, + "responsive_image_attributes": responsive_image_attributes, + "solid_color_overlay": solid_color_overlay, + "solid_color_overlay_transformation": solid_color_overlay_transformation, + "src_options": src_options, + "streaming_resolution": streaming_resolution, + "subtitle_overlay": subtitle_overlay, + "subtitle_overlay_transformation": subtitle_overlay_transformation, + "text_overlay": text_overlay, + "text_overlay_transformation": text_overlay_transformation, + "transformation": transformation, + "transformation_position": transformation_position, + "video_overlay": video_overlay, + }, + dummy_create_params.DummyCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AsyncDummyResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncDummyResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncDummyResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncDummyResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncDummyResourceWithStreamingResponse(self) + + async def create( + self, + *, + base_overlay: BaseOverlay | Omit = omit, + extensions: Extensions | Omit = omit, + get_image_attributes_options: GetImageAttributesOptions | Omit = omit, + image_overlay: ImageOverlay | Omit = omit, + overlay: Overlay | Omit = omit, + overlay_position: OverlayPosition | Omit = omit, + overlay_timing: OverlayTiming | Omit = omit, + responsive_image_attributes: ResponsiveImageAttributes | Omit = omit, + solid_color_overlay: SolidColorOverlay | Omit = omit, + solid_color_overlay_transformation: SolidColorOverlayTransformation | Omit = omit, + src_options: SrcOptions | Omit = omit, + streaming_resolution: StreamingResolution | Omit = omit, + subtitle_overlay: SubtitleOverlay | Omit = omit, + subtitle_overlay_transformation: SubtitleOverlayTransformation | Omit = omit, + text_overlay: TextOverlay | Omit = omit, + text_overlay_transformation: TextOverlayTransformation | Omit = omit, + transformation: Transformation | Omit = omit, + transformation_position: TransformationPosition | Omit = omit, + video_overlay: VideoOverlay | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """Internal test endpoint for SDK generation purposes only. + + This endpoint + demonstrates usage of all shared models defined in the Stainless configuration + and is not intended for public consumption. + + Args: + extensions: Array of extensions to be applied to the asset. Each extension can be configured + with specific parameters based on the extension type. + + get_image_attributes_options: Options for generating responsive image attributes including `src`, `srcSet`, + and `sizes` for HTML `` elements. This schema extends `SrcOptions` to add + support for responsive image generation with breakpoints. + + overlay: Specifies an overlay to be applied on the parent image or video. ImageKit + supports overlays including images, text, videos, subtitles, and solid colors. + See + [Overlay using layers](https://imagekit.io/docs/transformations#overlay-using-layers). + + responsive_image_attributes: Resulting set of attributes suitable for an HTML `` element. Useful for + enabling responsive image loading with `srcSet` and `sizes`. + + src_options: Options for generating ImageKit URLs with transformations. See the + [Transformations guide](https://imagekit.io/docs/transformations). + + streaming_resolution: Available streaming resolutions for + [adaptive bitrate streaming](https://imagekit.io/docs/adaptive-bitrate-streaming) + + subtitle_overlay_transformation: Subtitle styling options. + [Learn more](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + from the docs. + + transformation: The SDK provides easy-to-use names for transformations. These names are + converted to the corresponding transformation string before being added to the + URL. SDKs are updated regularly to support new transformations. If you want to + use a transformation that is not supported by the SDK, You can use the `raw` + parameter to pass the transformation string directly. See the + [Transformations documentation](https://imagekit.io/docs/transformations). + + transformation_position: By default, the transformation string is added as a query parameter in the URL, + e.g., `?tr=w-100,h-100`. If you want to add the transformation string in the + path of the URL, set this to `path`. Learn more in the + [Transformations guide](https://imagekit.io/docs/transformations). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + "/v1/dummy/test", + body=await async_maybe_transform( + { + "base_overlay": base_overlay, + "extensions": extensions, + "get_image_attributes_options": get_image_attributes_options, + "image_overlay": image_overlay, + "overlay": overlay, + "overlay_position": overlay_position, + "overlay_timing": overlay_timing, + "responsive_image_attributes": responsive_image_attributes, + "solid_color_overlay": solid_color_overlay, + "solid_color_overlay_transformation": solid_color_overlay_transformation, + "src_options": src_options, + "streaming_resolution": streaming_resolution, + "subtitle_overlay": subtitle_overlay, + "subtitle_overlay_transformation": subtitle_overlay_transformation, + "text_overlay": text_overlay, + "text_overlay_transformation": text_overlay_transformation, + "transformation": transformation, + "transformation_position": transformation_position, + "video_overlay": video_overlay, + }, + dummy_create_params.DummyCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class DummyResourceWithRawResponse: + def __init__(self, dummy: DummyResource) -> None: + self._dummy = dummy + + self.create = to_raw_response_wrapper( + dummy.create, + ) + + +class AsyncDummyResourceWithRawResponse: + def __init__(self, dummy: AsyncDummyResource) -> None: + self._dummy = dummy + + self.create = async_to_raw_response_wrapper( + dummy.create, + ) + + +class DummyResourceWithStreamingResponse: + def __init__(self, dummy: DummyResource) -> None: + self._dummy = dummy + + self.create = to_streamed_response_wrapper( + dummy.create, + ) + + +class AsyncDummyResourceWithStreamingResponse: + def __init__(self, dummy: AsyncDummyResource) -> None: + self._dummy = dummy + + self.create = async_to_streamed_response_wrapper( + dummy.create, + ) diff --git a/src/imagekitio/resources/files/__init__.py b/src/imagekitio/resources/files/__init__.py new file mode 100644 index 00000000..7fdf5194 --- /dev/null +++ b/src/imagekitio/resources/files/__init__.py @@ -0,0 +1,61 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .bulk import ( + BulkResource, + AsyncBulkResource, + BulkResourceWithRawResponse, + AsyncBulkResourceWithRawResponse, + BulkResourceWithStreamingResponse, + AsyncBulkResourceWithStreamingResponse, +) +from .files import ( + FilesResource, + AsyncFilesResource, + FilesResourceWithRawResponse, + AsyncFilesResourceWithRawResponse, + FilesResourceWithStreamingResponse, + AsyncFilesResourceWithStreamingResponse, +) +from .metadata import ( + MetadataResource, + AsyncMetadataResource, + MetadataResourceWithRawResponse, + AsyncMetadataResourceWithRawResponse, + MetadataResourceWithStreamingResponse, + AsyncMetadataResourceWithStreamingResponse, +) +from .versions import ( + VersionsResource, + AsyncVersionsResource, + VersionsResourceWithRawResponse, + AsyncVersionsResourceWithRawResponse, + VersionsResourceWithStreamingResponse, + AsyncVersionsResourceWithStreamingResponse, +) + +__all__ = [ + "BulkResource", + "AsyncBulkResource", + "BulkResourceWithRawResponse", + "AsyncBulkResourceWithRawResponse", + "BulkResourceWithStreamingResponse", + "AsyncBulkResourceWithStreamingResponse", + "VersionsResource", + "AsyncVersionsResource", + "VersionsResourceWithRawResponse", + "AsyncVersionsResourceWithRawResponse", + "VersionsResourceWithStreamingResponse", + "AsyncVersionsResourceWithStreamingResponse", + "MetadataResource", + "AsyncMetadataResource", + "MetadataResourceWithRawResponse", + "AsyncMetadataResourceWithRawResponse", + "MetadataResourceWithStreamingResponse", + "AsyncMetadataResourceWithStreamingResponse", + "FilesResource", + "AsyncFilesResource", + "FilesResourceWithRawResponse", + "AsyncFilesResourceWithRawResponse", + "FilesResourceWithStreamingResponse", + "AsyncFilesResourceWithStreamingResponse", +] diff --git a/src/imagekitio/resources/files/bulk.py b/src/imagekitio/resources/files/bulk.py new file mode 100644 index 00000000..cd525b81 --- /dev/null +++ b/src/imagekitio/resources/files/bulk.py @@ -0,0 +1,488 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, SequenceNotStr, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...types.files import bulk_delete_params, bulk_add_tags_params, bulk_remove_tags_params, bulk_remove_ai_tags_params +from ..._base_client import make_request_options +from ...types.files.bulk_delete_response import BulkDeleteResponse +from ...types.files.bulk_add_tags_response import BulkAddTagsResponse +from ...types.files.bulk_remove_tags_response import BulkRemoveTagsResponse +from ...types.files.bulk_remove_ai_tags_response import BulkRemoveAITagsResponse + +__all__ = ["BulkResource", "AsyncBulkResource"] + + +class BulkResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> BulkResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return BulkResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BulkResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return BulkResourceWithStreamingResponse(self) + + def delete( + self, + *, + file_ids: SequenceNotStr[str], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BulkDeleteResponse: + """ + This API deletes multiple files and all their file versions permanently. + + Note: If a file or specific transformation has been requested in the past, then + the response is cached. Deleting a file does not purge the cache. You can purge + the cache using purge cache API. + + A maximum of 100 files can be deleted at a time. + + Args: + file_ids: An array of fileIds which you want to delete. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/files/batch/deleteByFileIds", + body=maybe_transform({"file_ids": file_ids}, bulk_delete_params.BulkDeleteParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BulkDeleteResponse, + ) + + def add_tags( + self, + *, + file_ids: SequenceNotStr[str], + tags: SequenceNotStr[str], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BulkAddTagsResponse: + """This API adds tags to multiple files in bulk. + + A maximum of 50 files can be + specified at a time. + + Args: + file_ids: An array of fileIds to which you want to add tags. + + tags: An array of tags that you want to add to the files. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/files/addTags", + body=maybe_transform( + { + "file_ids": file_ids, + "tags": tags, + }, + bulk_add_tags_params.BulkAddTagsParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BulkAddTagsResponse, + ) + + def remove_ai_tags( + self, + *, + ai_tags: SequenceNotStr[str], + file_ids: SequenceNotStr[str], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BulkRemoveAITagsResponse: + """This API removes AITags from multiple files in bulk. + + A maximum of 50 files can + be specified at a time. + + Args: + ai_tags: An array of AITags that you want to remove from the files. + + file_ids: An array of fileIds from which you want to remove AITags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/files/removeAITags", + body=maybe_transform( + { + "ai_tags": ai_tags, + "file_ids": file_ids, + }, + bulk_remove_ai_tags_params.BulkRemoveAITagsParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BulkRemoveAITagsResponse, + ) + + def remove_tags( + self, + *, + file_ids: SequenceNotStr[str], + tags: SequenceNotStr[str], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BulkRemoveTagsResponse: + """This API removes tags from multiple files in bulk. + + A maximum of 50 files can be + specified at a time. + + Args: + file_ids: An array of fileIds from which you want to remove tags. + + tags: An array of tags that you want to remove from the files. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/files/removeTags", + body=maybe_transform( + { + "file_ids": file_ids, + "tags": tags, + }, + bulk_remove_tags_params.BulkRemoveTagsParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BulkRemoveTagsResponse, + ) + + +class AsyncBulkResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncBulkResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncBulkResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBulkResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncBulkResourceWithStreamingResponse(self) + + async def delete( + self, + *, + file_ids: SequenceNotStr[str], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BulkDeleteResponse: + """ + This API deletes multiple files and all their file versions permanently. + + Note: If a file or specific transformation has been requested in the past, then + the response is cached. Deleting a file does not purge the cache. You can purge + the cache using purge cache API. + + A maximum of 100 files can be deleted at a time. + + Args: + file_ids: An array of fileIds which you want to delete. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/files/batch/deleteByFileIds", + body=await async_maybe_transform({"file_ids": file_ids}, bulk_delete_params.BulkDeleteParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BulkDeleteResponse, + ) + + async def add_tags( + self, + *, + file_ids: SequenceNotStr[str], + tags: SequenceNotStr[str], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BulkAddTagsResponse: + """This API adds tags to multiple files in bulk. + + A maximum of 50 files can be + specified at a time. + + Args: + file_ids: An array of fileIds to which you want to add tags. + + tags: An array of tags that you want to add to the files. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/files/addTags", + body=await async_maybe_transform( + { + "file_ids": file_ids, + "tags": tags, + }, + bulk_add_tags_params.BulkAddTagsParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BulkAddTagsResponse, + ) + + async def remove_ai_tags( + self, + *, + ai_tags: SequenceNotStr[str], + file_ids: SequenceNotStr[str], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BulkRemoveAITagsResponse: + """This API removes AITags from multiple files in bulk. + + A maximum of 50 files can + be specified at a time. + + Args: + ai_tags: An array of AITags that you want to remove from the files. + + file_ids: An array of fileIds from which you want to remove AITags. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/files/removeAITags", + body=await async_maybe_transform( + { + "ai_tags": ai_tags, + "file_ids": file_ids, + }, + bulk_remove_ai_tags_params.BulkRemoveAITagsParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BulkRemoveAITagsResponse, + ) + + async def remove_tags( + self, + *, + file_ids: SequenceNotStr[str], + tags: SequenceNotStr[str], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BulkRemoveTagsResponse: + """This API removes tags from multiple files in bulk. + + A maximum of 50 files can be + specified at a time. + + Args: + file_ids: An array of fileIds from which you want to remove tags. + + tags: An array of tags that you want to remove from the files. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/files/removeTags", + body=await async_maybe_transform( + { + "file_ids": file_ids, + "tags": tags, + }, + bulk_remove_tags_params.BulkRemoveTagsParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BulkRemoveTagsResponse, + ) + + +class BulkResourceWithRawResponse: + def __init__(self, bulk: BulkResource) -> None: + self._bulk = bulk + + self.delete = to_raw_response_wrapper( + bulk.delete, + ) + self.add_tags = to_raw_response_wrapper( + bulk.add_tags, + ) + self.remove_ai_tags = to_raw_response_wrapper( + bulk.remove_ai_tags, + ) + self.remove_tags = to_raw_response_wrapper( + bulk.remove_tags, + ) + + +class AsyncBulkResourceWithRawResponse: + def __init__(self, bulk: AsyncBulkResource) -> None: + self._bulk = bulk + + self.delete = async_to_raw_response_wrapper( + bulk.delete, + ) + self.add_tags = async_to_raw_response_wrapper( + bulk.add_tags, + ) + self.remove_ai_tags = async_to_raw_response_wrapper( + bulk.remove_ai_tags, + ) + self.remove_tags = async_to_raw_response_wrapper( + bulk.remove_tags, + ) + + +class BulkResourceWithStreamingResponse: + def __init__(self, bulk: BulkResource) -> None: + self._bulk = bulk + + self.delete = to_streamed_response_wrapper( + bulk.delete, + ) + self.add_tags = to_streamed_response_wrapper( + bulk.add_tags, + ) + self.remove_ai_tags = to_streamed_response_wrapper( + bulk.remove_ai_tags, + ) + self.remove_tags = to_streamed_response_wrapper( + bulk.remove_tags, + ) + + +class AsyncBulkResourceWithStreamingResponse: + def __init__(self, bulk: AsyncBulkResource) -> None: + self._bulk = bulk + + self.delete = async_to_streamed_response_wrapper( + bulk.delete, + ) + self.add_tags = async_to_streamed_response_wrapper( + bulk.add_tags, + ) + self.remove_ai_tags = async_to_streamed_response_wrapper( + bulk.remove_ai_tags, + ) + self.remove_tags = async_to_streamed_response_wrapper( + bulk.remove_tags, + ) diff --git a/src/imagekitio/resources/files/files.py b/src/imagekitio/resources/files/files.py new file mode 100644 index 00000000..af6c166f --- /dev/null +++ b/src/imagekitio/resources/files/files.py @@ -0,0 +1,1574 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, List, Union, Mapping, Optional, cast +from typing_extensions import Literal, overload + +import httpx + +from .bulk import ( + BulkResource, + AsyncBulkResource, + BulkResourceWithRawResponse, + AsyncBulkResourceWithRawResponse, + BulkResourceWithStreamingResponse, + AsyncBulkResourceWithStreamingResponse, +) +from ...types import ( + file_copy_params, + file_move_params, + file_rename_params, + file_update_params, + file_upload_params, +) +from ..._types import ( + Body, + Omit, + Query, + Headers, + NoneType, + NotGiven, + FileTypes, + SequenceNotStr, + omit, + not_given, +) +from ..._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from .metadata import ( + MetadataResource, + AsyncMetadataResource, + MetadataResourceWithRawResponse, + AsyncMetadataResourceWithRawResponse, + MetadataResourceWithStreamingResponse, + AsyncMetadataResourceWithStreamingResponse, +) +from .versions import ( + VersionsResource, + AsyncVersionsResource, + VersionsResourceWithRawResponse, + AsyncVersionsResourceWithRawResponse, + VersionsResourceWithStreamingResponse, + AsyncVersionsResourceWithStreamingResponse, +) +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...types.file import File +from ..._base_client import make_request_options +from ...lib.serialization_utils import serialize_upload_options +from ...types.file_copy_response import FileCopyResponse +from ...types.file_move_response import FileMoveResponse +from ...types.file_rename_response import FileRenameResponse +from ...types.file_update_response import FileUpdateResponse +from ...types.file_upload_response import FileUploadResponse +from ...types.shared_params.extensions import Extensions + +__all__ = ["FilesResource", "AsyncFilesResource"] + + +class FilesResource(SyncAPIResource): + @cached_property + def bulk(self) -> BulkResource: + return BulkResource(self._client) + + @cached_property + def versions(self) -> VersionsResource: + return VersionsResource(self._client) + + @cached_property + def metadata(self) -> MetadataResource: + return MetadataResource(self._client) + + @cached_property + def with_raw_response(self) -> FilesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return FilesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> FilesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return FilesResourceWithStreamingResponse(self) + + @overload + def update( + self, + file_id: str, + *, + custom_coordinates: Optional[str] | Omit = omit, + custom_metadata: Dict[str, object] | Omit = omit, + description: str | Omit = omit, + extensions: Extensions | Omit = omit, + remove_ai_tags: Union[SequenceNotStr[str], Literal["all"]] | Omit = omit, + tags: Optional[SequenceNotStr[str]] | Omit = omit, + webhook_url: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileUpdateResponse: + """ + This API updates the details or attributes of the current version of the file. + You can update `tags`, `customCoordinates`, `customMetadata`, publication + status, remove existing `AITags` and apply extensions using this API. + + Args: + custom_coordinates: Define an important area in the image in the format `x,y,width,height` e.g. + `10,10,100,100`. Send `null` to unset this value. + + custom_metadata: A key-value data to be associated with the asset. To unset a key, send `null` + value for that key. Before setting any custom metadata on an asset you have to + create the field using custom metadata fields API. + + description: Optional text to describe the contents of the file. + + extensions: Array of extensions to be applied to the asset. Each extension can be configured + with specific parameters based on the extension type. + + remove_ai_tags: An array of AITags associated with the file that you want to remove, e.g. + `["car", "vehicle", "motorsports"]`. + + If you want to remove all AITags associated with the file, send a string - + "all". + + Note: The remove operation for `AITags` executes before any of the `extensions` + are processed. + + tags: An array of tags associated with the file, such as `["tag1", "tag2"]`. Send + `null` to unset all tags associated with the file. + + webhook_url: The final status of extensions after they have completed execution will be + delivered to this endpoint as a POST request. + [Learn more](/docs/api-reference/digital-asset-management-dam/managing-assets/update-file-details#webhook-payload-structure) + about the webhook payload structure. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + def update( + self, + file_id: str, + *, + publish: file_update_params.ChangePublicationStatusPublish | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileUpdateResponse: + """ + This API updates the details or attributes of the current version of the file. + You can update `tags`, `customCoordinates`, `customMetadata`, publication + status, remove existing `AITags` and apply extensions using this API. + + Args: + publish: Configure the publication status of a file and its versions. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + def update( + self, + file_id: str, + *, + custom_coordinates: Optional[str] | Omit = omit, + custom_metadata: Dict[str, object] | Omit = omit, + description: str | Omit = omit, + extensions: Extensions | Omit = omit, + remove_ai_tags: Union[SequenceNotStr[str], Literal["all"]] | Omit = omit, + tags: Optional[SequenceNotStr[str]] | Omit = omit, + webhook_url: str | Omit = omit, + publish: file_update_params.ChangePublicationStatusPublish | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileUpdateResponse: + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + return self._patch( + f"/v1/files/{file_id}/details", + body=maybe_transform( + { + "custom_coordinates": custom_coordinates, + "custom_metadata": custom_metadata, + "description": description, + "extensions": extensions, + "remove_ai_tags": remove_ai_tags, + "tags": tags, + "webhook_url": webhook_url, + "publish": publish, + }, + file_update_params.FileUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileUpdateResponse, + ) + + def delete( + self, + file_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """ + This API deletes the file and all its file versions permanently. + + Note: If a file or specific transformation has been requested in the past, then + the response is cached. Deleting a file does not purge the cache. You can purge + the cache using purge cache API. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + f"/v1/files/{file_id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def copy( + self, + *, + destination_path: str, + source_file_path: str, + include_file_versions: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileCopyResponse: + """ + This will copy a file from one folder to another. + + Note: If any file at the destination has the same name as the source file, then + the source file and its versions (if `includeFileVersions` is set to true) will + be appended to the destination file version history. + + Args: + destination_path: Full path to the folder you want to copy the above file into. + + source_file_path: The full path of the file you want to copy. + + include_file_versions: Option to copy all versions of a file. By default, only the current version of + the file is copied. When set to true, all versions of the file will be copied. + Default value - `false`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/files/copy", + body=maybe_transform( + { + "destination_path": destination_path, + "source_file_path": source_file_path, + "include_file_versions": include_file_versions, + }, + file_copy_params.FileCopyParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileCopyResponse, + ) + + def get( + self, + file_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> File: + """ + This API returns an object with details or attributes about the current version + of the file. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + return self._get( + f"/v1/files/{file_id}/details", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=File, + ) + + def move( + self, + *, + destination_path: str, + source_file_path: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileMoveResponse: + """ + This will move a file and all its versions from one folder to another. + + Note: If any file at the destination has the same name as the source file, then + the source file and its versions will be appended to the destination file. + + Args: + destination_path: Full path to the folder you want to move the above file into. + + source_file_path: The full path of the file you want to move. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/files/move", + body=maybe_transform( + { + "destination_path": destination_path, + "source_file_path": source_file_path, + }, + file_move_params.FileMoveParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileMoveResponse, + ) + + def rename( + self, + *, + file_path: str, + new_file_name: str, + purge_cache: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileRenameResponse: + """ + You can rename an already existing file in the media library using rename file + API. This operation would rename all file versions of the file. + + Note: The old URLs will stop working. The file/file version URLs cached on CDN + will continue to work unless a purge is requested. + + Args: + file_path: The full path of the file you want to rename. + + new_file_name: + The new name of the file. A filename can contain: + + Alphanumeric Characters: `a-z`, `A-Z`, `0-9` (including Unicode letters, marks, + and numerals in other languages). Special Characters: `.`, `_`, and `-`. + + Any other character, including space, will be replaced by `_`. + + purge_cache: Option to purge cache for the old file and its versions' URLs. + + When set to true, it will internally issue a purge cache request on CDN to + remove cached content of old file and its versions. This purge request is + counted against your monthly purge quota. + + Note: If the old file were accessible at + `https://ik.imagekit.io/demo/old-filename.jpg`, a purge cache request would be + issued against `https://ik.imagekit.io/demo/old-filename.jpg*` (with a wildcard + at the end). It will remove the file and its versions' URLs and any + transformations made using query parameters on this file or its versions. + However, the cache for file transformations made using path parameters will + persist. You can purge them using the purge API. For more details, refer to the + purge API documentation. + + Default value - `false` + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._put( + "/v1/files/rename", + body=maybe_transform( + { + "file_path": file_path, + "new_file_name": new_file_name, + "purge_cache": purge_cache, + }, + file_rename_params.FileRenameParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileRenameResponse, + ) + + def upload( + self, + *, + file: FileTypes, + file_name: str, + token: str | Omit = omit, + checks: str | Omit = omit, + custom_coordinates: str | Omit = omit, + custom_metadata: Dict[str, object] | Omit = omit, + description: str | Omit = omit, + expire: int | Omit = omit, + extensions: Extensions | Omit = omit, + folder: str | Omit = omit, + is_private_file: bool | Omit = omit, + is_published: bool | Omit = omit, + overwrite_ai_tags: bool | Omit = omit, + overwrite_custom_metadata: bool | Omit = omit, + overwrite_file: bool | Omit = omit, + overwrite_tags: bool | Omit = omit, + public_key: str | Omit = omit, + response_fields: List[ + Literal[ + "tags", + "customCoordinates", + "isPrivateFile", + "embeddedMetadata", + "isPublished", + "customMetadata", + "metadata", + "selectedFieldsSchema", + ] + ] + | Omit = omit, + signature: str | Omit = omit, + tags: SequenceNotStr[str] | Omit = omit, + transformation: file_upload_params.Transformation | Omit = omit, + use_unique_file_name: bool | Omit = omit, + webhook_url: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileUploadResponse: + """ + ImageKit.io allows you to upload files directly from both the server and client + sides. For server-side uploads, private API key authentication is used. For + client-side uploads, generate a one-time `token`, `signature`, and `expire` from + your secure backend using private API. + [Learn more](/docs/api-reference/upload-file/upload-file#how-to-implement-client-side-file-upload) + about how to implement client-side file upload. + + The [V2 API](/docs/api-reference/upload-file/upload-file-v2) enhances security + by verifying the entire payload using JWT. + + **File size limit** \\ + On the free plan, the maximum upload file sizes are 20MB for images, audio, and raw + files and 100MB for videos. On the paid plan, these limits increase to 40MB for images, + audio, and raw files and 2GB for videos. These limits can be further increased with + higher-tier plans. + + **Version limit** \\ + A file can have a maximum of 100 versions. + + **Demo applications** + + - A full-fledged + [upload widget using Uppy](https://github.com/imagekit-samples/uppy-uploader), + supporting file selections from local storage, URL, Dropbox, Google Drive, + Instagram, and more. + - [Quick start guides](/docs/quick-start-guides) for various frameworks and + technologies. + + Args: + file: + The API accepts any of the following: + + - **Binary data** – send the raw bytes as `multipart/form-data`. + - **HTTP / HTTPS URL** – a publicly reachable URL that ImageKit’s servers can + fetch. + - **Base64 string** – the file encoded as a Base64 data URI or plain Base64. + + When supplying a URL, the server must receive the response headers within 8 + seconds; otherwise the request fails with 400 Bad Request. + + file_name: + The name with which the file has to be uploaded. The file name can contain: + + - Alphanumeric Characters: `a-z`, `A-Z`, `0-9`. + - Special Characters: `.`, `-` + + Any other character including space will be replaced by `_` + + token: A unique value that the ImageKit.io server will use to recognize and prevent + subsequent retries for the same request. We suggest using V4 UUIDs, or another + random string with enough entropy to avoid collisions. This field is only + required for authentication when uploading a file from the client side. + + **Note**: Sending a value that has been used in the past will result in a + validation error. Even if your previous request resulted in an error, you should + always send a new value for this field. + + checks: Server-side checks to run on the asset. Read more about + [Upload API checks](/docs/api-reference/upload-file/upload-file#upload-api-checks). + + custom_coordinates: Define an important area in the image. This is only relevant for image type + files. + + - To be passed as a string with the x and y coordinates of the top-left corner, + and width and height of the area of interest in the format `x,y,width,height`. + For example - `10,10,100,100` + - Can be used with fo-customtransformation. + - If this field is not specified and the file is overwritten, then + customCoordinates will be removed. + + custom_metadata: JSON key-value pairs to associate with the asset. Create the custom metadata + fields before setting these values. + + description: Optional text to describe the contents of the file. + + expire: The time until your signature is valid. It must be a + [Unix time](https://en.wikipedia.org/wiki/Unix_time) in less than 1 hour into + the future. It should be in seconds. This field is only required for + authentication when uploading a file from the client side. + + extensions: Array of extensions to be applied to the asset. Each extension can be configured + with specific parameters based on the extension type. + + folder: The folder path in which the image has to be uploaded. If the folder(s) didn't + exist before, a new folder(s) is created. + + The folder name can contain: + + - Alphanumeric Characters: `a-z` , `A-Z` , `0-9` + - Special Characters: `/` , `_` , `-` + + Using multiple `/` creates a nested folder. + + is_private_file: Whether to mark the file as private or not. + + If `true`, the file is marked as private and is accessible only using named + transformation or signed URL. + + is_published: Whether to upload file as published or not. + + If `false`, the file is marked as unpublished, which restricts access to the + file only via the media library. Files in draft or unpublished state can only be + publicly accessed after being published. + + The option to upload in draft state is only available in custom enterprise + pricing plans. + + overwrite_ai_tags: If set to `true` and a file already exists at the exact location, its AITags + will be removed. Set `overwriteAITags` to `false` to preserve AITags. + + overwrite_custom_metadata: If the request does not have `customMetadata`, and a file already exists at the + exact location, existing customMetadata will be removed. + + overwrite_file: If `false` and `useUniqueFileName` is also `false`, and a file already exists at + the exact location, upload API will return an error immediately. + + overwrite_tags: If the request does not have `tags`, and a file already exists at the exact + location, existing tags will be removed. + + public_key: Your ImageKit.io public key. This field is only required for authentication when + uploading a file from the client side. + + response_fields: Array of response field keys to include in the API response body. + + signature: HMAC-SHA1 digest of the token+expire using your ImageKit.io private API key as a + key. Learn how to create a signature on the page below. This should be in + lowercase. + + Signature must be calculated on the server-side. This field is only required for + authentication when uploading a file from the client side. + + tags: Set the tags while uploading the file. Provide an array of tag strings (e.g. + `["tag1", "tag2", "tag3"]`). The combined length of all tag characters must not + exceed 500, and the `%` character is not allowed. If this field is not specified + and the file is overwritten, the existing tags will be removed. + + transformation: Configure pre-processing (`pre`) and post-processing (`post`) transformations. + + - `pre` — applied before the file is uploaded to the Media Library. + Useful for reducing file size or applying basic optimizations upfront (e.g., + resize, compress). + + - `post` — applied immediately after upload. + Ideal for generating transformed versions (like video encodes or thumbnails) + in advance, so they're ready for delivery without delay. + + You can mix and match any combination of post-processing types. + + use_unique_file_name: Whether to use a unique filename for this file or not. + + If `true`, ImageKit.io will add a unique suffix to the filename parameter to get + a unique filename. + + If `false`, then the image is uploaded with the provided filename parameter, and + any existing file with the same name is replaced. + + webhook_url: The final status of extensions after they have completed execution will be + delivered to this endpoint as a POST request. + [Learn more](/docs/api-reference/digital-asset-management-dam/managing-assets/update-file-details#webhook-payload-structure) + about the webhook payload structure. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + body = deepcopy_minimal( + { + "file": file, + "file_name": file_name, + "token": token, + "checks": checks, + "custom_coordinates": custom_coordinates, + "custom_metadata": custom_metadata, + "description": description, + "expire": expire, + "extensions": extensions, + "folder": folder, + "is_private_file": is_private_file, + "is_published": is_published, + "overwrite_ai_tags": overwrite_ai_tags, + "overwrite_custom_metadata": overwrite_custom_metadata, + "overwrite_file": overwrite_file, + "overwrite_tags": overwrite_tags, + "public_key": public_key, + "response_fields": response_fields, + "signature": signature, + "tags": tags, + "transformation": transformation, + "use_unique_file_name": use_unique_file_name, + "webhook_url": webhook_url, + } + ) + body = serialize_upload_options(body) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return self._post( + "/api/v1/files/upload" + if self._client._base_url_overridden + else "https://upload.imagekit.io/api/v1/files/upload", + body=maybe_transform(body, file_upload_params.FileUploadParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileUploadResponse, + ) + + +class AsyncFilesResource(AsyncAPIResource): + @cached_property + def bulk(self) -> AsyncBulkResource: + return AsyncBulkResource(self._client) + + @cached_property + def versions(self) -> AsyncVersionsResource: + return AsyncVersionsResource(self._client) + + @cached_property + def metadata(self) -> AsyncMetadataResource: + return AsyncMetadataResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncFilesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncFilesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncFilesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncFilesResourceWithStreamingResponse(self) + + @overload + async def update( + self, + file_id: str, + *, + custom_coordinates: Optional[str] | Omit = omit, + custom_metadata: Dict[str, object] | Omit = omit, + description: str | Omit = omit, + extensions: Extensions | Omit = omit, + remove_ai_tags: Union[SequenceNotStr[str], Literal["all"]] | Omit = omit, + tags: Optional[SequenceNotStr[str]] | Omit = omit, + webhook_url: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileUpdateResponse: + """ + This API updates the details or attributes of the current version of the file. + You can update `tags`, `customCoordinates`, `customMetadata`, publication + status, remove existing `AITags` and apply extensions using this API. + + Args: + custom_coordinates: Define an important area in the image in the format `x,y,width,height` e.g. + `10,10,100,100`. Send `null` to unset this value. + + custom_metadata: A key-value data to be associated with the asset. To unset a key, send `null` + value for that key. Before setting any custom metadata on an asset you have to + create the field using custom metadata fields API. + + description: Optional text to describe the contents of the file. + + extensions: Array of extensions to be applied to the asset. Each extension can be configured + with specific parameters based on the extension type. + + remove_ai_tags: An array of AITags associated with the file that you want to remove, e.g. + `["car", "vehicle", "motorsports"]`. + + If you want to remove all AITags associated with the file, send a string - + "all". + + Note: The remove operation for `AITags` executes before any of the `extensions` + are processed. + + tags: An array of tags associated with the file, such as `["tag1", "tag2"]`. Send + `null` to unset all tags associated with the file. + + webhook_url: The final status of extensions after they have completed execution will be + delivered to this endpoint as a POST request. + [Learn more](/docs/api-reference/digital-asset-management-dam/managing-assets/update-file-details#webhook-payload-structure) + about the webhook payload structure. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @overload + async def update( + self, + file_id: str, + *, + publish: file_update_params.ChangePublicationStatusPublish | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileUpdateResponse: + """ + This API updates the details or attributes of the current version of the file. + You can update `tags`, `customCoordinates`, `customMetadata`, publication + status, remove existing `AITags` and apply extensions using this API. + + Args: + publish: Configure the publication status of a file and its versions. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + async def update( + self, + file_id: str, + *, + custom_coordinates: Optional[str] | Omit = omit, + custom_metadata: Dict[str, object] | Omit = omit, + description: str | Omit = omit, + extensions: Extensions | Omit = omit, + remove_ai_tags: Union[SequenceNotStr[str], Literal["all"]] | Omit = omit, + tags: Optional[SequenceNotStr[str]] | Omit = omit, + webhook_url: str | Omit = omit, + publish: file_update_params.ChangePublicationStatusPublish | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileUpdateResponse: + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + return await self._patch( + f"/v1/files/{file_id}/details", + body=await async_maybe_transform( + { + "custom_coordinates": custom_coordinates, + "custom_metadata": custom_metadata, + "description": description, + "extensions": extensions, + "remove_ai_tags": remove_ai_tags, + "tags": tags, + "webhook_url": webhook_url, + "publish": publish, + }, + file_update_params.FileUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileUpdateResponse, + ) + + async def delete( + self, + file_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """ + This API deletes the file and all its file versions permanently. + + Note: If a file or specific transformation has been requested in the past, then + the response is cached. Deleting a file does not purge the cache. You can purge + the cache using purge cache API. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + f"/v1/files/{file_id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def copy( + self, + *, + destination_path: str, + source_file_path: str, + include_file_versions: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileCopyResponse: + """ + This will copy a file from one folder to another. + + Note: If any file at the destination has the same name as the source file, then + the source file and its versions (if `includeFileVersions` is set to true) will + be appended to the destination file version history. + + Args: + destination_path: Full path to the folder you want to copy the above file into. + + source_file_path: The full path of the file you want to copy. + + include_file_versions: Option to copy all versions of a file. By default, only the current version of + the file is copied. When set to true, all versions of the file will be copied. + Default value - `false`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/files/copy", + body=await async_maybe_transform( + { + "destination_path": destination_path, + "source_file_path": source_file_path, + "include_file_versions": include_file_versions, + }, + file_copy_params.FileCopyParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileCopyResponse, + ) + + async def get( + self, + file_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> File: + """ + This API returns an object with details or attributes about the current version + of the file. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + return await self._get( + f"/v1/files/{file_id}/details", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=File, + ) + + async def move( + self, + *, + destination_path: str, + source_file_path: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileMoveResponse: + """ + This will move a file and all its versions from one folder to another. + + Note: If any file at the destination has the same name as the source file, then + the source file and its versions will be appended to the destination file. + + Args: + destination_path: Full path to the folder you want to move the above file into. + + source_file_path: The full path of the file you want to move. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/files/move", + body=await async_maybe_transform( + { + "destination_path": destination_path, + "source_file_path": source_file_path, + }, + file_move_params.FileMoveParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileMoveResponse, + ) + + async def rename( + self, + *, + file_path: str, + new_file_name: str, + purge_cache: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileRenameResponse: + """ + You can rename an already existing file in the media library using rename file + API. This operation would rename all file versions of the file. + + Note: The old URLs will stop working. The file/file version URLs cached on CDN + will continue to work unless a purge is requested. + + Args: + file_path: The full path of the file you want to rename. + + new_file_name: + The new name of the file. A filename can contain: + + Alphanumeric Characters: `a-z`, `A-Z`, `0-9` (including Unicode letters, marks, + and numerals in other languages). Special Characters: `.`, `_`, and `-`. + + Any other character, including space, will be replaced by `_`. + + purge_cache: Option to purge cache for the old file and its versions' URLs. + + When set to true, it will internally issue a purge cache request on CDN to + remove cached content of old file and its versions. This purge request is + counted against your monthly purge quota. + + Note: If the old file were accessible at + `https://ik.imagekit.io/demo/old-filename.jpg`, a purge cache request would be + issued against `https://ik.imagekit.io/demo/old-filename.jpg*` (with a wildcard + at the end). It will remove the file and its versions' URLs and any + transformations made using query parameters on this file or its versions. + However, the cache for file transformations made using path parameters will + persist. You can purge them using the purge API. For more details, refer to the + purge API documentation. + + Default value - `false` + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._put( + "/v1/files/rename", + body=await async_maybe_transform( + { + "file_path": file_path, + "new_file_name": new_file_name, + "purge_cache": purge_cache, + }, + file_rename_params.FileRenameParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileRenameResponse, + ) + + async def upload( + self, + *, + file: FileTypes, + file_name: str, + token: str | Omit = omit, + checks: str | Omit = omit, + custom_coordinates: str | Omit = omit, + custom_metadata: Dict[str, object] | Omit = omit, + description: str | Omit = omit, + expire: int | Omit = omit, + extensions: Extensions | Omit = omit, + folder: str | Omit = omit, + is_private_file: bool | Omit = omit, + is_published: bool | Omit = omit, + overwrite_ai_tags: bool | Omit = omit, + overwrite_custom_metadata: bool | Omit = omit, + overwrite_file: bool | Omit = omit, + overwrite_tags: bool | Omit = omit, + public_key: str | Omit = omit, + response_fields: List[ + Literal[ + "tags", + "customCoordinates", + "isPrivateFile", + "embeddedMetadata", + "isPublished", + "customMetadata", + "metadata", + "selectedFieldsSchema", + ] + ] + | Omit = omit, + signature: str | Omit = omit, + tags: SequenceNotStr[str] | Omit = omit, + transformation: file_upload_params.Transformation | Omit = omit, + use_unique_file_name: bool | Omit = omit, + webhook_url: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FileUploadResponse: + """ + ImageKit.io allows you to upload files directly from both the server and client + sides. For server-side uploads, private API key authentication is used. For + client-side uploads, generate a one-time `token`, `signature`, and `expire` from + your secure backend using private API. + [Learn more](/docs/api-reference/upload-file/upload-file#how-to-implement-client-side-file-upload) + about how to implement client-side file upload. + + The [V2 API](/docs/api-reference/upload-file/upload-file-v2) enhances security + by verifying the entire payload using JWT. + + **File size limit** \\ + On the free plan, the maximum upload file sizes are 20MB for images, audio, and raw + files and 100MB for videos. On the paid plan, these limits increase to 40MB for images, + audio, and raw files and 2GB for videos. These limits can be further increased with + higher-tier plans. + + **Version limit** \\ + A file can have a maximum of 100 versions. + + **Demo applications** + + - A full-fledged + [upload widget using Uppy](https://github.com/imagekit-samples/uppy-uploader), + supporting file selections from local storage, URL, Dropbox, Google Drive, + Instagram, and more. + - [Quick start guides](/docs/quick-start-guides) for various frameworks and + technologies. + + Args: + file: + The API accepts any of the following: + + - **Binary data** – send the raw bytes as `multipart/form-data`. + - **HTTP / HTTPS URL** – a publicly reachable URL that ImageKit’s servers can + fetch. + - **Base64 string** – the file encoded as a Base64 data URI or plain Base64. + + When supplying a URL, the server must receive the response headers within 8 + seconds; otherwise the request fails with 400 Bad Request. + + file_name: + The name with which the file has to be uploaded. The file name can contain: + + - Alphanumeric Characters: `a-z`, `A-Z`, `0-9`. + - Special Characters: `.`, `-` + + Any other character including space will be replaced by `_` + + token: A unique value that the ImageKit.io server will use to recognize and prevent + subsequent retries for the same request. We suggest using V4 UUIDs, or another + random string with enough entropy to avoid collisions. This field is only + required for authentication when uploading a file from the client side. + + **Note**: Sending a value that has been used in the past will result in a + validation error. Even if your previous request resulted in an error, you should + always send a new value for this field. + + checks: Server-side checks to run on the asset. Read more about + [Upload API checks](/docs/api-reference/upload-file/upload-file#upload-api-checks). + + custom_coordinates: Define an important area in the image. This is only relevant for image type + files. + + - To be passed as a string with the x and y coordinates of the top-left corner, + and width and height of the area of interest in the format `x,y,width,height`. + For example - `10,10,100,100` + - Can be used with fo-customtransformation. + - If this field is not specified and the file is overwritten, then + customCoordinates will be removed. + + custom_metadata: JSON key-value pairs to associate with the asset. Create the custom metadata + fields before setting these values. + + description: Optional text to describe the contents of the file. + + expire: The time until your signature is valid. It must be a + [Unix time](https://en.wikipedia.org/wiki/Unix_time) in less than 1 hour into + the future. It should be in seconds. This field is only required for + authentication when uploading a file from the client side. + + extensions: Array of extensions to be applied to the asset. Each extension can be configured + with specific parameters based on the extension type. + + folder: The folder path in which the image has to be uploaded. If the folder(s) didn't + exist before, a new folder(s) is created. + + The folder name can contain: + + - Alphanumeric Characters: `a-z` , `A-Z` , `0-9` + - Special Characters: `/` , `_` , `-` + + Using multiple `/` creates a nested folder. + + is_private_file: Whether to mark the file as private or not. + + If `true`, the file is marked as private and is accessible only using named + transformation or signed URL. + + is_published: Whether to upload file as published or not. + + If `false`, the file is marked as unpublished, which restricts access to the + file only via the media library. Files in draft or unpublished state can only be + publicly accessed after being published. + + The option to upload in draft state is only available in custom enterprise + pricing plans. + + overwrite_ai_tags: If set to `true` and a file already exists at the exact location, its AITags + will be removed. Set `overwriteAITags` to `false` to preserve AITags. + + overwrite_custom_metadata: If the request does not have `customMetadata`, and a file already exists at the + exact location, existing customMetadata will be removed. + + overwrite_file: If `false` and `useUniqueFileName` is also `false`, and a file already exists at + the exact location, upload API will return an error immediately. + + overwrite_tags: If the request does not have `tags`, and a file already exists at the exact + location, existing tags will be removed. + + public_key: Your ImageKit.io public key. This field is only required for authentication when + uploading a file from the client side. + + response_fields: Array of response field keys to include in the API response body. + + signature: HMAC-SHA1 digest of the token+expire using your ImageKit.io private API key as a + key. Learn how to create a signature on the page below. This should be in + lowercase. + + Signature must be calculated on the server-side. This field is only required for + authentication when uploading a file from the client side. + + tags: Set the tags while uploading the file. Provide an array of tag strings (e.g. + `["tag1", "tag2", "tag3"]`). The combined length of all tag characters must not + exceed 500, and the `%` character is not allowed. If this field is not specified + and the file is overwritten, the existing tags will be removed. + + transformation: Configure pre-processing (`pre`) and post-processing (`post`) transformations. + + - `pre` — applied before the file is uploaded to the Media Library. + Useful for reducing file size or applying basic optimizations upfront (e.g., + resize, compress). + + - `post` — applied immediately after upload. + Ideal for generating transformed versions (like video encodes or thumbnails) + in advance, so they're ready for delivery without delay. + + You can mix and match any combination of post-processing types. + + use_unique_file_name: Whether to use a unique filename for this file or not. + + If `true`, ImageKit.io will add a unique suffix to the filename parameter to get + a unique filename. + + If `false`, then the image is uploaded with the provided filename parameter, and + any existing file with the same name is replaced. + + webhook_url: The final status of extensions after they have completed execution will be + delivered to this endpoint as a POST request. + [Learn more](/docs/api-reference/digital-asset-management-dam/managing-assets/update-file-details#webhook-payload-structure) + about the webhook payload structure. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + body = deepcopy_minimal( + { + "file": file, + "file_name": file_name, + "token": token, + "checks": checks, + "custom_coordinates": custom_coordinates, + "custom_metadata": custom_metadata, + "description": description, + "expire": expire, + "extensions": extensions, + "folder": folder, + "is_private_file": is_private_file, + "is_published": is_published, + "overwrite_ai_tags": overwrite_ai_tags, + "overwrite_custom_metadata": overwrite_custom_metadata, + "overwrite_file": overwrite_file, + "overwrite_tags": overwrite_tags, + "public_key": public_key, + "response_fields": response_fields, + "signature": signature, + "tags": tags, + "transformation": transformation, + "use_unique_file_name": use_unique_file_name, + "webhook_url": webhook_url, + } + ) + body = serialize_upload_options(body) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return await self._post( + "/api/v1/files/upload" + if self._client._base_url_overridden + else "https://upload.imagekit.io/api/v1/files/upload", + body=await async_maybe_transform(body, file_upload_params.FileUploadParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FileUploadResponse, + ) + + +class FilesResourceWithRawResponse: + def __init__(self, files: FilesResource) -> None: + self._files = files + + self.update = to_raw_response_wrapper( + files.update, + ) + self.delete = to_raw_response_wrapper( + files.delete, + ) + self.copy = to_raw_response_wrapper( + files.copy, + ) + self.get = to_raw_response_wrapper( + files.get, + ) + self.move = to_raw_response_wrapper( + files.move, + ) + self.rename = to_raw_response_wrapper( + files.rename, + ) + self.upload = to_raw_response_wrapper( + files.upload, + ) + + @cached_property + def bulk(self) -> BulkResourceWithRawResponse: + return BulkResourceWithRawResponse(self._files.bulk) + + @cached_property + def versions(self) -> VersionsResourceWithRawResponse: + return VersionsResourceWithRawResponse(self._files.versions) + + @cached_property + def metadata(self) -> MetadataResourceWithRawResponse: + return MetadataResourceWithRawResponse(self._files.metadata) + + +class AsyncFilesResourceWithRawResponse: + def __init__(self, files: AsyncFilesResource) -> None: + self._files = files + + self.update = async_to_raw_response_wrapper( + files.update, + ) + self.delete = async_to_raw_response_wrapper( + files.delete, + ) + self.copy = async_to_raw_response_wrapper( + files.copy, + ) + self.get = async_to_raw_response_wrapper( + files.get, + ) + self.move = async_to_raw_response_wrapper( + files.move, + ) + self.rename = async_to_raw_response_wrapper( + files.rename, + ) + self.upload = async_to_raw_response_wrapper( + files.upload, + ) + + @cached_property + def bulk(self) -> AsyncBulkResourceWithRawResponse: + return AsyncBulkResourceWithRawResponse(self._files.bulk) + + @cached_property + def versions(self) -> AsyncVersionsResourceWithRawResponse: + return AsyncVersionsResourceWithRawResponse(self._files.versions) + + @cached_property + def metadata(self) -> AsyncMetadataResourceWithRawResponse: + return AsyncMetadataResourceWithRawResponse(self._files.metadata) + + +class FilesResourceWithStreamingResponse: + def __init__(self, files: FilesResource) -> None: + self._files = files + + self.update = to_streamed_response_wrapper( + files.update, + ) + self.delete = to_streamed_response_wrapper( + files.delete, + ) + self.copy = to_streamed_response_wrapper( + files.copy, + ) + self.get = to_streamed_response_wrapper( + files.get, + ) + self.move = to_streamed_response_wrapper( + files.move, + ) + self.rename = to_streamed_response_wrapper( + files.rename, + ) + self.upload = to_streamed_response_wrapper( + files.upload, + ) + + @cached_property + def bulk(self) -> BulkResourceWithStreamingResponse: + return BulkResourceWithStreamingResponse(self._files.bulk) + + @cached_property + def versions(self) -> VersionsResourceWithStreamingResponse: + return VersionsResourceWithStreamingResponse(self._files.versions) + + @cached_property + def metadata(self) -> MetadataResourceWithStreamingResponse: + return MetadataResourceWithStreamingResponse(self._files.metadata) + + +class AsyncFilesResourceWithStreamingResponse: + def __init__(self, files: AsyncFilesResource) -> None: + self._files = files + + self.update = async_to_streamed_response_wrapper( + files.update, + ) + self.delete = async_to_streamed_response_wrapper( + files.delete, + ) + self.copy = async_to_streamed_response_wrapper( + files.copy, + ) + self.get = async_to_streamed_response_wrapper( + files.get, + ) + self.move = async_to_streamed_response_wrapper( + files.move, + ) + self.rename = async_to_streamed_response_wrapper( + files.rename, + ) + self.upload = async_to_streamed_response_wrapper( + files.upload, + ) + + @cached_property + def bulk(self) -> AsyncBulkResourceWithStreamingResponse: + return AsyncBulkResourceWithStreamingResponse(self._files.bulk) + + @cached_property + def versions(self) -> AsyncVersionsResourceWithStreamingResponse: + return AsyncVersionsResourceWithStreamingResponse(self._files.versions) + + @cached_property + def metadata(self) -> AsyncMetadataResourceWithStreamingResponse: + return AsyncMetadataResourceWithStreamingResponse(self._files.metadata) diff --git a/src/imagekitio/resources/files/metadata.py b/src/imagekitio/resources/files/metadata.py new file mode 100644 index 00000000..1648e6ea --- /dev/null +++ b/src/imagekitio/resources/files/metadata.py @@ -0,0 +1,263 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...types.files import metadata_get_from_url_params +from ..._base_client import make_request_options +from ...types.metadata import Metadata + +__all__ = ["MetadataResource", "AsyncMetadataResource"] + + +class MetadataResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> MetadataResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return MetadataResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> MetadataResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return MetadataResourceWithStreamingResponse(self) + + def get( + self, + file_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Metadata: + """ + You can programmatically get image EXIF, pHash, and other metadata for uploaded + files in the ImageKit.io media library using this API. + + You can also get the metadata in upload API response by passing `metadata` in + `responseFields` parameter. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + return self._get( + f"/v1/files/{file_id}/metadata", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Metadata, + ) + + def get_from_url( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Metadata: + """ + Get image EXIF, pHash, and other metadata from ImageKit.io powered remote URL + using this API. + + Args: + url: Should be a valid file URL. It should be accessible using your ImageKit.io + account. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/v1/files/metadata", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"url": url}, metadata_get_from_url_params.MetadataGetFromURLParams), + ), + cast_to=Metadata, + ) + + +class AsyncMetadataResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncMetadataResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncMetadataResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncMetadataResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncMetadataResourceWithStreamingResponse(self) + + async def get( + self, + file_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Metadata: + """ + You can programmatically get image EXIF, pHash, and other metadata for uploaded + files in the ImageKit.io media library using this API. + + You can also get the metadata in upload API response by passing `metadata` in + `responseFields` parameter. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + return await self._get( + f"/v1/files/{file_id}/metadata", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Metadata, + ) + + async def get_from_url( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Metadata: + """ + Get image EXIF, pHash, and other metadata from ImageKit.io powered remote URL + using this API. + + Args: + url: Should be a valid file URL. It should be accessible using your ImageKit.io + account. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/v1/files/metadata", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"url": url}, metadata_get_from_url_params.MetadataGetFromURLParams), + ), + cast_to=Metadata, + ) + + +class MetadataResourceWithRawResponse: + def __init__(self, metadata: MetadataResource) -> None: + self._metadata = metadata + + self.get = to_raw_response_wrapper( + metadata.get, + ) + self.get_from_url = to_raw_response_wrapper( + metadata.get_from_url, + ) + + +class AsyncMetadataResourceWithRawResponse: + def __init__(self, metadata: AsyncMetadataResource) -> None: + self._metadata = metadata + + self.get = async_to_raw_response_wrapper( + metadata.get, + ) + self.get_from_url = async_to_raw_response_wrapper( + metadata.get_from_url, + ) + + +class MetadataResourceWithStreamingResponse: + def __init__(self, metadata: MetadataResource) -> None: + self._metadata = metadata + + self.get = to_streamed_response_wrapper( + metadata.get, + ) + self.get_from_url = to_streamed_response_wrapper( + metadata.get_from_url, + ) + + +class AsyncMetadataResourceWithStreamingResponse: + def __init__(self, metadata: AsyncMetadataResource) -> None: + self._metadata = metadata + + self.get = async_to_streamed_response_wrapper( + metadata.get, + ) + self.get_from_url = async_to_streamed_response_wrapper( + metadata.get_from_url, + ) diff --git a/src/imagekitio/resources/files/versions.py b/src/imagekitio/resources/files/versions.py new file mode 100644 index 00000000..b2df5cb5 --- /dev/null +++ b/src/imagekitio/resources/files/versions.py @@ -0,0 +1,425 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...types.file import File +from ..._base_client import make_request_options +from ...types.files.version_list_response import VersionListResponse +from ...types.files.version_delete_response import VersionDeleteResponse + +__all__ = ["VersionsResource", "AsyncVersionsResource"] + + +class VersionsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> VersionsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return VersionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> VersionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return VersionsResourceWithStreamingResponse(self) + + def list( + self, + file_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> VersionListResponse: + """ + This API returns details of all versions of a file. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + return self._get( + f"/v1/files/{file_id}/versions", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=VersionListResponse, + ) + + def delete( + self, + version_id: str, + *, + file_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> VersionDeleteResponse: + """This API deletes a non-current file version permanently. + + The API returns an + empty response. + + Note: If you want to delete all versions of a file, use the delete file API. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + if not version_id: + raise ValueError(f"Expected a non-empty value for `version_id` but received {version_id!r}") + return self._delete( + f"/v1/files/{file_id}/versions/{version_id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=VersionDeleteResponse, + ) + + def get( + self, + version_id: str, + *, + file_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> File: + """ + This API returns an object with details or attributes of a file version. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + if not version_id: + raise ValueError(f"Expected a non-empty value for `version_id` but received {version_id!r}") + return self._get( + f"/v1/files/{file_id}/versions/{version_id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=File, + ) + + def restore( + self, + version_id: str, + *, + file_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> File: + """ + This API restores a file version as the current file version. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + if not version_id: + raise ValueError(f"Expected a non-empty value for `version_id` but received {version_id!r}") + return self._put( + f"/v1/files/{file_id}/versions/{version_id}/restore", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=File, + ) + + +class AsyncVersionsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncVersionsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncVersionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncVersionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncVersionsResourceWithStreamingResponse(self) + + async def list( + self, + file_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> VersionListResponse: + """ + This API returns details of all versions of a file. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + return await self._get( + f"/v1/files/{file_id}/versions", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=VersionListResponse, + ) + + async def delete( + self, + version_id: str, + *, + file_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> VersionDeleteResponse: + """This API deletes a non-current file version permanently. + + The API returns an + empty response. + + Note: If you want to delete all versions of a file, use the delete file API. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + if not version_id: + raise ValueError(f"Expected a non-empty value for `version_id` but received {version_id!r}") + return await self._delete( + f"/v1/files/{file_id}/versions/{version_id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=VersionDeleteResponse, + ) + + async def get( + self, + version_id: str, + *, + file_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> File: + """ + This API returns an object with details or attributes of a file version. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + if not version_id: + raise ValueError(f"Expected a non-empty value for `version_id` but received {version_id!r}") + return await self._get( + f"/v1/files/{file_id}/versions/{version_id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=File, + ) + + async def restore( + self, + version_id: str, + *, + file_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> File: + """ + This API restores a file version as the current file version. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not file_id: + raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}") + if not version_id: + raise ValueError(f"Expected a non-empty value for `version_id` but received {version_id!r}") + return await self._put( + f"/v1/files/{file_id}/versions/{version_id}/restore", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=File, + ) + + +class VersionsResourceWithRawResponse: + def __init__(self, versions: VersionsResource) -> None: + self._versions = versions + + self.list = to_raw_response_wrapper( + versions.list, + ) + self.delete = to_raw_response_wrapper( + versions.delete, + ) + self.get = to_raw_response_wrapper( + versions.get, + ) + self.restore = to_raw_response_wrapper( + versions.restore, + ) + + +class AsyncVersionsResourceWithRawResponse: + def __init__(self, versions: AsyncVersionsResource) -> None: + self._versions = versions + + self.list = async_to_raw_response_wrapper( + versions.list, + ) + self.delete = async_to_raw_response_wrapper( + versions.delete, + ) + self.get = async_to_raw_response_wrapper( + versions.get, + ) + self.restore = async_to_raw_response_wrapper( + versions.restore, + ) + + +class VersionsResourceWithStreamingResponse: + def __init__(self, versions: VersionsResource) -> None: + self._versions = versions + + self.list = to_streamed_response_wrapper( + versions.list, + ) + self.delete = to_streamed_response_wrapper( + versions.delete, + ) + self.get = to_streamed_response_wrapper( + versions.get, + ) + self.restore = to_streamed_response_wrapper( + versions.restore, + ) + + +class AsyncVersionsResourceWithStreamingResponse: + def __init__(self, versions: AsyncVersionsResource) -> None: + self._versions = versions + + self.list = async_to_streamed_response_wrapper( + versions.list, + ) + self.delete = async_to_streamed_response_wrapper( + versions.delete, + ) + self.get = async_to_streamed_response_wrapper( + versions.get, + ) + self.restore = async_to_streamed_response_wrapper( + versions.restore, + ) diff --git a/src/imagekitio/resources/folders/__init__.py b/src/imagekitio/resources/folders/__init__.py new file mode 100644 index 00000000..a88720f2 --- /dev/null +++ b/src/imagekitio/resources/folders/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .job import ( + JobResource, + AsyncJobResource, + JobResourceWithRawResponse, + AsyncJobResourceWithRawResponse, + JobResourceWithStreamingResponse, + AsyncJobResourceWithStreamingResponse, +) +from .folders import ( + FoldersResource, + AsyncFoldersResource, + FoldersResourceWithRawResponse, + AsyncFoldersResourceWithRawResponse, + FoldersResourceWithStreamingResponse, + AsyncFoldersResourceWithStreamingResponse, +) + +__all__ = [ + "JobResource", + "AsyncJobResource", + "JobResourceWithRawResponse", + "AsyncJobResourceWithRawResponse", + "JobResourceWithStreamingResponse", + "AsyncJobResourceWithStreamingResponse", + "FoldersResource", + "AsyncFoldersResource", + "FoldersResourceWithRawResponse", + "AsyncFoldersResourceWithRawResponse", + "FoldersResourceWithStreamingResponse", + "AsyncFoldersResourceWithStreamingResponse", +] diff --git a/src/imagekitio/resources/folders/folders.py b/src/imagekitio/resources/folders/folders.py new file mode 100644 index 00000000..a5fdf814 --- /dev/null +++ b/src/imagekitio/resources/folders/folders.py @@ -0,0 +1,713 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from .job import ( + JobResource, + AsyncJobResource, + JobResourceWithRawResponse, + AsyncJobResourceWithRawResponse, + JobResourceWithStreamingResponse, + AsyncJobResourceWithStreamingResponse, +) +from ...types import ( + folder_copy_params, + folder_move_params, + folder_create_params, + folder_delete_params, + folder_rename_params, +) +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.folder_copy_response import FolderCopyResponse +from ...types.folder_move_response import FolderMoveResponse +from ...types.folder_create_response import FolderCreateResponse +from ...types.folder_delete_response import FolderDeleteResponse +from ...types.folder_rename_response import FolderRenameResponse + +__all__ = ["FoldersResource", "AsyncFoldersResource"] + + +class FoldersResource(SyncAPIResource): + @cached_property + def job(self) -> JobResource: + return JobResource(self._client) + + @cached_property + def with_raw_response(self) -> FoldersResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return FoldersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> FoldersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return FoldersResourceWithStreamingResponse(self) + + def create( + self, + *, + folder_name: str, + parent_folder_path: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FolderCreateResponse: + """This will create a new folder. + + You can specify the folder name and location of + the parent folder where this new folder should be created. + + Args: + folder_name: The folder will be created with this name. + + All characters except alphabets and numbers (inclusive of unicode letters, + marks, and numerals in other languages) will be replaced by an underscore i.e. + `_`. + + parent_folder_path: The folder where the new folder should be created, for root use `/` else the + path e.g. `containing/folder/`. + + Note: If any folder(s) is not present in the parentFolderPath parameter, it will + be automatically created. For example, if you pass `/product/images/summer`, + then `product`, `images`, and `summer` folders will be created if they don't + already exist. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/folder", + body=maybe_transform( + { + "folder_name": folder_name, + "parent_folder_path": parent_folder_path, + }, + folder_create_params.FolderCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FolderCreateResponse, + ) + + def delete( + self, + *, + folder_path: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FolderDeleteResponse: + """This will delete a folder and all its contents permanently. + + The API returns an + empty response. + + Args: + folder_path: Full path to the folder you want to delete. For example `/folder/to/delete/`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._delete( + "/v1/folder", + body=maybe_transform({"folder_path": folder_path}, folder_delete_params.FolderDeleteParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FolderDeleteResponse, + ) + + def copy( + self, + *, + destination_path: str, + source_folder_path: str, + include_versions: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FolderCopyResponse: + """This will copy one folder into another. + + The selected folder, its nested folders, + files, and their versions (in `includeVersions` is set to true) are copied in + this operation. Note: If any file at the destination has the same name as the + source file, then the source file and its versions will be appended to the + destination file version history. + + Args: + destination_path: Full path to the destination folder where you want to copy the source folder + into. + + source_folder_path: The full path to the source folder you want to copy. + + include_versions: Option to copy all versions of files that are nested inside the selected folder. + By default, only the current version of each file will be copied. When set to + true, all versions of each file will be copied. Default value - `false`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/bulkJobs/copyFolder", + body=maybe_transform( + { + "destination_path": destination_path, + "source_folder_path": source_folder_path, + "include_versions": include_versions, + }, + folder_copy_params.FolderCopyParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FolderCopyResponse, + ) + + def move( + self, + *, + destination_path: str, + source_folder_path: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FolderMoveResponse: + """This will move one folder into another. + + The selected folder, its nested folders, + files, and their versions are moved in this operation. Note: If any file at the + destination has the same name as the source file, then the source file and its + versions will be appended to the destination file version history. + + Args: + destination_path: Full path to the destination folder where you want to move the source folder + into. + + source_folder_path: The full path to the source folder you want to move. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/bulkJobs/moveFolder", + body=maybe_transform( + { + "destination_path": destination_path, + "source_folder_path": source_folder_path, + }, + folder_move_params.FolderMoveParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FolderMoveResponse, + ) + + def rename( + self, + *, + folder_path: str, + new_folder_name: str, + purge_cache: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FolderRenameResponse: + """This API allows you to rename an existing folder. + + The folder and all its nested + assets and sub-folders will remain unchanged, but their paths will be updated to + reflect the new folder name. + + Args: + folder_path: The full path to the folder you want to rename. + + new_folder_name: The new name for the folder. + + All characters except alphabets and numbers (inclusive of unicode letters, + marks, and numerals in other languages) and `-` will be replaced by an + underscore i.e. `_`. + + purge_cache: Option to purge cache for the old nested files and their versions' URLs. + + When set to true, it will internally issue a purge cache request on CDN to + remove the cached content of the old nested files and their versions. There will + only be one purge request for all the nested files, which will be counted + against your monthly purge quota. + + Note: A purge cache request will be issued against + `https://ik.imagekit.io/old/folder/path*` (with a wildcard at the end). This + will remove all nested files, their versions' URLs, and any transformations made + using query parameters on these files or their versions. However, the cache for + file transformations made using path parameters will persist. You can purge them + using the purge API. For more details, refer to the purge API documentation. + + Default value - `false` + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/bulkJobs/renameFolder", + body=maybe_transform( + { + "folder_path": folder_path, + "new_folder_name": new_folder_name, + "purge_cache": purge_cache, + }, + folder_rename_params.FolderRenameParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FolderRenameResponse, + ) + + +class AsyncFoldersResource(AsyncAPIResource): + @cached_property + def job(self) -> AsyncJobResource: + return AsyncJobResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncFoldersResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncFoldersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncFoldersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncFoldersResourceWithStreamingResponse(self) + + async def create( + self, + *, + folder_name: str, + parent_folder_path: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FolderCreateResponse: + """This will create a new folder. + + You can specify the folder name and location of + the parent folder where this new folder should be created. + + Args: + folder_name: The folder will be created with this name. + + All characters except alphabets and numbers (inclusive of unicode letters, + marks, and numerals in other languages) will be replaced by an underscore i.e. + `_`. + + parent_folder_path: The folder where the new folder should be created, for root use `/` else the + path e.g. `containing/folder/`. + + Note: If any folder(s) is not present in the parentFolderPath parameter, it will + be automatically created. For example, if you pass `/product/images/summer`, + then `product`, `images`, and `summer` folders will be created if they don't + already exist. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/folder", + body=await async_maybe_transform( + { + "folder_name": folder_name, + "parent_folder_path": parent_folder_path, + }, + folder_create_params.FolderCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FolderCreateResponse, + ) + + async def delete( + self, + *, + folder_path: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FolderDeleteResponse: + """This will delete a folder and all its contents permanently. + + The API returns an + empty response. + + Args: + folder_path: Full path to the folder you want to delete. For example `/folder/to/delete/`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._delete( + "/v1/folder", + body=await async_maybe_transform({"folder_path": folder_path}, folder_delete_params.FolderDeleteParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FolderDeleteResponse, + ) + + async def copy( + self, + *, + destination_path: str, + source_folder_path: str, + include_versions: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FolderCopyResponse: + """This will copy one folder into another. + + The selected folder, its nested folders, + files, and their versions (in `includeVersions` is set to true) are copied in + this operation. Note: If any file at the destination has the same name as the + source file, then the source file and its versions will be appended to the + destination file version history. + + Args: + destination_path: Full path to the destination folder where you want to copy the source folder + into. + + source_folder_path: The full path to the source folder you want to copy. + + include_versions: Option to copy all versions of files that are nested inside the selected folder. + By default, only the current version of each file will be copied. When set to + true, all versions of each file will be copied. Default value - `false`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/bulkJobs/copyFolder", + body=await async_maybe_transform( + { + "destination_path": destination_path, + "source_folder_path": source_folder_path, + "include_versions": include_versions, + }, + folder_copy_params.FolderCopyParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FolderCopyResponse, + ) + + async def move( + self, + *, + destination_path: str, + source_folder_path: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FolderMoveResponse: + """This will move one folder into another. + + The selected folder, its nested folders, + files, and their versions are moved in this operation. Note: If any file at the + destination has the same name as the source file, then the source file and its + versions will be appended to the destination file version history. + + Args: + destination_path: Full path to the destination folder where you want to move the source folder + into. + + source_folder_path: The full path to the source folder you want to move. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/bulkJobs/moveFolder", + body=await async_maybe_transform( + { + "destination_path": destination_path, + "source_folder_path": source_folder_path, + }, + folder_move_params.FolderMoveParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FolderMoveResponse, + ) + + async def rename( + self, + *, + folder_path: str, + new_folder_name: str, + purge_cache: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FolderRenameResponse: + """This API allows you to rename an existing folder. + + The folder and all its nested + assets and sub-folders will remain unchanged, but their paths will be updated to + reflect the new folder name. + + Args: + folder_path: The full path to the folder you want to rename. + + new_folder_name: The new name for the folder. + + All characters except alphabets and numbers (inclusive of unicode letters, + marks, and numerals in other languages) and `-` will be replaced by an + underscore i.e. `_`. + + purge_cache: Option to purge cache for the old nested files and their versions' URLs. + + When set to true, it will internally issue a purge cache request on CDN to + remove the cached content of the old nested files and their versions. There will + only be one purge request for all the nested files, which will be counted + against your monthly purge quota. + + Note: A purge cache request will be issued against + `https://ik.imagekit.io/old/folder/path*` (with a wildcard at the end). This + will remove all nested files, their versions' URLs, and any transformations made + using query parameters on these files or their versions. However, the cache for + file transformations made using path parameters will persist. You can purge them + using the purge API. For more details, refer to the purge API documentation. + + Default value - `false` + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/bulkJobs/renameFolder", + body=await async_maybe_transform( + { + "folder_path": folder_path, + "new_folder_name": new_folder_name, + "purge_cache": purge_cache, + }, + folder_rename_params.FolderRenameParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FolderRenameResponse, + ) + + +class FoldersResourceWithRawResponse: + def __init__(self, folders: FoldersResource) -> None: + self._folders = folders + + self.create = to_raw_response_wrapper( + folders.create, + ) + self.delete = to_raw_response_wrapper( + folders.delete, + ) + self.copy = to_raw_response_wrapper( + folders.copy, + ) + self.move = to_raw_response_wrapper( + folders.move, + ) + self.rename = to_raw_response_wrapper( + folders.rename, + ) + + @cached_property + def job(self) -> JobResourceWithRawResponse: + return JobResourceWithRawResponse(self._folders.job) + + +class AsyncFoldersResourceWithRawResponse: + def __init__(self, folders: AsyncFoldersResource) -> None: + self._folders = folders + + self.create = async_to_raw_response_wrapper( + folders.create, + ) + self.delete = async_to_raw_response_wrapper( + folders.delete, + ) + self.copy = async_to_raw_response_wrapper( + folders.copy, + ) + self.move = async_to_raw_response_wrapper( + folders.move, + ) + self.rename = async_to_raw_response_wrapper( + folders.rename, + ) + + @cached_property + def job(self) -> AsyncJobResourceWithRawResponse: + return AsyncJobResourceWithRawResponse(self._folders.job) + + +class FoldersResourceWithStreamingResponse: + def __init__(self, folders: FoldersResource) -> None: + self._folders = folders + + self.create = to_streamed_response_wrapper( + folders.create, + ) + self.delete = to_streamed_response_wrapper( + folders.delete, + ) + self.copy = to_streamed_response_wrapper( + folders.copy, + ) + self.move = to_streamed_response_wrapper( + folders.move, + ) + self.rename = to_streamed_response_wrapper( + folders.rename, + ) + + @cached_property + def job(self) -> JobResourceWithStreamingResponse: + return JobResourceWithStreamingResponse(self._folders.job) + + +class AsyncFoldersResourceWithStreamingResponse: + def __init__(self, folders: AsyncFoldersResource) -> None: + self._folders = folders + + self.create = async_to_streamed_response_wrapper( + folders.create, + ) + self.delete = async_to_streamed_response_wrapper( + folders.delete, + ) + self.copy = async_to_streamed_response_wrapper( + folders.copy, + ) + self.move = async_to_streamed_response_wrapper( + folders.move, + ) + self.rename = async_to_streamed_response_wrapper( + folders.rename, + ) + + @cached_property + def job(self) -> AsyncJobResourceWithStreamingResponse: + return AsyncJobResourceWithStreamingResponse(self._folders.job) diff --git a/src/imagekitio/resources/folders/job.py b/src/imagekitio/resources/folders/job.py new file mode 100644 index 00000000..f731d083 --- /dev/null +++ b/src/imagekitio/resources/folders/job.py @@ -0,0 +1,163 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.folders.job_get_response import JobGetResponse + +__all__ = ["JobResource", "AsyncJobResource"] + + +class JobResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> JobResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return JobResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> JobResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return JobResourceWithStreamingResponse(self) + + def get( + self, + job_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> JobGetResponse: + """ + This API returns the status of a bulk job like copy and move folder operations. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not job_id: + raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}") + return self._get( + f"/v1/bulkJobs/{job_id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=JobGetResponse, + ) + + +class AsyncJobResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncJobResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + """ + return AsyncJobResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncJobResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + """ + return AsyncJobResourceWithStreamingResponse(self) + + async def get( + self, + job_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> JobGetResponse: + """ + This API returns the status of a bulk job like copy and move folder operations. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not job_id: + raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}") + return await self._get( + f"/v1/bulkJobs/{job_id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=JobGetResponse, + ) + + +class JobResourceWithRawResponse: + def __init__(self, job: JobResource) -> None: + self._job = job + + self.get = to_raw_response_wrapper( + job.get, + ) + + +class AsyncJobResourceWithRawResponse: + def __init__(self, job: AsyncJobResource) -> None: + self._job = job + + self.get = async_to_raw_response_wrapper( + job.get, + ) + + +class JobResourceWithStreamingResponse: + def __init__(self, job: JobResource) -> None: + self._job = job + + self.get = to_streamed_response_wrapper( + job.get, + ) + + +class AsyncJobResourceWithStreamingResponse: + def __init__(self, job: AsyncJobResource) -> None: + self._job = job + + self.get = async_to_streamed_response_wrapper( + job.get, + ) diff --git a/src/imagekitio/resources/webhooks.py b/src/imagekitio/resources/webhooks.py new file mode 100644 index 00000000..d308cca7 --- /dev/null +++ b/src/imagekitio/resources/webhooks.py @@ -0,0 +1,93 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import json +import base64 +from typing import Mapping, cast + +from .._models import construct_type +from .._resource import SyncAPIResource, AsyncAPIResource +from .._exceptions import ImageKitError +from ..types.unwrap_webhook_event import UnwrapWebhookEvent +from ..types.unsafe_unwrap_webhook_event import UnsafeUnwrapWebhookEvent + +__all__ = ["WebhooksResource", "AsyncWebhooksResource"] + + +class WebhooksResource(SyncAPIResource): + def unsafe_unwrap(self, payload: str) -> UnsafeUnwrapWebhookEvent: + return cast( + UnsafeUnwrapWebhookEvent, + construct_type( + type_=UnsafeUnwrapWebhookEvent, + value=json.loads(payload), + ), + ) + + def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | None = None) -> UnwrapWebhookEvent: + try: + from standardwebhooks import Webhook + except ImportError as exc: + raise ImageKitError("You need to install `imagekitio[webhooks]` to use this method") from exc + + if key is None: + key = self._client.webhook_secret + if key is None: + raise ValueError( + "Cannot verify a webhook without a key on either the client's webhook_secret or passed in as an argument" + ) + + if not isinstance(headers, dict): + headers = dict(headers) + + encoded_key = base64.b64encode(key.encode("utf-8")).decode("ascii") + + Webhook(encoded_key).verify(payload, headers) + + return cast( + UnwrapWebhookEvent, + construct_type( + type_=UnwrapWebhookEvent, + value=json.loads(payload), + ), + ) + + +class AsyncWebhooksResource(AsyncAPIResource): + def unsafe_unwrap(self, payload: str) -> UnsafeUnwrapWebhookEvent: + return cast( + UnsafeUnwrapWebhookEvent, + construct_type( + type_=UnsafeUnwrapWebhookEvent, + value=json.loads(payload), + ), + ) + + def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | None = None) -> UnwrapWebhookEvent: + try: + from standardwebhooks import Webhook + except ImportError as exc: + raise ImageKitError("You need to install `imagekitio[webhooks]` to use this method") from exc + + if key is None: + key = self._client.webhook_secret + if key is None: + raise ValueError( + "Cannot verify a webhook without a key on either the client's webhook_secret or passed in as an argument" + ) + + if not isinstance(headers, dict): + headers = dict(headers) + + encoded_key = base64.b64encode(key.encode("utf-8")).decode("ascii") + + Webhook(encoded_key).verify(payload, headers) + + return cast( + UnwrapWebhookEvent, + construct_type( + type_=UnwrapWebhookEvent, + value=json.loads(payload), + ), + ) diff --git a/src/imagekitio/types/__init__.py b/src/imagekitio/types/__init__.py new file mode 100644 index 00000000..dfbbb78f --- /dev/null +++ b/src/imagekitio/types/__init__.py @@ -0,0 +1,83 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from . import shared +from .. import _compat +from .file import File as File +from .folder import Folder as Folder +from .shared import ( + Overlay as Overlay, + Extensions as Extensions, + SrcOptions as SrcOptions, + BaseOverlay as BaseOverlay, + TextOverlay as TextOverlay, + ImageOverlay as ImageOverlay, + VideoOverlay as VideoOverlay, + OverlayTiming as OverlayTiming, + Transformation as Transformation, + OverlayPosition as OverlayPosition, + SubtitleOverlay as SubtitleOverlay, + SolidColorOverlay as SolidColorOverlay, + StreamingResolution as StreamingResolution, + TransformationPosition as TransformationPosition, + GetImageAttributesOptions as GetImageAttributesOptions, + ResponsiveImageAttributes as ResponsiveImageAttributes, + TextOverlayTransformation as TextOverlayTransformation, + SubtitleOverlayTransformation as SubtitleOverlayTransformation, + SolidColorOverlayTransformation as SolidColorOverlayTransformation, +) +from .metadata import Metadata as Metadata +from .file_copy_params import FileCopyParams as FileCopyParams +from .file_move_params import FileMoveParams as FileMoveParams +from .asset_list_params import AssetListParams as AssetListParams +from .base_webhook_event import BaseWebhookEvent as BaseWebhookEvent +from .file_copy_response import FileCopyResponse as FileCopyResponse +from .file_move_response import FileMoveResponse as FileMoveResponse +from .file_rename_params import FileRenameParams as FileRenameParams +from .file_update_params import FileUpdateParams as FileUpdateParams +from .file_upload_params import FileUploadParams as FileUploadParams +from .folder_copy_params import FolderCopyParams as FolderCopyParams +from .folder_move_params import FolderMoveParams as FolderMoveParams +from .asset_list_response import AssetListResponse as AssetListResponse +from .dummy_create_params import DummyCreateParams as DummyCreateParams +from .file_rename_response import FileRenameResponse as FileRenameResponse +from .file_update_response import FileUpdateResponse as FileUpdateResponse +from .file_upload_response import FileUploadResponse as FileUploadResponse +from .folder_copy_response import FolderCopyResponse as FolderCopyResponse +from .folder_create_params import FolderCreateParams as FolderCreateParams +from .folder_delete_params import FolderDeleteParams as FolderDeleteParams +from .folder_move_response import FolderMoveResponse as FolderMoveResponse +from .folder_rename_params import FolderRenameParams as FolderRenameParams +from .unwrap_webhook_event import UnwrapWebhookEvent as UnwrapWebhookEvent +from .custom_metadata_field import CustomMetadataField as CustomMetadataField +from .folder_create_response import FolderCreateResponse as FolderCreateResponse +from .folder_delete_response import FolderDeleteResponse as FolderDeleteResponse +from .folder_rename_response import FolderRenameResponse as FolderRenameResponse +from .update_file_request_param import UpdateFileRequestParam as UpdateFileRequestParam +from .unsafe_unwrap_webhook_event import UnsafeUnwrapWebhookEvent as UnsafeUnwrapWebhookEvent +from .upload_pre_transform_error_event import UploadPreTransformErrorEvent as UploadPreTransformErrorEvent +from .video_transformation_error_event import VideoTransformationErrorEvent as VideoTransformationErrorEvent +from .video_transformation_ready_event import VideoTransformationReadyEvent as VideoTransformationReadyEvent +from .custom_metadata_field_list_params import CustomMetadataFieldListParams as CustomMetadataFieldListParams +from .upload_post_transform_error_event import UploadPostTransformErrorEvent as UploadPostTransformErrorEvent +from .upload_pre_transform_success_event import UploadPreTransformSuccessEvent as UploadPreTransformSuccessEvent +from .custom_metadata_field_create_params import CustomMetadataFieldCreateParams as CustomMetadataFieldCreateParams +from .custom_metadata_field_list_response import CustomMetadataFieldListResponse as CustomMetadataFieldListResponse +from .custom_metadata_field_update_params import CustomMetadataFieldUpdateParams as CustomMetadataFieldUpdateParams +from .upload_post_transform_success_event import UploadPostTransformSuccessEvent as UploadPostTransformSuccessEvent +from .video_transformation_accepted_event import VideoTransformationAcceptedEvent as VideoTransformationAcceptedEvent +from .custom_metadata_field_delete_response import ( + CustomMetadataFieldDeleteResponse as CustomMetadataFieldDeleteResponse, +) + +# Rebuild cyclical models only after all modules are imported. +# This ensures that, when building the deferred (due to cyclical references) model schema, +# Pydantic can resolve the necessary references. +# See: https://github.com/pydantic/pydantic/issues/11250 for more context. +if _compat.PYDANTIC_V1: + shared.src_options.SrcOptions.update_forward_refs() # type: ignore + shared.transformation.Transformation.update_forward_refs() # type: ignore +else: + shared.src_options.SrcOptions.model_rebuild(_parent_namespace_depth=0) + shared.transformation.Transformation.model_rebuild(_parent_namespace_depth=0) diff --git a/src/imagekitio/types/accounts/__init__.py b/src/imagekitio/types/accounts/__init__.py new file mode 100644 index 00000000..3d713dbe --- /dev/null +++ b/src/imagekitio/types/accounts/__init__.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .origin_response import OriginResponse as OriginResponse +from .usage_get_params import UsageGetParams as UsageGetParams +from .usage_get_response import UsageGetResponse as UsageGetResponse +from .origin_create_params import OriginCreateParams as OriginCreateParams +from .origin_list_response import OriginListResponse as OriginListResponse +from .origin_request_param import OriginRequestParam as OriginRequestParam +from .origin_update_params import OriginUpdateParams as OriginUpdateParams +from .url_endpoint_response import URLEndpointResponse as URLEndpointResponse +from .url_endpoint_create_params import URLEndpointCreateParams as URLEndpointCreateParams +from .url_endpoint_list_response import URLEndpointListResponse as URLEndpointListResponse +from .url_endpoint_update_params import URLEndpointUpdateParams as URLEndpointUpdateParams diff --git a/src/imagekitio/types/accounts/origin_create_params.py b/src/imagekitio/types/accounts/origin_create_params.py new file mode 100644 index 00000000..7489a1d9 --- /dev/null +++ b/src/imagekitio/types/accounts/origin_create_params.py @@ -0,0 +1,208 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict + +from ..._utils import PropertyInfo + +__all__ = [ + "OriginCreateParams", + "S3", + "S3Compatible", + "CloudinaryBackup", + "WebFolder", + "WebProxy", + "GoogleCloudStorageGcs", + "AzureBlobStorage", + "AkeneoPim", +] + + +class S3(TypedDict, total=False): + access_key: Required[Annotated[str, PropertyInfo(alias="accessKey")]] + """Access key for the bucket.""" + + bucket: Required[str] + """S3 bucket name.""" + + name: Required[str] + """Display name of the origin.""" + + secret_key: Required[Annotated[str, PropertyInfo(alias="secretKey")]] + """Secret key for the bucket.""" + + type: Required[Literal["S3"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + """Path prefix inside the bucket.""" + + +class S3Compatible(TypedDict, total=False): + access_key: Required[Annotated[str, PropertyInfo(alias="accessKey")]] + """Access key for the bucket.""" + + bucket: Required[str] + """S3 bucket name.""" + + endpoint: Required[str] + """Custom S3-compatible endpoint.""" + + name: Required[str] + """Display name of the origin.""" + + secret_key: Required[Annotated[str, PropertyInfo(alias="secretKey")]] + """Secret key for the bucket.""" + + type: Required[Literal["S3_COMPATIBLE"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + """Path prefix inside the bucket.""" + + s3_force_path_style: Annotated[bool, PropertyInfo(alias="s3ForcePathStyle")] + """Use path-style S3 URLs?""" + + +class CloudinaryBackup(TypedDict, total=False): + access_key: Required[Annotated[str, PropertyInfo(alias="accessKey")]] + """Access key for the bucket.""" + + bucket: Required[str] + """S3 bucket name.""" + + name: Required[str] + """Display name of the origin.""" + + secret_key: Required[Annotated[str, PropertyInfo(alias="secretKey")]] + """Secret key for the bucket.""" + + type: Required[Literal["CLOUDINARY_BACKUP"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + """Path prefix inside the bucket.""" + + +class WebFolder(TypedDict, total=False): + base_url: Required[Annotated[str, PropertyInfo(alias="baseUrl")]] + """Root URL for the web folder origin.""" + + name: Required[str] + """Display name of the origin.""" + + type: Required[Literal["WEB_FOLDER"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + forward_host_header_to_origin: Annotated[bool, PropertyInfo(alias="forwardHostHeaderToOrigin")] + """Forward the Host header to origin?""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + +class WebProxy(TypedDict, total=False): + name: Required[str] + """Display name of the origin.""" + + type: Required[Literal["WEB_PROXY"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + +class GoogleCloudStorageGcs(TypedDict, total=False): + bucket: Required[str] + + client_email: Required[Annotated[str, PropertyInfo(alias="clientEmail")]] + + name: Required[str] + """Display name of the origin.""" + + private_key: Required[Annotated[str, PropertyInfo(alias="privateKey")]] + + type: Required[Literal["GCS"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + + +class AzureBlobStorage(TypedDict, total=False): + account_name: Required[Annotated[str, PropertyInfo(alias="accountName")]] + + container: Required[str] + + name: Required[str] + """Display name of the origin.""" + + sas_token: Required[Annotated[str, PropertyInfo(alias="sasToken")]] + + type: Required[Literal["AZURE_BLOB"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + + +class AkeneoPim(TypedDict, total=False): + base_url: Required[Annotated[str, PropertyInfo(alias="baseUrl")]] + """Akeneo instance base URL.""" + + client_id: Required[Annotated[str, PropertyInfo(alias="clientId")]] + """Akeneo API client ID.""" + + client_secret: Required[Annotated[str, PropertyInfo(alias="clientSecret")]] + """Akeneo API client secret.""" + + name: Required[str] + """Display name of the origin.""" + + password: Required[str] + """Akeneo API password.""" + + type: Required[Literal["AKENEO_PIM"]] + + username: Required[str] + """Akeneo API username.""" + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + +OriginCreateParams: TypeAlias = Union[ + S3, S3Compatible, CloudinaryBackup, WebFolder, WebProxy, GoogleCloudStorageGcs, AzureBlobStorage, AkeneoPim +] diff --git a/src/imagekitio/types/accounts/origin_list_response.py b/src/imagekitio/types/accounts/origin_list_response.py new file mode 100644 index 00000000..b0e09315 --- /dev/null +++ b/src/imagekitio/types/accounts/origin_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .origin_response import OriginResponse + +__all__ = ["OriginListResponse"] + +OriginListResponse: TypeAlias = List[OriginResponse] diff --git a/src/imagekitio/types/accounts/origin_request_param.py b/src/imagekitio/types/accounts/origin_request_param.py new file mode 100644 index 00000000..a2864ad4 --- /dev/null +++ b/src/imagekitio/types/accounts/origin_request_param.py @@ -0,0 +1,208 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict + +from ..._utils import PropertyInfo + +__all__ = [ + "OriginRequestParam", + "S3", + "S3Compatible", + "CloudinaryBackup", + "WebFolder", + "WebProxy", + "Gcs", + "AzureBlob", + "AkeneoPim", +] + + +class S3(TypedDict, total=False): + access_key: Required[Annotated[str, PropertyInfo(alias="accessKey")]] + """Access key for the bucket.""" + + bucket: Required[str] + """S3 bucket name.""" + + name: Required[str] + """Display name of the origin.""" + + secret_key: Required[Annotated[str, PropertyInfo(alias="secretKey")]] + """Secret key for the bucket.""" + + type: Required[Literal["S3"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + """Path prefix inside the bucket.""" + + +class S3Compatible(TypedDict, total=False): + access_key: Required[Annotated[str, PropertyInfo(alias="accessKey")]] + """Access key for the bucket.""" + + bucket: Required[str] + """S3 bucket name.""" + + endpoint: Required[str] + """Custom S3-compatible endpoint.""" + + name: Required[str] + """Display name of the origin.""" + + secret_key: Required[Annotated[str, PropertyInfo(alias="secretKey")]] + """Secret key for the bucket.""" + + type: Required[Literal["S3_COMPATIBLE"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + """Path prefix inside the bucket.""" + + s3_force_path_style: Annotated[bool, PropertyInfo(alias="s3ForcePathStyle")] + """Use path-style S3 URLs?""" + + +class CloudinaryBackup(TypedDict, total=False): + access_key: Required[Annotated[str, PropertyInfo(alias="accessKey")]] + """Access key for the bucket.""" + + bucket: Required[str] + """S3 bucket name.""" + + name: Required[str] + """Display name of the origin.""" + + secret_key: Required[Annotated[str, PropertyInfo(alias="secretKey")]] + """Secret key for the bucket.""" + + type: Required[Literal["CLOUDINARY_BACKUP"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + """Path prefix inside the bucket.""" + + +class WebFolder(TypedDict, total=False): + base_url: Required[Annotated[str, PropertyInfo(alias="baseUrl")]] + """Root URL for the web folder origin.""" + + name: Required[str] + """Display name of the origin.""" + + type: Required[Literal["WEB_FOLDER"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + forward_host_header_to_origin: Annotated[bool, PropertyInfo(alias="forwardHostHeaderToOrigin")] + """Forward the Host header to origin?""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + +class WebProxy(TypedDict, total=False): + name: Required[str] + """Display name of the origin.""" + + type: Required[Literal["WEB_PROXY"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + +class Gcs(TypedDict, total=False): + bucket: Required[str] + + client_email: Required[Annotated[str, PropertyInfo(alias="clientEmail")]] + + name: Required[str] + """Display name of the origin.""" + + private_key: Required[Annotated[str, PropertyInfo(alias="privateKey")]] + + type: Required[Literal["GCS"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + + +class AzureBlob(TypedDict, total=False): + account_name: Required[Annotated[str, PropertyInfo(alias="accountName")]] + + container: Required[str] + + name: Required[str] + """Display name of the origin.""" + + sas_token: Required[Annotated[str, PropertyInfo(alias="sasToken")]] + + type: Required[Literal["AZURE_BLOB"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + + +class AkeneoPim(TypedDict, total=False): + base_url: Required[Annotated[str, PropertyInfo(alias="baseUrl")]] + """Akeneo instance base URL.""" + + client_id: Required[Annotated[str, PropertyInfo(alias="clientId")]] + """Akeneo API client ID.""" + + client_secret: Required[Annotated[str, PropertyInfo(alias="clientSecret")]] + """Akeneo API client secret.""" + + name: Required[str] + """Display name of the origin.""" + + password: Required[str] + """Akeneo API password.""" + + type: Required[Literal["AKENEO_PIM"]] + + username: Required[str] + """Akeneo API username.""" + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + +OriginRequestParam: TypeAlias = Union[ + S3, S3Compatible, CloudinaryBackup, WebFolder, WebProxy, Gcs, AzureBlob, AkeneoPim +] diff --git a/src/imagekitio/types/accounts/origin_response.py b/src/imagekitio/types/accounts/origin_response.py new file mode 100644 index 00000000..d4374470 --- /dev/null +++ b/src/imagekitio/types/accounts/origin_response.py @@ -0,0 +1,224 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias + +from pydantic import Field as FieldInfo + +from ..._utils import PropertyInfo +from ..._models import BaseModel + +__all__ = [ + "OriginResponse", + "S3", + "S3Compatible", + "CloudinaryBackup", + "WebFolder", + "WebProxy", + "Gcs", + "AzureBlob", + "AkeneoPim", +] + + +class S3(BaseModel): + id: str + """Unique identifier for the origin. + + This is generated by ImageKit when you create a new origin. + """ + + bucket: str + """S3 bucket name.""" + + include_canonical_header: bool = FieldInfo(alias="includeCanonicalHeader") + """Whether to send a Canonical header.""" + + name: str + """Display name of the origin.""" + + prefix: str + """Path prefix inside the bucket.""" + + type: Literal["S3"] + + base_url_for_canonical_header: Optional[str] = FieldInfo(alias="baseUrlForCanonicalHeader", default=None) + """URL used in the Canonical header (if enabled).""" + + +class S3Compatible(BaseModel): + id: str + """Unique identifier for the origin. + + This is generated by ImageKit when you create a new origin. + """ + + bucket: str + """S3 bucket name.""" + + endpoint: str + """Custom S3-compatible endpoint.""" + + include_canonical_header: bool = FieldInfo(alias="includeCanonicalHeader") + """Whether to send a Canonical header.""" + + name: str + """Display name of the origin.""" + + prefix: str + """Path prefix inside the bucket.""" + + s3_force_path_style: bool = FieldInfo(alias="s3ForcePathStyle") + """Use path-style S3 URLs?""" + + type: Literal["S3_COMPATIBLE"] + + base_url_for_canonical_header: Optional[str] = FieldInfo(alias="baseUrlForCanonicalHeader", default=None) + """URL used in the Canonical header (if enabled).""" + + +class CloudinaryBackup(BaseModel): + id: str + """Unique identifier for the origin. + + This is generated by ImageKit when you create a new origin. + """ + + bucket: str + """S3 bucket name.""" + + include_canonical_header: bool = FieldInfo(alias="includeCanonicalHeader") + """Whether to send a Canonical header.""" + + name: str + """Display name of the origin.""" + + prefix: str + """Path prefix inside the bucket.""" + + type: Literal["CLOUDINARY_BACKUP"] + + base_url_for_canonical_header: Optional[str] = FieldInfo(alias="baseUrlForCanonicalHeader", default=None) + """URL used in the Canonical header (if enabled).""" + + +class WebFolder(BaseModel): + id: str + """Unique identifier for the origin. + + This is generated by ImageKit when you create a new origin. + """ + + base_url: str = FieldInfo(alias="baseUrl") + """Root URL for the web folder origin.""" + + forward_host_header_to_origin: bool = FieldInfo(alias="forwardHostHeaderToOrigin") + """Forward the Host header to origin?""" + + include_canonical_header: bool = FieldInfo(alias="includeCanonicalHeader") + """Whether to send a Canonical header.""" + + name: str + """Display name of the origin.""" + + type: Literal["WEB_FOLDER"] + + base_url_for_canonical_header: Optional[str] = FieldInfo(alias="baseUrlForCanonicalHeader", default=None) + """URL used in the Canonical header (if enabled).""" + + +class WebProxy(BaseModel): + id: str + """Unique identifier for the origin. + + This is generated by ImageKit when you create a new origin. + """ + + include_canonical_header: bool = FieldInfo(alias="includeCanonicalHeader") + """Whether to send a Canonical header.""" + + name: str + """Display name of the origin.""" + + type: Literal["WEB_PROXY"] + + base_url_for_canonical_header: Optional[str] = FieldInfo(alias="baseUrlForCanonicalHeader", default=None) + """URL used in the Canonical header (if enabled).""" + + +class Gcs(BaseModel): + id: str + """Unique identifier for the origin. + + This is generated by ImageKit when you create a new origin. + """ + + bucket: str + + client_email: str = FieldInfo(alias="clientEmail") + + include_canonical_header: bool = FieldInfo(alias="includeCanonicalHeader") + """Whether to send a Canonical header.""" + + name: str + """Display name of the origin.""" + + prefix: str + + type: Literal["GCS"] + + base_url_for_canonical_header: Optional[str] = FieldInfo(alias="baseUrlForCanonicalHeader", default=None) + """URL used in the Canonical header (if enabled).""" + + +class AzureBlob(BaseModel): + id: str + """Unique identifier for the origin. + + This is generated by ImageKit when you create a new origin. + """ + + account_name: str = FieldInfo(alias="accountName") + + container: str + + include_canonical_header: bool = FieldInfo(alias="includeCanonicalHeader") + """Whether to send a Canonical header.""" + + name: str + """Display name of the origin.""" + + prefix: str + + type: Literal["AZURE_BLOB"] + + base_url_for_canonical_header: Optional[str] = FieldInfo(alias="baseUrlForCanonicalHeader", default=None) + """URL used in the Canonical header (if enabled).""" + + +class AkeneoPim(BaseModel): + id: str + """Unique identifier for the origin. + + This is generated by ImageKit when you create a new origin. + """ + + base_url: str = FieldInfo(alias="baseUrl") + """Akeneo instance base URL.""" + + include_canonical_header: bool = FieldInfo(alias="includeCanonicalHeader") + """Whether to send a Canonical header.""" + + name: str + """Display name of the origin.""" + + type: Literal["AKENEO_PIM"] + + base_url_for_canonical_header: Optional[str] = FieldInfo(alias="baseUrlForCanonicalHeader", default=None) + """URL used in the Canonical header (if enabled).""" + + +OriginResponse: TypeAlias = Annotated[ + Union[S3, S3Compatible, CloudinaryBackup, WebFolder, WebProxy, Gcs, AzureBlob, AkeneoPim], + PropertyInfo(discriminator="type"), +] diff --git a/src/imagekitio/types/accounts/origin_update_params.py b/src/imagekitio/types/accounts/origin_update_params.py new file mode 100644 index 00000000..a7b39fba --- /dev/null +++ b/src/imagekitio/types/accounts/origin_update_params.py @@ -0,0 +1,208 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict + +from ..._utils import PropertyInfo + +__all__ = [ + "OriginUpdateParams", + "S3", + "S3Compatible", + "CloudinaryBackup", + "WebFolder", + "WebProxy", + "GoogleCloudStorageGcs", + "AzureBlobStorage", + "AkeneoPim", +] + + +class S3(TypedDict, total=False): + access_key: Required[Annotated[str, PropertyInfo(alias="accessKey")]] + """Access key for the bucket.""" + + bucket: Required[str] + """S3 bucket name.""" + + name: Required[str] + """Display name of the origin.""" + + secret_key: Required[Annotated[str, PropertyInfo(alias="secretKey")]] + """Secret key for the bucket.""" + + type: Required[Literal["S3"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + """Path prefix inside the bucket.""" + + +class S3Compatible(TypedDict, total=False): + access_key: Required[Annotated[str, PropertyInfo(alias="accessKey")]] + """Access key for the bucket.""" + + bucket: Required[str] + """S3 bucket name.""" + + endpoint: Required[str] + """Custom S3-compatible endpoint.""" + + name: Required[str] + """Display name of the origin.""" + + secret_key: Required[Annotated[str, PropertyInfo(alias="secretKey")]] + """Secret key for the bucket.""" + + type: Required[Literal["S3_COMPATIBLE"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + """Path prefix inside the bucket.""" + + s3_force_path_style: Annotated[bool, PropertyInfo(alias="s3ForcePathStyle")] + """Use path-style S3 URLs?""" + + +class CloudinaryBackup(TypedDict, total=False): + access_key: Required[Annotated[str, PropertyInfo(alias="accessKey")]] + """Access key for the bucket.""" + + bucket: Required[str] + """S3 bucket name.""" + + name: Required[str] + """Display name of the origin.""" + + secret_key: Required[Annotated[str, PropertyInfo(alias="secretKey")]] + """Secret key for the bucket.""" + + type: Required[Literal["CLOUDINARY_BACKUP"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + """Path prefix inside the bucket.""" + + +class WebFolder(TypedDict, total=False): + base_url: Required[Annotated[str, PropertyInfo(alias="baseUrl")]] + """Root URL for the web folder origin.""" + + name: Required[str] + """Display name of the origin.""" + + type: Required[Literal["WEB_FOLDER"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + forward_host_header_to_origin: Annotated[bool, PropertyInfo(alias="forwardHostHeaderToOrigin")] + """Forward the Host header to origin?""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + +class WebProxy(TypedDict, total=False): + name: Required[str] + """Display name of the origin.""" + + type: Required[Literal["WEB_PROXY"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + +class GoogleCloudStorageGcs(TypedDict, total=False): + bucket: Required[str] + + client_email: Required[Annotated[str, PropertyInfo(alias="clientEmail")]] + + name: Required[str] + """Display name of the origin.""" + + private_key: Required[Annotated[str, PropertyInfo(alias="privateKey")]] + + type: Required[Literal["GCS"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + + +class AzureBlobStorage(TypedDict, total=False): + account_name: Required[Annotated[str, PropertyInfo(alias="accountName")]] + + container: Required[str] + + name: Required[str] + """Display name of the origin.""" + + sas_token: Required[Annotated[str, PropertyInfo(alias="sasToken")]] + + type: Required[Literal["AZURE_BLOB"]] + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + prefix: str + + +class AkeneoPim(TypedDict, total=False): + base_url: Required[Annotated[str, PropertyInfo(alias="baseUrl")]] + """Akeneo instance base URL.""" + + client_id: Required[Annotated[str, PropertyInfo(alias="clientId")]] + """Akeneo API client ID.""" + + client_secret: Required[Annotated[str, PropertyInfo(alias="clientSecret")]] + """Akeneo API client secret.""" + + name: Required[str] + """Display name of the origin.""" + + password: Required[str] + """Akeneo API password.""" + + type: Required[Literal["AKENEO_PIM"]] + + username: Required[str] + """Akeneo API username.""" + + base_url_for_canonical_header: Annotated[str, PropertyInfo(alias="baseUrlForCanonicalHeader")] + """URL used in the Canonical header (if enabled).""" + + include_canonical_header: Annotated[bool, PropertyInfo(alias="includeCanonicalHeader")] + """Whether to send a Canonical header.""" + + +OriginUpdateParams: TypeAlias = Union[ + S3, S3Compatible, CloudinaryBackup, WebFolder, WebProxy, GoogleCloudStorageGcs, AzureBlobStorage, AkeneoPim +] diff --git a/src/imagekitio/types/accounts/url_endpoint_create_params.py b/src/imagekitio/types/accounts/url_endpoint_create_params.py new file mode 100644 index 00000000..1c4653f5 --- /dev/null +++ b/src/imagekitio/types/accounts/url_endpoint_create_params.py @@ -0,0 +1,50 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict + +from ..._types import SequenceNotStr +from ..._utils import PropertyInfo + +__all__ = ["URLEndpointCreateParams", "URLRewriter", "URLRewriterCloudinary", "URLRewriterImgix", "URLRewriterAkamai"] + + +class URLEndpointCreateParams(TypedDict, total=False): + description: Required[str] + """Description of the URL endpoint.""" + + origins: SequenceNotStr[str] + """ + Ordered list of origin IDs to try when the file isn’t in the Media Library; + ImageKit checks them in the sequence provided. Origin must be created before it + can be used in a URL endpoint. + """ + + url_prefix: Annotated[str, PropertyInfo(alias="urlPrefix")] + """ + Path segment appended to your base URL to form the endpoint (letters, digits, + and hyphens only — or empty for the default endpoint). + """ + + url_rewriter: Annotated[URLRewriter, PropertyInfo(alias="urlRewriter")] + """Configuration for third-party URL rewriting.""" + + +class URLRewriterCloudinary(TypedDict, total=False): + type: Required[Literal["CLOUDINARY"]] + + preserve_asset_delivery_types: Annotated[bool, PropertyInfo(alias="preserveAssetDeliveryTypes")] + """Whether to preserve `/` in the rewritten URL.""" + + +class URLRewriterImgix(TypedDict, total=False): + type: Required[Literal["IMGIX"]] + + +class URLRewriterAkamai(TypedDict, total=False): + type: Required[Literal["AKAMAI"]] + + +URLRewriter: TypeAlias = Union[URLRewriterCloudinary, URLRewriterImgix, URLRewriterAkamai] diff --git a/src/imagekitio/types/accounts/url_endpoint_list_response.py b/src/imagekitio/types/accounts/url_endpoint_list_response.py new file mode 100644 index 00000000..a51a8df0 --- /dev/null +++ b/src/imagekitio/types/accounts/url_endpoint_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .url_endpoint_response import URLEndpointResponse + +__all__ = ["URLEndpointListResponse"] + +URLEndpointListResponse: TypeAlias = List[URLEndpointResponse] diff --git a/src/imagekitio/types/accounts/url_endpoint_response.py b/src/imagekitio/types/accounts/url_endpoint_response.py new file mode 100644 index 00000000..c2bcadc7 --- /dev/null +++ b/src/imagekitio/types/accounts/url_endpoint_response.py @@ -0,0 +1,61 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias + +from pydantic import Field as FieldInfo + +from ..._utils import PropertyInfo +from ..._models import BaseModel + +__all__ = ["URLEndpointResponse", "URLRewriter", "URLRewriterCloudinary", "URLRewriterImgix", "URLRewriterAkamai"] + + +class URLRewriterCloudinary(BaseModel): + preserve_asset_delivery_types: bool = FieldInfo(alias="preserveAssetDeliveryTypes") + """Whether to preserve `/` in the rewritten URL.""" + + type: Literal["CLOUDINARY"] + + +class URLRewriterImgix(BaseModel): + type: Literal["IMGIX"] + + +class URLRewriterAkamai(BaseModel): + type: Literal["AKAMAI"] + + +URLRewriter: TypeAlias = Annotated[ + Union[URLRewriterCloudinary, URLRewriterImgix, URLRewriterAkamai], PropertyInfo(discriminator="type") +] + + +class URLEndpointResponse(BaseModel): + """URL‑endpoint object as returned by the API.""" + + id: str + """Unique identifier for the URL-endpoint. + + This is generated by ImageKit when you create a new URL-endpoint. For the + default URL-endpoint, this is always `default`. + """ + + description: str + """Description of the URL endpoint.""" + + origins: List[str] + """ + Ordered list of origin IDs to try when the file isn’t in the Media Library; + ImageKit checks them in the sequence provided. Origin must be created before it + can be used in a URL endpoint. + """ + + url_prefix: str = FieldInfo(alias="urlPrefix") + """ + Path segment appended to your base URL to form the endpoint (letters, digits, + and hyphens only — or empty for the default endpoint). + """ + + url_rewriter: Optional[URLRewriter] = FieldInfo(alias="urlRewriter", default=None) + """Configuration for third-party URL rewriting.""" diff --git a/src/imagekitio/types/accounts/url_endpoint_update_params.py b/src/imagekitio/types/accounts/url_endpoint_update_params.py new file mode 100644 index 00000000..e34f27b2 --- /dev/null +++ b/src/imagekitio/types/accounts/url_endpoint_update_params.py @@ -0,0 +1,50 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict + +from ..._types import SequenceNotStr +from ..._utils import PropertyInfo + +__all__ = ["URLEndpointUpdateParams", "URLRewriter", "URLRewriterCloudinary", "URLRewriterImgix", "URLRewriterAkamai"] + + +class URLEndpointUpdateParams(TypedDict, total=False): + description: Required[str] + """Description of the URL endpoint.""" + + origins: SequenceNotStr[str] + """ + Ordered list of origin IDs to try when the file isn’t in the Media Library; + ImageKit checks them in the sequence provided. Origin must be created before it + can be used in a URL endpoint. + """ + + url_prefix: Annotated[str, PropertyInfo(alias="urlPrefix")] + """ + Path segment appended to your base URL to form the endpoint (letters, digits, + and hyphens only — or empty for the default endpoint). + """ + + url_rewriter: Annotated[URLRewriter, PropertyInfo(alias="urlRewriter")] + """Configuration for third-party URL rewriting.""" + + +class URLRewriterCloudinary(TypedDict, total=False): + type: Required[Literal["CLOUDINARY"]] + + preserve_asset_delivery_types: Annotated[bool, PropertyInfo(alias="preserveAssetDeliveryTypes")] + """Whether to preserve `/` in the rewritten URL.""" + + +class URLRewriterImgix(TypedDict, total=False): + type: Required[Literal["IMGIX"]] + + +class URLRewriterAkamai(TypedDict, total=False): + type: Required[Literal["AKAMAI"]] + + +URLRewriter: TypeAlias = Union[URLRewriterCloudinary, URLRewriterImgix, URLRewriterAkamai] diff --git a/src/imagekitio/types/accounts/usage_get_params.py b/src/imagekitio/types/accounts/usage_get_params.py new file mode 100644 index 00000000..298a9690 --- /dev/null +++ b/src/imagekitio/types/accounts/usage_get_params.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from datetime import date +from typing_extensions import Required, Annotated, TypedDict + +from ..._utils import PropertyInfo + +__all__ = ["UsageGetParams"] + + +class UsageGetParams(TypedDict, total=False): + end_date: Required[Annotated[Union[str, date], PropertyInfo(alias="endDate", format="iso8601")]] + """Specify a `endDate` in `YYYY-MM-DD` format. + + It should be after the `startDate`. The difference between `startDate` and + `endDate` should be less than 90 days. + """ + + start_date: Required[Annotated[Union[str, date], PropertyInfo(alias="startDate", format="iso8601")]] + """Specify a `startDate` in `YYYY-MM-DD` format. + + It should be before the `endDate`. The difference between `startDate` and + `endDate` should be less than 90 days. + """ diff --git a/src/imagekitio/types/accounts/usage_get_response.py b/src/imagekitio/types/accounts/usage_get_response.py new file mode 100644 index 00000000..651552c3 --- /dev/null +++ b/src/imagekitio/types/accounts/usage_get_response.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["UsageGetResponse"] + + +class UsageGetResponse(BaseModel): + bandwidth_bytes: Optional[int] = FieldInfo(alias="bandwidthBytes", default=None) + """Amount of bandwidth used in bytes.""" + + extension_units_count: Optional[int] = FieldInfo(alias="extensionUnitsCount", default=None) + """Number of extension units used.""" + + media_library_storage_bytes: Optional[int] = FieldInfo(alias="mediaLibraryStorageBytes", default=None) + """Storage used by media library in bytes.""" + + original_cache_storage_bytes: Optional[int] = FieldInfo(alias="originalCacheStorageBytes", default=None) + """Storage used by the original cache in bytes.""" + + video_processing_units_count: Optional[int] = FieldInfo(alias="videoProcessingUnitsCount", default=None) + """Number of video processing units used.""" diff --git a/src/imagekitio/types/asset_list_params.py b/src/imagekitio/types/asset_list_params.py new file mode 100644 index 00000000..ea4024e0 --- /dev/null +++ b/src/imagekitio/types/asset_list_params.py @@ -0,0 +1,81 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["AssetListParams"] + + +class AssetListParams(TypedDict, total=False): + file_type: Annotated[Literal["all", "image", "non-image"], PropertyInfo(alias="fileType")] + """Filter results by file type. + + - `all` — include all file types + - `image` — include only image files + - `non-image` — include only non-image files (e.g., JS, CSS, video) + """ + + limit: int + """The maximum number of results to return in response.""" + + path: str + """Folder path if you want to limit the search within a specific folder. + + For example, `/sales-banner/` will only search in folder sales-banner. + + Note : If your use case involves searching within a folder as well as its + subfolders, you can use `path` parameter in `searchQuery` with appropriate + operator. Checkout + [Supported parameters](/docs/api-reference/digital-asset-management-dam/list-and-search-assets#supported-parameters) + for more information. + """ + + search_query: Annotated[str, PropertyInfo(alias="searchQuery")] + """Query string in a Lucene-like query language e.g. `createdAt > "7d"`. + + Note : When the searchQuery parameter is present, the following query parameters + will have no effect on the result: + + 1. `tags` + 2. `type` + 3. `name` + + [Learn more](/docs/api-reference/digital-asset-management-dam/list-and-search-assets#advanced-search-queries) + from examples. + """ + + skip: int + """The number of results to skip before returning results.""" + + sort: Literal[ + "ASC_NAME", + "DESC_NAME", + "ASC_CREATED", + "DESC_CREATED", + "ASC_UPDATED", + "DESC_UPDATED", + "ASC_HEIGHT", + "DESC_HEIGHT", + "ASC_WIDTH", + "DESC_WIDTH", + "ASC_SIZE", + "DESC_SIZE", + "ASC_RELEVANCE", + "DESC_RELEVANCE", + ] + """ + Sort the results by one of the supported fields in ascending or descending + order. + """ + + type: Literal["file", "file-version", "folder", "all"] + """Filter results by asset type. + + - `file` — returns only files + - `file-version` — returns specific file versions + - `folder` — returns only folders + - `all` — returns both files and folders (excludes `file-version`) + """ diff --git a/src/imagekitio/types/asset_list_response.py b/src/imagekitio/types/asset_list_response.py new file mode 100644 index 00000000..00596e25 --- /dev/null +++ b/src/imagekitio/types/asset_list_response.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union +from typing_extensions import TypeAlias + +from .file import File +from .folder import Folder + +__all__ = ["AssetListResponse", "AssetListResponseItem"] + +AssetListResponseItem: TypeAlias = Union[File, Folder] + +AssetListResponse: TypeAlias = List[AssetListResponseItem] diff --git a/src/imagekitio/types/base_webhook_event.py b/src/imagekitio/types/base_webhook_event.py new file mode 100644 index 00000000..b37e44a2 --- /dev/null +++ b/src/imagekitio/types/base_webhook_event.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["BaseWebhookEvent"] + + +class BaseWebhookEvent(BaseModel): + id: str + """Unique identifier for the event.""" + + type: str + """The type of webhook event.""" diff --git a/src/imagekitio/types/beta/__init__.py b/src/imagekitio/types/beta/__init__.py new file mode 100644 index 00000000..f8ee8b14 --- /dev/null +++ b/src/imagekitio/types/beta/__init__.py @@ -0,0 +1,3 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations diff --git a/src/imagekitio/types/beta/v2/__init__.py b/src/imagekitio/types/beta/v2/__init__.py new file mode 100644 index 00000000..c5c3d837 --- /dev/null +++ b/src/imagekitio/types/beta/v2/__init__.py @@ -0,0 +1,6 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .file_upload_params import FileUploadParams as FileUploadParams +from .file_upload_response import FileUploadResponse as FileUploadResponse diff --git a/src/imagekitio/types/beta/v2/file_upload_params.py b/src/imagekitio/types/beta/v2/file_upload_params.py new file mode 100644 index 00000000..7f75be26 --- /dev/null +++ b/src/imagekitio/types/beta/v2/file_upload_params.py @@ -0,0 +1,273 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, List, Union, Iterable +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict + +from ...._types import FileTypes, SequenceNotStr +from ...._utils import PropertyInfo +from ...shared_params.extensions import Extensions + +__all__ = [ + "FileUploadParams", + "Transformation", + "TransformationPost", + "TransformationPostTransformation", + "TransformationPostGifToVideo", + "TransformationPostThumbnail", + "TransformationPostAbs", +] + + +class FileUploadParams(TypedDict, total=False): + file: Required[FileTypes] + """The API accepts any of the following: + + - **Binary data** – send the raw bytes as `multipart/form-data`. + - **HTTP / HTTPS URL** – a publicly reachable URL that ImageKit’s servers can + fetch. + - **Base64 string** – the file encoded as a Base64 data URI or plain Base64. + + When supplying a URL, the server must receive the response headers within 8 + seconds; otherwise the request fails with 400 Bad Request. + """ + + file_name: Required[Annotated[str, PropertyInfo(alias="fileName")]] + """The name with which the file has to be uploaded.""" + + token: str + """This is the client-generated JSON Web Token (JWT). + + The ImageKit.io server uses it to authenticate and check that the upload request + parameters have not been tampered with after the token has been generated. Learn + how to create the token on the page below. This field is only required for + authentication when uploading a file from the client side. + + **Note**: Sending a JWT that has been used in the past will result in a + validation error. Even if your previous request resulted in an error, you should + always send a new token. + + **⚠️Warning**: JWT must be generated on the server-side because it is generated + using your account's private API key. This field is required for authentication + when uploading a file from the client-side. + """ + + checks: str + """ + Server-side checks to run on the asset. Read more about + [Upload API checks](/docs/api-reference/upload-file/upload-file-v2#upload-api-checks). + """ + + custom_coordinates: Annotated[str, PropertyInfo(alias="customCoordinates")] + """Define an important area in the image. + + This is only relevant for image type files. + + - To be passed as a string with the x and y coordinates of the top-left corner, + and width and height of the area of interest in the format `x,y,width,height`. + For example - `10,10,100,100` + - Can be used with fo-customtransformation. + - If this field is not specified and the file is overwritten, then + customCoordinates will be removed. + """ + + custom_metadata: Annotated[Dict[str, object], PropertyInfo(alias="customMetadata")] + """JSON key-value pairs to associate with the asset. + + Create the custom metadata fields before setting these values. + """ + + description: str + """Optional text to describe the contents of the file.""" + + extensions: Extensions + """Array of extensions to be applied to the asset. + + Each extension can be configured with specific parameters based on the extension + type. + """ + + folder: str + """The folder path in which the image has to be uploaded. + + If the folder(s) didn't exist before, a new folder(s) is created. Using multiple + `/` creates a nested folder. + """ + + is_private_file: Annotated[bool, PropertyInfo(alias="isPrivateFile")] + """Whether to mark the file as private or not. + + If `true`, the file is marked as private and is accessible only using named + transformation or signed URL. + """ + + is_published: Annotated[bool, PropertyInfo(alias="isPublished")] + """Whether to upload file as published or not. + + If `false`, the file is marked as unpublished, which restricts access to the + file only via the media library. Files in draft or unpublished state can only be + publicly accessed after being published. + + The option to upload in draft state is only available in custom enterprise + pricing plans. + """ + + overwrite_ai_tags: Annotated[bool, PropertyInfo(alias="overwriteAITags")] + """ + If set to `true` and a file already exists at the exact location, its AITags + will be removed. Set `overwriteAITags` to `false` to preserve AITags. + """ + + overwrite_custom_metadata: Annotated[bool, PropertyInfo(alias="overwriteCustomMetadata")] + """ + If the request does not have `customMetadata`, and a file already exists at the + exact location, existing customMetadata will be removed. + """ + + overwrite_file: Annotated[bool, PropertyInfo(alias="overwriteFile")] + """ + If `false` and `useUniqueFileName` is also `false`, and a file already exists at + the exact location, upload API will return an error immediately. + """ + + overwrite_tags: Annotated[bool, PropertyInfo(alias="overwriteTags")] + """ + If the request does not have `tags`, and a file already exists at the exact + location, existing tags will be removed. + """ + + response_fields: Annotated[ + List[ + Literal[ + "tags", + "customCoordinates", + "isPrivateFile", + "embeddedMetadata", + "isPublished", + "customMetadata", + "metadata", + "selectedFieldsSchema", + ] + ], + PropertyInfo(alias="responseFields"), + ] + """Array of response field keys to include in the API response body.""" + + tags: SequenceNotStr[str] + """Set the tags while uploading the file. Provide an array of tag strings (e.g. + + `["tag1", "tag2", "tag3"]`). The combined length of all tag characters must not + exceed 500, and the `%` character is not allowed. If this field is not specified + and the file is overwritten, the existing tags will be removed. + """ + + transformation: Transformation + """Configure pre-processing (`pre`) and post-processing (`post`) transformations. + + - `pre` — applied before the file is uploaded to the Media Library. + Useful for reducing file size or applying basic optimizations upfront (e.g., + resize, compress). + + - `post` — applied immediately after upload. + Ideal for generating transformed versions (like video encodes or thumbnails) + in advance, so they're ready for delivery without delay. + + You can mix and match any combination of post-processing types. + """ + + use_unique_file_name: Annotated[bool, PropertyInfo(alias="useUniqueFileName")] + """Whether to use a unique filename for this file or not. + + If `true`, ImageKit.io will add a unique suffix to the filename parameter to get + a unique filename. + + If `false`, then the image is uploaded with the provided filename parameter, and + any existing file with the same name is replaced. + """ + + webhook_url: Annotated[str, PropertyInfo(alias="webhookUrl")] + """ + The final status of extensions after they have completed execution will be + delivered to this endpoint as a POST request. + [Learn more](/docs/api-reference/digital-asset-management-dam/managing-assets/update-file-details#webhook-payload-structure) + about the webhook payload structure. + """ + + +class TransformationPostTransformation(TypedDict, total=False): + type: Required[Literal["transformation"]] + """Transformation type.""" + + value: Required[str] + """Transformation string (e.g. + + `w-200,h-200`). + Same syntax as ImageKit URL-based transformations. + """ + + +class TransformationPostGifToVideo(TypedDict, total=False): + type: Required[Literal["gif-to-video"]] + """Converts an animated GIF into an MP4.""" + + value: str + """Optional transformation string to apply to the output video. + + **Example**: `q-80` + """ + + +class TransformationPostThumbnail(TypedDict, total=False): + type: Required[Literal["thumbnail"]] + """Generates a thumbnail image.""" + + value: str + """Optional transformation string. + + **Example**: `w-150,h-150` + """ + + +class TransformationPostAbs(TypedDict, total=False): + protocol: Required[Literal["hls", "dash"]] + """Streaming protocol to use (`hls` or `dash`).""" + + type: Required[Literal["abs"]] + """Adaptive Bitrate Streaming (ABS) setup.""" + + value: Required[str] + """ + List of different representations you want to create separated by an underscore. + """ + + +TransformationPost: TypeAlias = Union[ + TransformationPostTransformation, TransformationPostGifToVideo, TransformationPostThumbnail, TransformationPostAbs +] + + +class Transformation(TypedDict, total=False): + """Configure pre-processing (`pre`) and post-processing (`post`) transformations. + + - `pre` — applied before the file is uploaded to the Media Library. + Useful for reducing file size or applying basic optimizations upfront (e.g., resize, compress). + + - `post` — applied immediately after upload. + Ideal for generating transformed versions (like video encodes or thumbnails) in advance, so they're ready for delivery without delay. + + You can mix and match any combination of post-processing types. + """ + + post: Iterable[TransformationPost] + """List of transformations to apply _after_ the file is uploaded. + + Each item must match one of the following types: `transformation`, + `gif-to-video`, `thumbnail`, `abs`. + """ + + pre: str + """Transformation string to apply before uploading the file to the Media Library. + + Useful for optimizing files at ingestion. + """ diff --git a/src/imagekitio/types/beta/v2/file_upload_response.py b/src/imagekitio/types/beta/v2/file_upload_response.py new file mode 100644 index 00000000..46cb7559 --- /dev/null +++ b/src/imagekitio/types/beta/v2/file_upload_response.py @@ -0,0 +1,257 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Union, Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from ...._models import BaseModel +from ...metadata import Metadata + +__all__ = ["FileUploadResponse", "AITag", "ExtensionStatus", "SelectedFieldsSchema", "VersionInfo"] + + +class AITag(BaseModel): + confidence: Optional[float] = None + """Confidence score of the tag.""" + + name: Optional[str] = None + """Name of the tag.""" + + source: Optional[str] = None + """Array of `AITags` associated with the image. + + If no `AITags` are set, it will be null. These tags can be added using the + `google-auto-tagging` or `aws-auto-tagging` extensions. + """ + + +class ExtensionStatus(BaseModel): + """ + Extension names with their processing status at the time of completion of the request. It could have one of the following status values: + + `success`: The extension has been successfully applied. + `failed`: The extension has failed and will not be retried. + `pending`: The extension will finish processing in some time. On completion, the final status (success / failed) will be sent to the `webhookUrl` provided. + + If no extension was requested, then this parameter is not returned. + """ + + ai_auto_description: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="ai-auto-description", default=None + ) + + aws_auto_tagging: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="aws-auto-tagging", default=None + ) + + google_auto_tagging: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="google-auto-tagging", default=None + ) + + remove_bg: Optional[Literal["success", "pending", "failed"]] = FieldInfo(alias="remove-bg", default=None) + + +class SelectedFieldsSchema(BaseModel): + type: Literal["Text", "Textarea", "Number", "Date", "Boolean", "SingleSelect", "MultiSelect"] + """Type of the custom metadata field.""" + + default_value: Union[str, float, bool, List[Union[str, float, bool]], None] = FieldInfo( + alias="defaultValue", default=None + ) + """The default value for this custom metadata field. + + The value should match the `type` of custom metadata field. + """ + + is_value_required: Optional[bool] = FieldInfo(alias="isValueRequired", default=None) + """Specifies if the custom metadata field is required or not.""" + + max_length: Optional[float] = FieldInfo(alias="maxLength", default=None) + """Maximum length of string. Only set if `type` is set to `Text` or `Textarea`.""" + + max_value: Union[str, float, None] = FieldInfo(alias="maxValue", default=None) + """Maximum value of the field. + + Only set if field type is `Date` or `Number`. For `Date` type field, the value + will be in ISO8601 string format. For `Number` type field, it will be a numeric + value. + """ + + min_length: Optional[float] = FieldInfo(alias="minLength", default=None) + """Minimum length of string. Only set if `type` is set to `Text` or `Textarea`.""" + + min_value: Union[str, float, None] = FieldInfo(alias="minValue", default=None) + """Minimum value of the field. + + Only set if field type is `Date` or `Number`. For `Date` type field, the value + will be in ISO8601 string format. For `Number` type field, it will be a numeric + value. + """ + + read_only: Optional[bool] = FieldInfo(alias="readOnly", default=None) + """Indicates whether the custom metadata field is read only. + + A read only field cannot be modified after being set. This field is configurable + only via the **Path policy** feature. + """ + + select_options: Optional[List[Union[str, float, bool]]] = FieldInfo(alias="selectOptions", default=None) + """An array of allowed values when field type is `SingleSelect` or `MultiSelect`.""" + + select_options_truncated: Optional[bool] = FieldInfo(alias="selectOptionsTruncated", default=None) + """Specifies if the selectOptions array is truncated. + + It is truncated when number of options are > 100. + """ + + +class VersionInfo(BaseModel): + """An object containing the file or file version's `id` (versionId) and `name`.""" + + id: Optional[str] = None + """Unique identifier of the file version.""" + + name: Optional[str] = None + """Name of the file version.""" + + +class FileUploadResponse(BaseModel): + """Object containing details of a successful upload.""" + + ai_tags: Optional[List[AITag]] = FieldInfo(alias="AITags", default=None) + """An array of tags assigned to the uploaded file by auto tagging.""" + + audio_codec: Optional[str] = FieldInfo(alias="audioCodec", default=None) + """The audio codec used in the video (only for video).""" + + bit_rate: Optional[int] = FieldInfo(alias="bitRate", default=None) + """The bit rate of the video in kbps (only for video).""" + + custom_coordinates: Optional[str] = FieldInfo(alias="customCoordinates", default=None) + """ + Value of custom coordinates associated with the image in the format + `x,y,width,height`. If `customCoordinates` are not defined, then it is `null`. + Send `customCoordinates` in `responseFields` in API request to get the value of + this field. + """ + + custom_metadata: Optional[Dict[str, object]] = FieldInfo(alias="customMetadata", default=None) + """A key-value data associated with the asset. + + Use `responseField` in API request to get `customMetadata` in the upload API + response. Before setting any custom metadata on an asset, you have to create the + field using custom metadata fields API. Send `customMetadata` in + `responseFields` in API request to get the value of this field. + """ + + description: Optional[str] = None + """Optional text to describe the contents of the file. + + Can be set by the user or the ai-auto-description extension. + """ + + duration: Optional[int] = None + """The duration of the video in seconds (only for video).""" + + embedded_metadata: Optional[Dict[str, object]] = FieldInfo(alias="embeddedMetadata", default=None) + """Consolidated embedded metadata associated with the file. + + It includes exif, iptc, and xmp data. Send `embeddedMetadata` in + `responseFields` in API request to get embeddedMetadata in the upload API + response. + """ + + extension_status: Optional[ExtensionStatus] = FieldInfo(alias="extensionStatus", default=None) + """ + Extension names with their processing status at the time of completion of the + request. It could have one of the following status values: + + `success`: The extension has been successfully applied. `failed`: The extension + has failed and will not be retried. `pending`: The extension will finish + processing in some time. On completion, the final status (success / failed) will + be sent to the `webhookUrl` provided. + + If no extension was requested, then this parameter is not returned. + """ + + file_id: Optional[str] = FieldInfo(alias="fileId", default=None) + """Unique fileId. + + Store this fileld in your database, as this will be used to perform update + action on this file. + """ + + file_path: Optional[str] = FieldInfo(alias="filePath", default=None) + """The relative path of the file in the media library e.g. + + `/marketing-assets/new-banner.jpg`. + """ + + file_type: Optional[str] = FieldInfo(alias="fileType", default=None) + """Type of the uploaded file. Possible values are `image`, `non-image`.""" + + height: Optional[float] = None + """Height of the image in pixels (Only for images)""" + + is_private_file: Optional[bool] = FieldInfo(alias="isPrivateFile", default=None) + """Is the file marked as private. + + It can be either `true` or `false`. Send `isPrivateFile` in `responseFields` in + API request to get the value of this field. + """ + + is_published: Optional[bool] = FieldInfo(alias="isPublished", default=None) + """Is the file published or in draft state. + + It can be either `true` or `false`. Send `isPublished` in `responseFields` in + API request to get the value of this field. + """ + + metadata: Optional[Metadata] = None + """Legacy metadata. + + Send `metadata` in `responseFields` in API request to get metadata in the upload + API response. + """ + + name: Optional[str] = None + """Name of the asset.""" + + selected_fields_schema: Optional[Dict[str, SelectedFieldsSchema]] = FieldInfo( + alias="selectedFieldsSchema", default=None + ) + """ + This field is included in the response only if the Path policy feature is + available in the plan. It contains schema definitions for the custom metadata + fields selected for the specified file path. Field selection can only be done + when the Path policy feature is enabled. + + Keys are the names of the custom metadata fields; the value object has details + about the custom metadata schema. + """ + + size: Optional[float] = None + """Size of the image file in Bytes.""" + + tags: Optional[List[str]] = None + """The array of tags associated with the asset. + + If no tags are set, it will be `null`. Send `tags` in `responseFields` in API + request to get the value of this field. + """ + + thumbnail_url: Optional[str] = FieldInfo(alias="thumbnailUrl", default=None) + """In the case of an image, a small thumbnail URL.""" + + url: Optional[str] = None + """A publicly accessible URL of the file.""" + + version_info: Optional[VersionInfo] = FieldInfo(alias="versionInfo", default=None) + """An object containing the file or file version's `id` (versionId) and `name`.""" + + video_codec: Optional[str] = FieldInfo(alias="videoCodec", default=None) + """The video codec used in the video (only for video).""" + + width: Optional[float] = None + """Width of the image in pixels (Only for Images)""" diff --git a/src/imagekitio/types/cache/__init__.py b/src/imagekitio/types/cache/__init__.py new file mode 100644 index 00000000..76e5283c --- /dev/null +++ b/src/imagekitio/types/cache/__init__.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .invalidation_get_response import InvalidationGetResponse as InvalidationGetResponse +from .invalidation_create_params import InvalidationCreateParams as InvalidationCreateParams +from .invalidation_create_response import InvalidationCreateResponse as InvalidationCreateResponse diff --git a/src/imagekitio/types/cache/invalidation_create_params.py b/src/imagekitio/types/cache/invalidation_create_params.py new file mode 100644 index 00000000..55df58fe --- /dev/null +++ b/src/imagekitio/types/cache/invalidation_create_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["InvalidationCreateParams"] + + +class InvalidationCreateParams(TypedDict, total=False): + url: Required[str] + """The full URL of the file to be purged.""" diff --git a/src/imagekitio/types/cache/invalidation_create_response.py b/src/imagekitio/types/cache/invalidation_create_response.py new file mode 100644 index 00000000..2dfbce88 --- /dev/null +++ b/src/imagekitio/types/cache/invalidation_create_response.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["InvalidationCreateResponse"] + + +class InvalidationCreateResponse(BaseModel): + request_id: Optional[str] = FieldInfo(alias="requestId", default=None) + """Unique identifier of the purge request. + + This can be used to check the status of the purge request. + """ diff --git a/src/imagekitio/types/cache/invalidation_get_response.py b/src/imagekitio/types/cache/invalidation_get_response.py new file mode 100644 index 00000000..96c13046 --- /dev/null +++ b/src/imagekitio/types/cache/invalidation_get_response.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["InvalidationGetResponse"] + + +class InvalidationGetResponse(BaseModel): + status: Optional[Literal["Pending", "Completed"]] = None + """Status of the purge request.""" diff --git a/src/imagekitio/types/custom_metadata_field.py b/src/imagekitio/types/custom_metadata_field.py new file mode 100644 index 00000000..1ebde723 --- /dev/null +++ b/src/imagekitio/types/custom_metadata_field.py @@ -0,0 +1,77 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["CustomMetadataField", "Schema"] + + +class Schema(BaseModel): + """An object that describes the rules for the custom metadata field value.""" + + type: Literal["Text", "Textarea", "Number", "Date", "Boolean", "SingleSelect", "MultiSelect"] + """Type of the custom metadata field.""" + + default_value: Union[str, float, bool, List[Union[str, float, bool]], None] = FieldInfo( + alias="defaultValue", default=None + ) + """The default value for this custom metadata field. + + Data type of default value depends on the field type. + """ + + is_value_required: Optional[bool] = FieldInfo(alias="isValueRequired", default=None) + """Specifies if the this custom metadata field is required or not.""" + + max_length: Optional[float] = FieldInfo(alias="maxLength", default=None) + """Maximum length of string. Only set if `type` is set to `Text` or `Textarea`.""" + + max_value: Union[str, float, None] = FieldInfo(alias="maxValue", default=None) + """Maximum value of the field. + + Only set if field type is `Date` or `Number`. For `Date` type field, the value + will be in ISO8601 string format. For `Number` type field, it will be a numeric + value. + """ + + min_length: Optional[float] = FieldInfo(alias="minLength", default=None) + """Minimum length of string. Only set if `type` is set to `Text` or `Textarea`.""" + + min_value: Union[str, float, None] = FieldInfo(alias="minValue", default=None) + """Minimum value of the field. + + Only set if field type is `Date` or `Number`. For `Date` type field, the value + will be in ISO8601 string format. For `Number` type field, it will be a numeric + value. + """ + + select_options: Optional[List[Union[str, float, bool]]] = FieldInfo(alias="selectOptions", default=None) + """An array of allowed values when field type is `SingleSelect` or `MultiSelect`.""" + + +class CustomMetadataField(BaseModel): + """Object containing details of a custom metadata field.""" + + id: str + """Unique identifier for the custom metadata field. Use this to update the field.""" + + label: str + """Human readable name of the custom metadata field. + + This name is displayed as form field label to the users while setting field + value on the asset in the media library UI. + """ + + name: str + """API name of the custom metadata field. + + This becomes the key while setting `customMetadata` (key-value object) for an + asset using upload or update API. + """ + + schema_: Schema = FieldInfo(alias="schema") + """An object that describes the rules for the custom metadata field value.""" diff --git a/src/imagekitio/types/custom_metadata_field_create_params.py b/src/imagekitio/types/custom_metadata_field_create_params.py new file mode 100644 index 00000000..0e265b09 --- /dev/null +++ b/src/imagekitio/types/custom_metadata_field_create_params.py @@ -0,0 +1,85 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["CustomMetadataFieldCreateParams", "Schema"] + + +class CustomMetadataFieldCreateParams(TypedDict, total=False): + label: Required[str] + """Human readable name of the custom metadata field. + + This should be unique across all non deleted custom metadata fields. This name + is displayed as form field label to the users while setting field value on an + asset in the media library UI. + """ + + name: Required[str] + """API name of the custom metadata field. + + This should be unique across all (including deleted) custom metadata fields. + """ + + schema: Required[Schema] + + +class Schema(TypedDict, total=False): + type: Required[Literal["Text", "Textarea", "Number", "Date", "Boolean", "SingleSelect", "MultiSelect"]] + """Type of the custom metadata field.""" + + default_value: Annotated[ + Union[str, float, bool, SequenceNotStr[Union[str, float, bool]]], PropertyInfo(alias="defaultValue") + ] + """The default value for this custom metadata field. + + This property is only required if `isValueRequired` property is set to `true`. + The value should match the `type` of custom metadata field. + """ + + is_value_required: Annotated[bool, PropertyInfo(alias="isValueRequired")] + """Sets this custom metadata field as required. + + Setting custom metadata fields on an asset will throw error if the value for all + required fields are not present in upload or update asset API request body. + """ + + max_length: Annotated[float, PropertyInfo(alias="maxLength")] + """Maximum length of string. + + Only set this property if `type` is set to `Text` or `Textarea`. + """ + + max_value: Annotated[Union[str, float], PropertyInfo(alias="maxValue")] + """Maximum value of the field. + + Only set this property if field type is `Date` or `Number`. For `Date` type + field, set the minimum date in ISO8601 string format. For `Number` type field, + set the minimum numeric value. + """ + + min_length: Annotated[float, PropertyInfo(alias="minLength")] + """Minimum length of string. + + Only set this property if `type` is set to `Text` or `Textarea`. + """ + + min_value: Annotated[Union[str, float], PropertyInfo(alias="minValue")] + """Minimum value of the field. + + Only set this property if field type is `Date` or `Number`. For `Date` type + field, set the minimum date in ISO8601 string format. For `Number` type field, + set the minimum numeric value. + """ + + select_options: Annotated[SequenceNotStr[Union[str, float, bool]], PropertyInfo(alias="selectOptions")] + """An array of allowed values. + + This property is only required if `type` property is set to `SingleSelect` or + `MultiSelect`. + """ diff --git a/src/imagekitio/types/custom_metadata_field_delete_response.py b/src/imagekitio/types/custom_metadata_field_delete_response.py new file mode 100644 index 00000000..247d6a95 --- /dev/null +++ b/src/imagekitio/types/custom_metadata_field_delete_response.py @@ -0,0 +1,9 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["CustomMetadataFieldDeleteResponse"] + + +class CustomMetadataFieldDeleteResponse(BaseModel): + pass diff --git a/src/imagekitio/types/custom_metadata_field_list_params.py b/src/imagekitio/types/custom_metadata_field_list_params.py new file mode 100644 index 00000000..a84f93ea --- /dev/null +++ b/src/imagekitio/types/custom_metadata_field_list_params.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["CustomMetadataFieldListParams"] + + +class CustomMetadataFieldListParams(TypedDict, total=False): + folder_path: Annotated[str, PropertyInfo(alias="folderPath")] + """ + The folder path (e.g., `/path/to/folder`) for which to retrieve applicable + custom metadata fields. Useful for determining path-specific field selections + when the [Path policy](https://imagekit.io/docs/dam/path-policy) feature is in + use. + """ + + include_deleted: Annotated[bool, PropertyInfo(alias="includeDeleted")] + """Set it to `true` to include deleted field objects in the API response.""" diff --git a/src/imagekitio/types/custom_metadata_field_list_response.py b/src/imagekitio/types/custom_metadata_field_list_response.py new file mode 100644 index 00000000..f3928746 --- /dev/null +++ b/src/imagekitio/types/custom_metadata_field_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .custom_metadata_field import CustomMetadataField + +__all__ = ["CustomMetadataFieldListResponse"] + +CustomMetadataFieldListResponse: TypeAlias = List[CustomMetadataField] diff --git a/src/imagekitio/types/custom_metadata_field_update_params.py b/src/imagekitio/types/custom_metadata_field_update_params.py new file mode 100644 index 00000000..fbb9effc --- /dev/null +++ b/src/imagekitio/types/custom_metadata_field_update_params.py @@ -0,0 +1,88 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["CustomMetadataFieldUpdateParams", "Schema"] + + +class CustomMetadataFieldUpdateParams(TypedDict, total=False): + label: str + """Human readable name of the custom metadata field. + + This should be unique across all non deleted custom metadata fields. This name + is displayed as form field label to the users while setting field value on an + asset in the media library UI. This parameter is required if `schema` is not + provided. + """ + + schema: Schema + """An object that describes the rules for the custom metadata key. + + This parameter is required if `label` is not provided. Note: `type` cannot be + updated and will be ignored if sent with the `schema`. The schema will be + validated as per the existing `type`. + """ + + +class Schema(TypedDict, total=False): + """An object that describes the rules for the custom metadata key. + + This parameter is required if `label` is not provided. Note: `type` cannot be updated and will be ignored if sent with the `schema`. The schema will be validated as per the existing `type`. + """ + + default_value: Annotated[ + Union[str, float, bool, SequenceNotStr[Union[str, float, bool]]], PropertyInfo(alias="defaultValue") + ] + """The default value for this custom metadata field. + + This property is only required if `isValueRequired` property is set to `true`. + The value should match the `type` of custom metadata field. + """ + + is_value_required: Annotated[bool, PropertyInfo(alias="isValueRequired")] + """Sets this custom metadata field as required. + + Setting custom metadata fields on an asset will throw error if the value for all + required fields are not present in upload or update asset API request body. + """ + + max_length: Annotated[float, PropertyInfo(alias="maxLength")] + """Maximum length of string. + + Only set this property if `type` is set to `Text` or `Textarea`. + """ + + max_value: Annotated[Union[str, float], PropertyInfo(alias="maxValue")] + """Maximum value of the field. + + Only set this property if field type is `Date` or `Number`. For `Date` type + field, set the minimum date in ISO8601 string format. For `Number` type field, + set the minimum numeric value. + """ + + min_length: Annotated[float, PropertyInfo(alias="minLength")] + """Minimum length of string. + + Only set this property if `type` is set to `Text` or `Textarea`. + """ + + min_value: Annotated[Union[str, float], PropertyInfo(alias="minValue")] + """Minimum value of the field. + + Only set this property if field type is `Date` or `Number`. For `Date` type + field, set the minimum date in ISO8601 string format. For `Number` type field, + set the minimum numeric value. + """ + + select_options: Annotated[SequenceNotStr[Union[str, float, bool]], PropertyInfo(alias="selectOptions")] + """An array of allowed values. + + This property is only required if `type` property is set to `SingleSelect` or + `MultiSelect`. + """ diff --git a/src/imagekitio/types/dummy_create_params.py b/src/imagekitio/types/dummy_create_params.py new file mode 100644 index 00000000..e21a3964 --- /dev/null +++ b/src/imagekitio/types/dummy_create_params.py @@ -0,0 +1,124 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Annotated, TypedDict + +from .._utils import PropertyInfo +from .shared_params.extensions import Extensions +from .shared_params.base_overlay import BaseOverlay +from .shared_params.text_overlay import TextOverlay +from .shared.streaming_resolution import StreamingResolution +from .shared_params.overlay_timing import OverlayTiming +from .shared.transformation_position import TransformationPosition +from .shared_params.overlay_position import OverlayPosition +from .shared_params.subtitle_overlay import SubtitleOverlay +from .shared_params.solid_color_overlay import SolidColorOverlay +from .shared_params.responsive_image_attributes import ResponsiveImageAttributes +from .shared_params.text_overlay_transformation import TextOverlayTransformation +from .shared_params.subtitle_overlay_transformation import SubtitleOverlayTransformation +from .shared_params.solid_color_overlay_transformation import SolidColorOverlayTransformation + +__all__ = ["DummyCreateParams"] + + +class DummyCreateParams(TypedDict, total=False): + base_overlay: Annotated[BaseOverlay, PropertyInfo(alias="baseOverlay")] + + extensions: Extensions + """Array of extensions to be applied to the asset. + + Each extension can be configured with specific parameters based on the extension + type. + """ + + get_image_attributes_options: Annotated[ + "GetImageAttributesOptions", PropertyInfo(alias="getImageAttributesOptions") + ] + """ + Options for generating responsive image attributes including `src`, `srcSet`, + and `sizes` for HTML `` elements. This schema extends `SrcOptions` to add + support for responsive image generation with breakpoints. + """ + + image_overlay: Annotated["ImageOverlay", PropertyInfo(alias="imageOverlay")] + + overlay: "Overlay" + """Specifies an overlay to be applied on the parent image or video. + + ImageKit supports overlays including images, text, videos, subtitles, and solid + colors. See + [Overlay using layers](https://imagekit.io/docs/transformations#overlay-using-layers). + """ + + overlay_position: Annotated[OverlayPosition, PropertyInfo(alias="overlayPosition")] + + overlay_timing: Annotated[OverlayTiming, PropertyInfo(alias="overlayTiming")] + + responsive_image_attributes: Annotated[ResponsiveImageAttributes, PropertyInfo(alias="responsiveImageAttributes")] + """ + Resulting set of attributes suitable for an HTML `` element. Useful for + enabling responsive image loading with `srcSet` and `sizes`. + """ + + solid_color_overlay: Annotated[SolidColorOverlay, PropertyInfo(alias="solidColorOverlay")] + + solid_color_overlay_transformation: Annotated[ + SolidColorOverlayTransformation, PropertyInfo(alias="solidColorOverlayTransformation") + ] + + src_options: Annotated["SrcOptions", PropertyInfo(alias="srcOptions")] + """Options for generating ImageKit URLs with transformations. + + See the [Transformations guide](https://imagekit.io/docs/transformations). + """ + + streaming_resolution: Annotated[StreamingResolution, PropertyInfo(alias="streamingResolution")] + """ + Available streaming resolutions for + [adaptive bitrate streaming](https://imagekit.io/docs/adaptive-bitrate-streaming) + """ + + subtitle_overlay: Annotated[SubtitleOverlay, PropertyInfo(alias="subtitleOverlay")] + + subtitle_overlay_transformation: Annotated[ + SubtitleOverlayTransformation, PropertyInfo(alias="subtitleOverlayTransformation") + ] + """Subtitle styling options. + + [Learn more](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + from the docs. + """ + + text_overlay: Annotated[TextOverlay, PropertyInfo(alias="textOverlay")] + + text_overlay_transformation: Annotated[TextOverlayTransformation, PropertyInfo(alias="textOverlayTransformation")] + + transformation: "Transformation" + """The SDK provides easy-to-use names for transformations. + + These names are converted to the corresponding transformation string before + being added to the URL. SDKs are updated regularly to support new + transformations. If you want to use a transformation that is not supported by + the SDK, You can use the `raw` parameter to pass the transformation string + directly. See the + [Transformations documentation](https://imagekit.io/docs/transformations). + """ + + transformation_position: Annotated[TransformationPosition, PropertyInfo(alias="transformationPosition")] + """ + By default, the transformation string is added as a query parameter in the URL, + e.g., `?tr=w-100,h-100`. If you want to add the transformation string in the + path of the URL, set this to `path`. Learn more in the + [Transformations guide](https://imagekit.io/docs/transformations). + """ + + video_overlay: Annotated["VideoOverlay", PropertyInfo(alias="videoOverlay")] + + +from .shared_params.overlay import Overlay +from .shared_params.src_options import SrcOptions +from .shared_params.image_overlay import ImageOverlay +from .shared_params.video_overlay import VideoOverlay +from .shared_params.transformation import Transformation +from .shared_params.get_image_attributes_options import GetImageAttributesOptions diff --git a/src/imagekitio/types/file.py b/src/imagekitio/types/file.py new file mode 100644 index 00000000..d4a1573f --- /dev/null +++ b/src/imagekitio/types/file.py @@ -0,0 +1,192 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Union, Optional +from datetime import datetime +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["File", "AITag", "SelectedFieldsSchema", "VersionInfo"] + + +class AITag(BaseModel): + confidence: Optional[float] = None + """Confidence score of the tag.""" + + name: Optional[str] = None + """Name of the tag.""" + + source: Optional[str] = None + """Source of the tag. + + Possible values are `google-auto-tagging` and `aws-auto-tagging`. + """ + + +class SelectedFieldsSchema(BaseModel): + type: Literal["Text", "Textarea", "Number", "Date", "Boolean", "SingleSelect", "MultiSelect"] + """Type of the custom metadata field.""" + + default_value: Union[str, float, bool, List[Union[str, float, bool]], None] = FieldInfo( + alias="defaultValue", default=None + ) + """The default value for this custom metadata field. + + The value should match the `type` of custom metadata field. + """ + + is_value_required: Optional[bool] = FieldInfo(alias="isValueRequired", default=None) + """Specifies if the custom metadata field is required or not.""" + + max_length: Optional[float] = FieldInfo(alias="maxLength", default=None) + """Maximum length of string. Only set if `type` is set to `Text` or `Textarea`.""" + + max_value: Union[str, float, None] = FieldInfo(alias="maxValue", default=None) + """Maximum value of the field. + + Only set if field type is `Date` or `Number`. For `Date` type field, the value + will be in ISO8601 string format. For `Number` type field, it will be a numeric + value. + """ + + min_length: Optional[float] = FieldInfo(alias="minLength", default=None) + """Minimum length of string. Only set if `type` is set to `Text` or `Textarea`.""" + + min_value: Union[str, float, None] = FieldInfo(alias="minValue", default=None) + """Minimum value of the field. + + Only set if field type is `Date` or `Number`. For `Date` type field, the value + will be in ISO8601 string format. For `Number` type field, it will be a numeric + value. + """ + + read_only: Optional[bool] = FieldInfo(alias="readOnly", default=None) + """Indicates whether the custom metadata field is read only. + + A read only field cannot be modified after being set. This field is configurable + only via the **Path policy** feature. + """ + + select_options: Optional[List[Union[str, float, bool]]] = FieldInfo(alias="selectOptions", default=None) + """An array of allowed values when field type is `SingleSelect` or `MultiSelect`.""" + + select_options_truncated: Optional[bool] = FieldInfo(alias="selectOptionsTruncated", default=None) + """Specifies if the selectOptions array is truncated. + + It is truncated when number of options are > 100. + """ + + +class VersionInfo(BaseModel): + """An object with details of the file version.""" + + id: Optional[str] = None + """Unique identifier of the file version.""" + + name: Optional[str] = None + """Name of the file version.""" + + +class File(BaseModel): + """Object containing details of a file or file version.""" + + ai_tags: Optional[List[AITag]] = FieldInfo(alias="AITags", default=None) + """An array of tags assigned to the file by auto tagging.""" + + created_at: Optional[datetime] = FieldInfo(alias="createdAt", default=None) + """Date and time when the file was uploaded. + + The date and time is in ISO8601 format. + """ + + custom_coordinates: Optional[str] = FieldInfo(alias="customCoordinates", default=None) + """An string with custom coordinates of the file.""" + + custom_metadata: Optional[Dict[str, object]] = FieldInfo(alias="customMetadata", default=None) + """An object with custom metadata for the file.""" + + description: Optional[str] = None + """Optional text to describe the contents of the file. + + Can be set by the user or the ai-auto-description extension. + """ + + file_id: Optional[str] = FieldInfo(alias="fileId", default=None) + """Unique identifier of the asset.""" + + file_path: Optional[str] = FieldInfo(alias="filePath", default=None) + """Path of the file. + + This is the path you would use in the URL to access the file. For example, if + the file is at the root of the media library, the path will be `/file.jpg`. If + the file is inside a folder named `images`, the path will be `/images/file.jpg`. + """ + + file_type: Optional[str] = FieldInfo(alias="fileType", default=None) + """Type of the file. Possible values are `image`, `non-image`.""" + + has_alpha: Optional[bool] = FieldInfo(alias="hasAlpha", default=None) + """Specifies if the image has an alpha channel.""" + + height: Optional[float] = None + """Height of the file.""" + + is_private_file: Optional[bool] = FieldInfo(alias="isPrivateFile", default=None) + """Specifies if the file is private or not.""" + + is_published: Optional[bool] = FieldInfo(alias="isPublished", default=None) + """Specifies if the file is published or not.""" + + mime: Optional[str] = None + """MIME type of the file.""" + + name: Optional[str] = None + """Name of the asset.""" + + selected_fields_schema: Optional[Dict[str, SelectedFieldsSchema]] = FieldInfo( + alias="selectedFieldsSchema", default=None + ) + """ + This field is included in the response only if the Path policy feature is + available in the plan. It contains schema definitions for the custom metadata + fields selected for the specified file path. Field selection can only be done + when the Path policy feature is enabled. + + Keys are the names of the custom metadata fields; the value object has details + about the custom metadata schema. + """ + + size: Optional[float] = None + """Size of the file in bytes.""" + + tags: Optional[List[str]] = None + """An array of tags assigned to the file. + + Tags are used to search files in the media library. + """ + + thumbnail: Optional[str] = None + """URL of the thumbnail image. + + This URL is used to access the thumbnail image of the file in the media library. + """ + + type: Optional[Literal["file", "file-version"]] = None + """Type of the asset.""" + + updated_at: Optional[datetime] = FieldInfo(alias="updatedAt", default=None) + """Date and time when the file was last updated. + + The date and time is in ISO8601 format. + """ + + url: Optional[str] = None + """URL of the file.""" + + version_info: Optional[VersionInfo] = FieldInfo(alias="versionInfo", default=None) + """An object with details of the file version.""" + + width: Optional[float] = None + """Width of the file.""" diff --git a/src/imagekitio/types/file_copy_params.py b/src/imagekitio/types/file_copy_params.py new file mode 100644 index 00000000..e8a8f946 --- /dev/null +++ b/src/imagekitio/types/file_copy_params.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["FileCopyParams"] + + +class FileCopyParams(TypedDict, total=False): + destination_path: Required[Annotated[str, PropertyInfo(alias="destinationPath")]] + """Full path to the folder you want to copy the above file into.""" + + source_file_path: Required[Annotated[str, PropertyInfo(alias="sourceFilePath")]] + """The full path of the file you want to copy.""" + + include_file_versions: Annotated[bool, PropertyInfo(alias="includeFileVersions")] + """Option to copy all versions of a file. + + By default, only the current version of the file is copied. When set to true, + all versions of the file will be copied. Default value - `false`. + """ diff --git a/src/imagekitio/types/file_copy_response.py b/src/imagekitio/types/file_copy_response.py new file mode 100644 index 00000000..81267b0d --- /dev/null +++ b/src/imagekitio/types/file_copy_response.py @@ -0,0 +1,9 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["FileCopyResponse"] + + +class FileCopyResponse(BaseModel): + pass diff --git a/src/imagekitio/types/file_move_params.py b/src/imagekitio/types/file_move_params.py new file mode 100644 index 00000000..1fc2a9e6 --- /dev/null +++ b/src/imagekitio/types/file_move_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["FileMoveParams"] + + +class FileMoveParams(TypedDict, total=False): + destination_path: Required[Annotated[str, PropertyInfo(alias="destinationPath")]] + """Full path to the folder you want to move the above file into.""" + + source_file_path: Required[Annotated[str, PropertyInfo(alias="sourceFilePath")]] + """The full path of the file you want to move.""" diff --git a/src/imagekitio/types/file_move_response.py b/src/imagekitio/types/file_move_response.py new file mode 100644 index 00000000..b92b949f --- /dev/null +++ b/src/imagekitio/types/file_move_response.py @@ -0,0 +1,9 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["FileMoveResponse"] + + +class FileMoveResponse(BaseModel): + pass diff --git a/src/imagekitio/types/file_rename_params.py b/src/imagekitio/types/file_rename_params.py new file mode 100644 index 00000000..22ada893 --- /dev/null +++ b/src/imagekitio/types/file_rename_params.py @@ -0,0 +1,42 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["FileRenameParams"] + + +class FileRenameParams(TypedDict, total=False): + file_path: Required[Annotated[str, PropertyInfo(alias="filePath")]] + """The full path of the file you want to rename.""" + + new_file_name: Required[Annotated[str, PropertyInfo(alias="newFileName")]] + """The new name of the file. A filename can contain: + + Alphanumeric Characters: `a-z`, `A-Z`, `0-9` (including Unicode letters, marks, + and numerals in other languages). Special Characters: `.`, `_`, and `-`. + + Any other character, including space, will be replaced by `_`. + """ + + purge_cache: Annotated[bool, PropertyInfo(alias="purgeCache")] + """Option to purge cache for the old file and its versions' URLs. + + When set to true, it will internally issue a purge cache request on CDN to + remove cached content of old file and its versions. This purge request is + counted against your monthly purge quota. + + Note: If the old file were accessible at + `https://ik.imagekit.io/demo/old-filename.jpg`, a purge cache request would be + issued against `https://ik.imagekit.io/demo/old-filename.jpg*` (with a wildcard + at the end). It will remove the file and its versions' URLs and any + transformations made using query parameters on this file or its versions. + However, the cache for file transformations made using path parameters will + persist. You can purge them using the purge API. For more details, refer to the + purge API documentation. + + Default value - `false` + """ diff --git a/src/imagekitio/types/file_rename_response.py b/src/imagekitio/types/file_rename_response.py new file mode 100644 index 00000000..2ef8d821 --- /dev/null +++ b/src/imagekitio/types/file_rename_response.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["FileRenameResponse"] + + +class FileRenameResponse(BaseModel): + purge_request_id: Optional[str] = FieldInfo(alias="purgeRequestId", default=None) + """Unique identifier of the purge request. + + This can be used to check the status of the purge request. + """ diff --git a/src/imagekitio/types/file_update_params.py b/src/imagekitio/types/file_update_params.py new file mode 100644 index 00000000..503bafe5 --- /dev/null +++ b/src/imagekitio/types/file_update_params.py @@ -0,0 +1,85 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Optional +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo +from .shared_params.extensions import Extensions + +__all__ = ["FileUpdateParams", "UpdateFileDetails", "ChangePublicationStatus", "ChangePublicationStatusPublish"] + + +class UpdateFileDetails(TypedDict, total=False): + custom_coordinates: Annotated[Optional[str], PropertyInfo(alias="customCoordinates")] + """Define an important area in the image in the format `x,y,width,height` e.g. + + `10,10,100,100`. Send `null` to unset this value. + """ + + custom_metadata: Annotated[Dict[str, object], PropertyInfo(alias="customMetadata")] + """A key-value data to be associated with the asset. + + To unset a key, send `null` value for that key. Before setting any custom + metadata on an asset you have to create the field using custom metadata fields + API. + """ + + description: str + """Optional text to describe the contents of the file.""" + + extensions: Extensions + """Array of extensions to be applied to the asset. + + Each extension can be configured with specific parameters based on the extension + type. + """ + + remove_ai_tags: Annotated[Union[SequenceNotStr[str], Literal["all"]], PropertyInfo(alias="removeAITags")] + """An array of AITags associated with the file that you want to remove, e.g. + + `["car", "vehicle", "motorsports"]`. + + If you want to remove all AITags associated with the file, send a string - + "all". + + Note: The remove operation for `AITags` executes before any of the `extensions` + are processed. + """ + + tags: Optional[SequenceNotStr[str]] + """An array of tags associated with the file, such as `["tag1", "tag2"]`. + + Send `null` to unset all tags associated with the file. + """ + + webhook_url: Annotated[str, PropertyInfo(alias="webhookUrl")] + """ + The final status of extensions after they have completed execution will be + delivered to this endpoint as a POST request. + [Learn more](/docs/api-reference/digital-asset-management-dam/managing-assets/update-file-details#webhook-payload-structure) + about the webhook payload structure. + """ + + +class ChangePublicationStatus(TypedDict, total=False): + publish: ChangePublicationStatusPublish + """Configure the publication status of a file and its versions.""" + + +class ChangePublicationStatusPublish(TypedDict, total=False): + """Configure the publication status of a file and its versions.""" + + is_published: Required[Annotated[bool, PropertyInfo(alias="isPublished")]] + """Set to `true` to publish the file. Set to `false` to unpublish the file.""" + + include_file_versions: Annotated[bool, PropertyInfo(alias="includeFileVersions")] + """Set to `true` to publish/unpublish all versions of the file. + + Set to `false` to publish/unpublish only the current version of the file. + """ + + +FileUpdateParams: TypeAlias = Union[UpdateFileDetails, ChangePublicationStatus] diff --git a/src/imagekitio/types/file_update_response.py b/src/imagekitio/types/file_update_response.py new file mode 100644 index 00000000..936e3361 --- /dev/null +++ b/src/imagekitio/types/file_update_response.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .file import File +from .._models import BaseModel + +__all__ = ["FileUpdateResponse", "FileUpdateResponseExtensionStatus"] + + +class FileUpdateResponseExtensionStatus(BaseModel): + ai_auto_description: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="ai-auto-description", default=None + ) + + aws_auto_tagging: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="aws-auto-tagging", default=None + ) + + google_auto_tagging: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="google-auto-tagging", default=None + ) + + remove_bg: Optional[Literal["success", "pending", "failed"]] = FieldInfo(alias="remove-bg", default=None) + + +class FileUpdateResponse(File): + """Object containing details of a file or file version.""" + + extension_status: Optional[FileUpdateResponseExtensionStatus] = FieldInfo(alias="extensionStatus", default=None) diff --git a/src/imagekitio/types/file_upload_params.py b/src/imagekitio/types/file_upload_params.py new file mode 100644 index 00000000..1dd9a23d --- /dev/null +++ b/src/imagekitio/types/file_upload_params.py @@ -0,0 +1,305 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, List, Union, Iterable +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict + +from .._types import FileTypes, SequenceNotStr +from .._utils import PropertyInfo +from .shared_params.extensions import Extensions + +__all__ = [ + "FileUploadParams", + "Transformation", + "TransformationPost", + "TransformationPostTransformation", + "TransformationPostGifToVideo", + "TransformationPostThumbnail", + "TransformationPostAbs", +] + + +class FileUploadParams(TypedDict, total=False): + file: Required[FileTypes] + """The API accepts any of the following: + + - **Binary data** – send the raw bytes as `multipart/form-data`. + - **HTTP / HTTPS URL** – a publicly reachable URL that ImageKit’s servers can + fetch. + - **Base64 string** – the file encoded as a Base64 data URI or plain Base64. + + When supplying a URL, the server must receive the response headers within 8 + seconds; otherwise the request fails with 400 Bad Request. + """ + + file_name: Required[Annotated[str, PropertyInfo(alias="fileName")]] + """The name with which the file has to be uploaded. The file name can contain: + + - Alphanumeric Characters: `a-z`, `A-Z`, `0-9`. + - Special Characters: `.`, `-` + + Any other character including space will be replaced by `_` + """ + + token: str + """ + A unique value that the ImageKit.io server will use to recognize and prevent + subsequent retries for the same request. We suggest using V4 UUIDs, or another + random string with enough entropy to avoid collisions. This field is only + required for authentication when uploading a file from the client side. + + **Note**: Sending a value that has been used in the past will result in a + validation error. Even if your previous request resulted in an error, you should + always send a new value for this field. + """ + + checks: str + """ + Server-side checks to run on the asset. Read more about + [Upload API checks](/docs/api-reference/upload-file/upload-file#upload-api-checks). + """ + + custom_coordinates: Annotated[str, PropertyInfo(alias="customCoordinates")] + """Define an important area in the image. + + This is only relevant for image type files. + + - To be passed as a string with the x and y coordinates of the top-left corner, + and width and height of the area of interest in the format `x,y,width,height`. + For example - `10,10,100,100` + - Can be used with fo-customtransformation. + - If this field is not specified and the file is overwritten, then + customCoordinates will be removed. + """ + + custom_metadata: Annotated[Dict[str, object], PropertyInfo(alias="customMetadata")] + """JSON key-value pairs to associate with the asset. + + Create the custom metadata fields before setting these values. + """ + + description: str + """Optional text to describe the contents of the file.""" + + expire: int + """The time until your signature is valid. + + It must be a [Unix time](https://en.wikipedia.org/wiki/Unix_time) in less than 1 + hour into the future. It should be in seconds. This field is only required for + authentication when uploading a file from the client side. + """ + + extensions: Extensions + """Array of extensions to be applied to the asset. + + Each extension can be configured with specific parameters based on the extension + type. + """ + + folder: str + """The folder path in which the image has to be uploaded. + + If the folder(s) didn't exist before, a new folder(s) is created. + + The folder name can contain: + + - Alphanumeric Characters: `a-z` , `A-Z` , `0-9` + - Special Characters: `/` , `_` , `-` + + Using multiple `/` creates a nested folder. + """ + + is_private_file: Annotated[bool, PropertyInfo(alias="isPrivateFile")] + """Whether to mark the file as private or not. + + If `true`, the file is marked as private and is accessible only using named + transformation or signed URL. + """ + + is_published: Annotated[bool, PropertyInfo(alias="isPublished")] + """Whether to upload file as published or not. + + If `false`, the file is marked as unpublished, which restricts access to the + file only via the media library. Files in draft or unpublished state can only be + publicly accessed after being published. + + The option to upload in draft state is only available in custom enterprise + pricing plans. + """ + + overwrite_ai_tags: Annotated[bool, PropertyInfo(alias="overwriteAITags")] + """ + If set to `true` and a file already exists at the exact location, its AITags + will be removed. Set `overwriteAITags` to `false` to preserve AITags. + """ + + overwrite_custom_metadata: Annotated[bool, PropertyInfo(alias="overwriteCustomMetadata")] + """ + If the request does not have `customMetadata`, and a file already exists at the + exact location, existing customMetadata will be removed. + """ + + overwrite_file: Annotated[bool, PropertyInfo(alias="overwriteFile")] + """ + If `false` and `useUniqueFileName` is also `false`, and a file already exists at + the exact location, upload API will return an error immediately. + """ + + overwrite_tags: Annotated[bool, PropertyInfo(alias="overwriteTags")] + """ + If the request does not have `tags`, and a file already exists at the exact + location, existing tags will be removed. + """ + + public_key: Annotated[str, PropertyInfo(alias="publicKey")] + """Your ImageKit.io public key. + + This field is only required for authentication when uploading a file from the + client side. + """ + + response_fields: Annotated[ + List[ + Literal[ + "tags", + "customCoordinates", + "isPrivateFile", + "embeddedMetadata", + "isPublished", + "customMetadata", + "metadata", + "selectedFieldsSchema", + ] + ], + PropertyInfo(alias="responseFields"), + ] + """Array of response field keys to include in the API response body.""" + + signature: str + """ + HMAC-SHA1 digest of the token+expire using your ImageKit.io private API key as a + key. Learn how to create a signature on the page below. This should be in + lowercase. + + Signature must be calculated on the server-side. This field is only required for + authentication when uploading a file from the client side. + """ + + tags: SequenceNotStr[str] + """Set the tags while uploading the file. Provide an array of tag strings (e.g. + + `["tag1", "tag2", "tag3"]`). The combined length of all tag characters must not + exceed 500, and the `%` character is not allowed. If this field is not specified + and the file is overwritten, the existing tags will be removed. + """ + + transformation: Transformation + """Configure pre-processing (`pre`) and post-processing (`post`) transformations. + + - `pre` — applied before the file is uploaded to the Media Library. + Useful for reducing file size or applying basic optimizations upfront (e.g., + resize, compress). + + - `post` — applied immediately after upload. + Ideal for generating transformed versions (like video encodes or thumbnails) + in advance, so they're ready for delivery without delay. + + You can mix and match any combination of post-processing types. + """ + + use_unique_file_name: Annotated[bool, PropertyInfo(alias="useUniqueFileName")] + """Whether to use a unique filename for this file or not. + + If `true`, ImageKit.io will add a unique suffix to the filename parameter to get + a unique filename. + + If `false`, then the image is uploaded with the provided filename parameter, and + any existing file with the same name is replaced. + """ + + webhook_url: Annotated[str, PropertyInfo(alias="webhookUrl")] + """ + The final status of extensions after they have completed execution will be + delivered to this endpoint as a POST request. + [Learn more](/docs/api-reference/digital-asset-management-dam/managing-assets/update-file-details#webhook-payload-structure) + about the webhook payload structure. + """ + + +class TransformationPostTransformation(TypedDict, total=False): + type: Required[Literal["transformation"]] + """Transformation type.""" + + value: Required[str] + """Transformation string (e.g. + + `w-200,h-200`). + Same syntax as ImageKit URL-based transformations. + """ + + +class TransformationPostGifToVideo(TypedDict, total=False): + type: Required[Literal["gif-to-video"]] + """Converts an animated GIF into an MP4.""" + + value: str + """Optional transformation string to apply to the output video. + + **Example**: `q-80` + """ + + +class TransformationPostThumbnail(TypedDict, total=False): + type: Required[Literal["thumbnail"]] + """Generates a thumbnail image.""" + + value: str + """Optional transformation string. + + **Example**: `w-150,h-150` + """ + + +class TransformationPostAbs(TypedDict, total=False): + protocol: Required[Literal["hls", "dash"]] + """Streaming protocol to use (`hls` or `dash`).""" + + type: Required[Literal["abs"]] + """Adaptive Bitrate Streaming (ABS) setup.""" + + value: Required[str] + """ + List of different representations you want to create separated by an underscore. + """ + + +TransformationPost: TypeAlias = Union[ + TransformationPostTransformation, TransformationPostGifToVideo, TransformationPostThumbnail, TransformationPostAbs +] + + +class Transformation(TypedDict, total=False): + """Configure pre-processing (`pre`) and post-processing (`post`) transformations. + + - `pre` — applied before the file is uploaded to the Media Library. + Useful for reducing file size or applying basic optimizations upfront (e.g., resize, compress). + + - `post` — applied immediately after upload. + Ideal for generating transformed versions (like video encodes or thumbnails) in advance, so they're ready for delivery without delay. + + You can mix and match any combination of post-processing types. + """ + + post: Iterable[TransformationPost] + """List of transformations to apply _after_ the file is uploaded. + + Each item must match one of the following types: `transformation`, + `gif-to-video`, `thumbnail`, `abs`. + """ + + pre: str + """Transformation string to apply before uploading the file to the Media Library. + + Useful for optimizing files at ingestion. + """ diff --git a/src/imagekitio/types/file_upload_response.py b/src/imagekitio/types/file_upload_response.py new file mode 100644 index 00000000..e99dc77a --- /dev/null +++ b/src/imagekitio/types/file_upload_response.py @@ -0,0 +1,257 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Union, Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .metadata import Metadata + +__all__ = ["FileUploadResponse", "AITag", "ExtensionStatus", "SelectedFieldsSchema", "VersionInfo"] + + +class AITag(BaseModel): + confidence: Optional[float] = None + """Confidence score of the tag.""" + + name: Optional[str] = None + """Name of the tag.""" + + source: Optional[str] = None + """Array of `AITags` associated with the image. + + If no `AITags` are set, it will be null. These tags can be added using the + `google-auto-tagging` or `aws-auto-tagging` extensions. + """ + + +class ExtensionStatus(BaseModel): + """ + Extension names with their processing status at the time of completion of the request. It could have one of the following status values: + + `success`: The extension has been successfully applied. + `failed`: The extension has failed and will not be retried. + `pending`: The extension will finish processing in some time. On completion, the final status (success / failed) will be sent to the `webhookUrl` provided. + + If no extension was requested, then this parameter is not returned. + """ + + ai_auto_description: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="ai-auto-description", default=None + ) + + aws_auto_tagging: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="aws-auto-tagging", default=None + ) + + google_auto_tagging: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="google-auto-tagging", default=None + ) + + remove_bg: Optional[Literal["success", "pending", "failed"]] = FieldInfo(alias="remove-bg", default=None) + + +class SelectedFieldsSchema(BaseModel): + type: Literal["Text", "Textarea", "Number", "Date", "Boolean", "SingleSelect", "MultiSelect"] + """Type of the custom metadata field.""" + + default_value: Union[str, float, bool, List[Union[str, float, bool]], None] = FieldInfo( + alias="defaultValue", default=None + ) + """The default value for this custom metadata field. + + The value should match the `type` of custom metadata field. + """ + + is_value_required: Optional[bool] = FieldInfo(alias="isValueRequired", default=None) + """Specifies if the custom metadata field is required or not.""" + + max_length: Optional[float] = FieldInfo(alias="maxLength", default=None) + """Maximum length of string. Only set if `type` is set to `Text` or `Textarea`.""" + + max_value: Union[str, float, None] = FieldInfo(alias="maxValue", default=None) + """Maximum value of the field. + + Only set if field type is `Date` or `Number`. For `Date` type field, the value + will be in ISO8601 string format. For `Number` type field, it will be a numeric + value. + """ + + min_length: Optional[float] = FieldInfo(alias="minLength", default=None) + """Minimum length of string. Only set if `type` is set to `Text` or `Textarea`.""" + + min_value: Union[str, float, None] = FieldInfo(alias="minValue", default=None) + """Minimum value of the field. + + Only set if field type is `Date` or `Number`. For `Date` type field, the value + will be in ISO8601 string format. For `Number` type field, it will be a numeric + value. + """ + + read_only: Optional[bool] = FieldInfo(alias="readOnly", default=None) + """Indicates whether the custom metadata field is read only. + + A read only field cannot be modified after being set. This field is configurable + only via the **Path policy** feature. + """ + + select_options: Optional[List[Union[str, float, bool]]] = FieldInfo(alias="selectOptions", default=None) + """An array of allowed values when field type is `SingleSelect` or `MultiSelect`.""" + + select_options_truncated: Optional[bool] = FieldInfo(alias="selectOptionsTruncated", default=None) + """Specifies if the selectOptions array is truncated. + + It is truncated when number of options are > 100. + """ + + +class VersionInfo(BaseModel): + """An object containing the file or file version's `id` (versionId) and `name`.""" + + id: Optional[str] = None + """Unique identifier of the file version.""" + + name: Optional[str] = None + """Name of the file version.""" + + +class FileUploadResponse(BaseModel): + """Object containing details of a successful upload.""" + + ai_tags: Optional[List[AITag]] = FieldInfo(alias="AITags", default=None) + """An array of tags assigned to the uploaded file by auto tagging.""" + + audio_codec: Optional[str] = FieldInfo(alias="audioCodec", default=None) + """The audio codec used in the video (only for video).""" + + bit_rate: Optional[int] = FieldInfo(alias="bitRate", default=None) + """The bit rate of the video in kbps (only for video).""" + + custom_coordinates: Optional[str] = FieldInfo(alias="customCoordinates", default=None) + """ + Value of custom coordinates associated with the image in the format + `x,y,width,height`. If `customCoordinates` are not defined, then it is `null`. + Send `customCoordinates` in `responseFields` in API request to get the value of + this field. + """ + + custom_metadata: Optional[Dict[str, object]] = FieldInfo(alias="customMetadata", default=None) + """A key-value data associated with the asset. + + Use `responseField` in API request to get `customMetadata` in the upload API + response. Before setting any custom metadata on an asset, you have to create the + field using custom metadata fields API. Send `customMetadata` in + `responseFields` in API request to get the value of this field. + """ + + description: Optional[str] = None + """Optional text to describe the contents of the file. + + Can be set by the user or the ai-auto-description extension. + """ + + duration: Optional[int] = None + """The duration of the video in seconds (only for video).""" + + embedded_metadata: Optional[Dict[str, object]] = FieldInfo(alias="embeddedMetadata", default=None) + """Consolidated embedded metadata associated with the file. + + It includes exif, iptc, and xmp data. Send `embeddedMetadata` in + `responseFields` in API request to get embeddedMetadata in the upload API + response. + """ + + extension_status: Optional[ExtensionStatus] = FieldInfo(alias="extensionStatus", default=None) + """ + Extension names with their processing status at the time of completion of the + request. It could have one of the following status values: + + `success`: The extension has been successfully applied. `failed`: The extension + has failed and will not be retried. `pending`: The extension will finish + processing in some time. On completion, the final status (success / failed) will + be sent to the `webhookUrl` provided. + + If no extension was requested, then this parameter is not returned. + """ + + file_id: Optional[str] = FieldInfo(alias="fileId", default=None) + """Unique fileId. + + Store this fileld in your database, as this will be used to perform update + action on this file. + """ + + file_path: Optional[str] = FieldInfo(alias="filePath", default=None) + """The relative path of the file in the media library e.g. + + `/marketing-assets/new-banner.jpg`. + """ + + file_type: Optional[str] = FieldInfo(alias="fileType", default=None) + """Type of the uploaded file. Possible values are `image`, `non-image`.""" + + height: Optional[float] = None + """Height of the image in pixels (Only for images)""" + + is_private_file: Optional[bool] = FieldInfo(alias="isPrivateFile", default=None) + """Is the file marked as private. + + It can be either `true` or `false`. Send `isPrivateFile` in `responseFields` in + API request to get the value of this field. + """ + + is_published: Optional[bool] = FieldInfo(alias="isPublished", default=None) + """Is the file published or in draft state. + + It can be either `true` or `false`. Send `isPublished` in `responseFields` in + API request to get the value of this field. + """ + + metadata: Optional[Metadata] = None + """Legacy metadata. + + Send `metadata` in `responseFields` in API request to get metadata in the upload + API response. + """ + + name: Optional[str] = None + """Name of the asset.""" + + selected_fields_schema: Optional[Dict[str, SelectedFieldsSchema]] = FieldInfo( + alias="selectedFieldsSchema", default=None + ) + """ + This field is included in the response only if the Path policy feature is + available in the plan. It contains schema definitions for the custom metadata + fields selected for the specified file path. Field selection can only be done + when the Path policy feature is enabled. + + Keys are the names of the custom metadata fields; the value object has details + about the custom metadata schema. + """ + + size: Optional[float] = None + """Size of the image file in Bytes.""" + + tags: Optional[List[str]] = None + """The array of tags associated with the asset. + + If no tags are set, it will be `null`. Send `tags` in `responseFields` in API + request to get the value of this field. + """ + + thumbnail_url: Optional[str] = FieldInfo(alias="thumbnailUrl", default=None) + """In the case of an image, a small thumbnail URL.""" + + url: Optional[str] = None + """A publicly accessible URL of the file.""" + + version_info: Optional[VersionInfo] = FieldInfo(alias="versionInfo", default=None) + """An object containing the file or file version's `id` (versionId) and `name`.""" + + video_codec: Optional[str] = FieldInfo(alias="videoCodec", default=None) + """The video codec used in the video (only for video).""" + + width: Optional[float] = None + """Width of the image in pixels (Only for Images)""" diff --git a/src/imagekitio/types/files/__init__.py b/src/imagekitio/types/files/__init__.py new file mode 100644 index 00000000..b46129a5 --- /dev/null +++ b/src/imagekitio/types/files/__init__.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .bulk_delete_params import BulkDeleteParams as BulkDeleteParams +from .bulk_add_tags_params import BulkAddTagsParams as BulkAddTagsParams +from .bulk_delete_response import BulkDeleteResponse as BulkDeleteResponse +from .version_list_response import VersionListResponse as VersionListResponse +from .bulk_add_tags_response import BulkAddTagsResponse as BulkAddTagsResponse +from .bulk_remove_tags_params import BulkRemoveTagsParams as BulkRemoveTagsParams +from .version_delete_response import VersionDeleteResponse as VersionDeleteResponse +from .bulk_remove_tags_response import BulkRemoveTagsResponse as BulkRemoveTagsResponse +from .bulk_remove_ai_tags_params import BulkRemoveAITagsParams as BulkRemoveAITagsParams +from .bulk_remove_ai_tags_response import BulkRemoveAITagsResponse as BulkRemoveAITagsResponse +from .metadata_get_from_url_params import MetadataGetFromURLParams as MetadataGetFromURLParams diff --git a/src/imagekitio/types/files/bulk_add_tags_params.py b/src/imagekitio/types/files/bulk_add_tags_params.py new file mode 100644 index 00000000..f83f5776 --- /dev/null +++ b/src/imagekitio/types/files/bulk_add_tags_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from ..._types import SequenceNotStr +from ..._utils import PropertyInfo + +__all__ = ["BulkAddTagsParams"] + + +class BulkAddTagsParams(TypedDict, total=False): + file_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="fileIds")]] + """An array of fileIds to which you want to add tags.""" + + tags: Required[SequenceNotStr[str]] + """An array of tags that you want to add to the files.""" diff --git a/src/imagekitio/types/files/bulk_add_tags_response.py b/src/imagekitio/types/files/bulk_add_tags_response.py new file mode 100644 index 00000000..059ae528 --- /dev/null +++ b/src/imagekitio/types/files/bulk_add_tags_response.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["BulkAddTagsResponse"] + + +class BulkAddTagsResponse(BaseModel): + successfully_updated_file_ids: Optional[List[str]] = FieldInfo(alias="successfullyUpdatedFileIds", default=None) + """An array of fileIds that in which tags were successfully added.""" diff --git a/src/imagekitio/types/files/bulk_delete_params.py b/src/imagekitio/types/files/bulk_delete_params.py new file mode 100644 index 00000000..2df1ee5a --- /dev/null +++ b/src/imagekitio/types/files/bulk_delete_params.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from ..._types import SequenceNotStr +from ..._utils import PropertyInfo + +__all__ = ["BulkDeleteParams"] + + +class BulkDeleteParams(TypedDict, total=False): + file_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="fileIds")]] + """An array of fileIds which you want to delete.""" diff --git a/src/imagekitio/types/files/bulk_delete_response.py b/src/imagekitio/types/files/bulk_delete_response.py new file mode 100644 index 00000000..af431f3b --- /dev/null +++ b/src/imagekitio/types/files/bulk_delete_response.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["BulkDeleteResponse"] + + +class BulkDeleteResponse(BaseModel): + successfully_deleted_file_ids: Optional[List[str]] = FieldInfo(alias="successfullyDeletedFileIds", default=None) + """An array of fileIds that were successfully deleted.""" diff --git a/src/imagekitio/types/files/bulk_remove_ai_tags_params.py b/src/imagekitio/types/files/bulk_remove_ai_tags_params.py new file mode 100644 index 00000000..7d69ed3e --- /dev/null +++ b/src/imagekitio/types/files/bulk_remove_ai_tags_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from ..._types import SequenceNotStr +from ..._utils import PropertyInfo + +__all__ = ["BulkRemoveAITagsParams"] + + +class BulkRemoveAITagsParams(TypedDict, total=False): + ai_tags: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="AITags")]] + """An array of AITags that you want to remove from the files.""" + + file_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="fileIds")]] + """An array of fileIds from which you want to remove AITags.""" diff --git a/src/imagekitio/types/files/bulk_remove_ai_tags_response.py b/src/imagekitio/types/files/bulk_remove_ai_tags_response.py new file mode 100644 index 00000000..155d03ca --- /dev/null +++ b/src/imagekitio/types/files/bulk_remove_ai_tags_response.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["BulkRemoveAITagsResponse"] + + +class BulkRemoveAITagsResponse(BaseModel): + successfully_updated_file_ids: Optional[List[str]] = FieldInfo(alias="successfullyUpdatedFileIds", default=None) + """An array of fileIds that in which AITags were successfully removed.""" diff --git a/src/imagekitio/types/files/bulk_remove_tags_params.py b/src/imagekitio/types/files/bulk_remove_tags_params.py new file mode 100644 index 00000000..02e7f241 --- /dev/null +++ b/src/imagekitio/types/files/bulk_remove_tags_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from ..._types import SequenceNotStr +from ..._utils import PropertyInfo + +__all__ = ["BulkRemoveTagsParams"] + + +class BulkRemoveTagsParams(TypedDict, total=False): + file_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="fileIds")]] + """An array of fileIds from which you want to remove tags.""" + + tags: Required[SequenceNotStr[str]] + """An array of tags that you want to remove from the files.""" diff --git a/src/imagekitio/types/files/bulk_remove_tags_response.py b/src/imagekitio/types/files/bulk_remove_tags_response.py new file mode 100644 index 00000000..37c1c671 --- /dev/null +++ b/src/imagekitio/types/files/bulk_remove_tags_response.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["BulkRemoveTagsResponse"] + + +class BulkRemoveTagsResponse(BaseModel): + successfully_updated_file_ids: Optional[List[str]] = FieldInfo(alias="successfullyUpdatedFileIds", default=None) + """An array of fileIds that in which tags were successfully removed.""" diff --git a/src/imagekitio/types/files/metadata_get_from_url_params.py b/src/imagekitio/types/files/metadata_get_from_url_params.py new file mode 100644 index 00000000..6fdbb631 --- /dev/null +++ b/src/imagekitio/types/files/metadata_get_from_url_params.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["MetadataGetFromURLParams"] + + +class MetadataGetFromURLParams(TypedDict, total=False): + url: Required[str] + """Should be a valid file URL. + + It should be accessible using your ImageKit.io account. + """ diff --git a/src/imagekitio/types/files/version_delete_response.py b/src/imagekitio/types/files/version_delete_response.py new file mode 100644 index 00000000..51b121fd --- /dev/null +++ b/src/imagekitio/types/files/version_delete_response.py @@ -0,0 +1,9 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from ..._models import BaseModel + +__all__ = ["VersionDeleteResponse"] + + +class VersionDeleteResponse(BaseModel): + pass diff --git a/src/imagekitio/types/files/version_list_response.py b/src/imagekitio/types/files/version_list_response.py new file mode 100644 index 00000000..6d0c3f02 --- /dev/null +++ b/src/imagekitio/types/files/version_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from ..file import File + +__all__ = ["VersionListResponse"] + +VersionListResponse: TypeAlias = List[File] diff --git a/src/imagekitio/types/folder.py b/src/imagekitio/types/folder.py new file mode 100644 index 00000000..17b72874 --- /dev/null +++ b/src/imagekitio/types/folder.py @@ -0,0 +1,42 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["Folder"] + + +class Folder(BaseModel): + created_at: Optional[datetime] = FieldInfo(alias="createdAt", default=None) + """Date and time when the folder was created. + + The date and time is in ISO8601 format. + """ + + folder_id: Optional[str] = FieldInfo(alias="folderId", default=None) + """Unique identifier of the asset.""" + + folder_path: Optional[str] = FieldInfo(alias="folderPath", default=None) + """Path of the folder. + + This is the path you would use in the URL to access the folder. For example, if + the folder is at the root of the media library, the path will be /folder. If the + folder is inside another folder named images, the path will be /images/folder. + """ + + name: Optional[str] = None + """Name of the asset.""" + + type: Optional[Literal["folder"]] = None + """Type of the asset.""" + + updated_at: Optional[datetime] = FieldInfo(alias="updatedAt", default=None) + """Date and time when the folder was last updated. + + The date and time is in ISO8601 format. + """ diff --git a/src/imagekitio/types/folder_copy_params.py b/src/imagekitio/types/folder_copy_params.py new file mode 100644 index 00000000..4ccc7fa7 --- /dev/null +++ b/src/imagekitio/types/folder_copy_params.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["FolderCopyParams"] + + +class FolderCopyParams(TypedDict, total=False): + destination_path: Required[Annotated[str, PropertyInfo(alias="destinationPath")]] + """ + Full path to the destination folder where you want to copy the source folder + into. + """ + + source_folder_path: Required[Annotated[str, PropertyInfo(alias="sourceFolderPath")]] + """The full path to the source folder you want to copy.""" + + include_versions: Annotated[bool, PropertyInfo(alias="includeVersions")] + """Option to copy all versions of files that are nested inside the selected folder. + + By default, only the current version of each file will be copied. When set to + true, all versions of each file will be copied. Default value - `false`. + """ diff --git a/src/imagekitio/types/folder_copy_response.py b/src/imagekitio/types/folder_copy_response.py new file mode 100644 index 00000000..69c34c1e --- /dev/null +++ b/src/imagekitio/types/folder_copy_response.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["FolderCopyResponse"] + + +class FolderCopyResponse(BaseModel): + """Job submitted successfully. A `jobId` will be returned.""" + + job_id: str = FieldInfo(alias="jobId") + """Unique identifier of the bulk job. + + This can be used to check the status of the bulk job. + """ diff --git a/src/imagekitio/types/folder_create_params.py b/src/imagekitio/types/folder_create_params.py new file mode 100644 index 00000000..82863b56 --- /dev/null +++ b/src/imagekitio/types/folder_create_params.py @@ -0,0 +1,30 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["FolderCreateParams"] + + +class FolderCreateParams(TypedDict, total=False): + folder_name: Required[Annotated[str, PropertyInfo(alias="folderName")]] + """The folder will be created with this name. + + All characters except alphabets and numbers (inclusive of unicode letters, + marks, and numerals in other languages) will be replaced by an underscore i.e. + `_`. + """ + + parent_folder_path: Required[Annotated[str, PropertyInfo(alias="parentFolderPath")]] + """ + The folder where the new folder should be created, for root use `/` else the + path e.g. `containing/folder/`. + + Note: If any folder(s) is not present in the parentFolderPath parameter, it will + be automatically created. For example, if you pass `/product/images/summer`, + then `product`, `images`, and `summer` folders will be created if they don't + already exist. + """ diff --git a/src/imagekitio/types/folder_create_response.py b/src/imagekitio/types/folder_create_response.py new file mode 100644 index 00000000..1f10670c --- /dev/null +++ b/src/imagekitio/types/folder_create_response.py @@ -0,0 +1,9 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["FolderCreateResponse"] + + +class FolderCreateResponse(BaseModel): + pass diff --git a/src/imagekitio/types/folder_delete_params.py b/src/imagekitio/types/folder_delete_params.py new file mode 100644 index 00000000..8b5ff529 --- /dev/null +++ b/src/imagekitio/types/folder_delete_params.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["FolderDeleteParams"] + + +class FolderDeleteParams(TypedDict, total=False): + folder_path: Required[Annotated[str, PropertyInfo(alias="folderPath")]] + """Full path to the folder you want to delete. For example `/folder/to/delete/`.""" diff --git a/src/imagekitio/types/folder_delete_response.py b/src/imagekitio/types/folder_delete_response.py new file mode 100644 index 00000000..40686cb2 --- /dev/null +++ b/src/imagekitio/types/folder_delete_response.py @@ -0,0 +1,9 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["FolderDeleteResponse"] + + +class FolderDeleteResponse(BaseModel): + pass diff --git a/src/imagekitio/types/folder_move_params.py b/src/imagekitio/types/folder_move_params.py new file mode 100644 index 00000000..59f63cdf --- /dev/null +++ b/src/imagekitio/types/folder_move_params.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["FolderMoveParams"] + + +class FolderMoveParams(TypedDict, total=False): + destination_path: Required[Annotated[str, PropertyInfo(alias="destinationPath")]] + """ + Full path to the destination folder where you want to move the source folder + into. + """ + + source_folder_path: Required[Annotated[str, PropertyInfo(alias="sourceFolderPath")]] + """The full path to the source folder you want to move.""" diff --git a/src/imagekitio/types/folder_move_response.py b/src/imagekitio/types/folder_move_response.py new file mode 100644 index 00000000..d3fe1d83 --- /dev/null +++ b/src/imagekitio/types/folder_move_response.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["FolderMoveResponse"] + + +class FolderMoveResponse(BaseModel): + """Job submitted successfully. A `jobId` will be returned.""" + + job_id: str = FieldInfo(alias="jobId") + """Unique identifier of the bulk job. + + This can be used to check the status of the bulk job. + """ diff --git a/src/imagekitio/types/folder_rename_params.py b/src/imagekitio/types/folder_rename_params.py new file mode 100644 index 00000000..8c9caba1 --- /dev/null +++ b/src/imagekitio/types/folder_rename_params.py @@ -0,0 +1,40 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["FolderRenameParams"] + + +class FolderRenameParams(TypedDict, total=False): + folder_path: Required[Annotated[str, PropertyInfo(alias="folderPath")]] + """The full path to the folder you want to rename.""" + + new_folder_name: Required[Annotated[str, PropertyInfo(alias="newFolderName")]] + """The new name for the folder. + + All characters except alphabets and numbers (inclusive of unicode letters, + marks, and numerals in other languages) and `-` will be replaced by an + underscore i.e. `_`. + """ + + purge_cache: Annotated[bool, PropertyInfo(alias="purgeCache")] + """Option to purge cache for the old nested files and their versions' URLs. + + When set to true, it will internally issue a purge cache request on CDN to + remove the cached content of the old nested files and their versions. There will + only be one purge request for all the nested files, which will be counted + against your monthly purge quota. + + Note: A purge cache request will be issued against + `https://ik.imagekit.io/old/folder/path*` (with a wildcard at the end). This + will remove all nested files, their versions' URLs, and any transformations made + using query parameters on these files or their versions. However, the cache for + file transformations made using path parameters will persist. You can purge them + using the purge API. For more details, refer to the purge API documentation. + + Default value - `false` + """ diff --git a/src/imagekitio/types/folder_rename_response.py b/src/imagekitio/types/folder_rename_response.py new file mode 100644 index 00000000..d3319b76 --- /dev/null +++ b/src/imagekitio/types/folder_rename_response.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["FolderRenameResponse"] + + +class FolderRenameResponse(BaseModel): + """Job submitted successfully. A `jobId` will be returned.""" + + job_id: str = FieldInfo(alias="jobId") + """Unique identifier of the bulk job. + + This can be used to check the status of the bulk job. + """ diff --git a/src/imagekitio/types/folders/__init__.py b/src/imagekitio/types/folders/__init__.py new file mode 100644 index 00000000..8c60dd28 --- /dev/null +++ b/src/imagekitio/types/folders/__init__.py @@ -0,0 +1,5 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .job_get_response import JobGetResponse as JobGetResponse diff --git a/src/imagekitio/types/folders/job_get_response.py b/src/imagekitio/types/folders/job_get_response.py new file mode 100644 index 00000000..17a11efc --- /dev/null +++ b/src/imagekitio/types/folders/job_get_response.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["JobGetResponse"] + + +class JobGetResponse(BaseModel): + job_id: Optional[str] = FieldInfo(alias="jobId", default=None) + """Unique identifier of the bulk job.""" + + purge_request_id: Optional[str] = FieldInfo(alias="purgeRequestId", default=None) + """Unique identifier of the purge request. + + This will be present only if `purgeCache` is set to `true` in the rename folder + API request. + """ + + status: Optional[Literal["Pending", "Completed"]] = None + """Status of the bulk job.""" + + type: Optional[Literal["COPY_FOLDER", "MOVE_FOLDER", "RENAME_FOLDER"]] = None + """Type of the bulk job.""" diff --git a/src/imagekitio/types/metadata.py b/src/imagekitio/types/metadata.py new file mode 100644 index 00000000..87ac3341 --- /dev/null +++ b/src/imagekitio/types/metadata.py @@ -0,0 +1,185 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["Metadata", "Exif", "ExifExif", "ExifGps", "ExifImage", "ExifInteroperability", "ExifThumbnail"] + + +class ExifExif(BaseModel): + """Object containing Exif details.""" + + aperture_value: Optional[float] = FieldInfo(alias="ApertureValue", default=None) + + color_space: Optional[int] = FieldInfo(alias="ColorSpace", default=None) + + create_date: Optional[str] = FieldInfo(alias="CreateDate", default=None) + + custom_rendered: Optional[int] = FieldInfo(alias="CustomRendered", default=None) + + date_time_original: Optional[str] = FieldInfo(alias="DateTimeOriginal", default=None) + + exif_image_height: Optional[int] = FieldInfo(alias="ExifImageHeight", default=None) + + exif_image_width: Optional[int] = FieldInfo(alias="ExifImageWidth", default=None) + + exif_version: Optional[str] = FieldInfo(alias="ExifVersion", default=None) + + exposure_compensation: Optional[float] = FieldInfo(alias="ExposureCompensation", default=None) + + exposure_mode: Optional[int] = FieldInfo(alias="ExposureMode", default=None) + + exposure_program: Optional[int] = FieldInfo(alias="ExposureProgram", default=None) + + exposure_time: Optional[float] = FieldInfo(alias="ExposureTime", default=None) + + flash: Optional[int] = FieldInfo(alias="Flash", default=None) + + flashpix_version: Optional[str] = FieldInfo(alias="FlashpixVersion", default=None) + + f_number: Optional[float] = FieldInfo(alias="FNumber", default=None) + + focal_length: Optional[int] = FieldInfo(alias="FocalLength", default=None) + + focal_plane_resolution_unit: Optional[int] = FieldInfo(alias="FocalPlaneResolutionUnit", default=None) + + focal_plane_x_resolution: Optional[float] = FieldInfo(alias="FocalPlaneXResolution", default=None) + + focal_plane_y_resolution: Optional[float] = FieldInfo(alias="FocalPlaneYResolution", default=None) + + interop_offset: Optional[int] = FieldInfo(alias="InteropOffset", default=None) + + iso: Optional[int] = FieldInfo(alias="ISO", default=None) + + metering_mode: Optional[int] = FieldInfo(alias="MeteringMode", default=None) + + scene_capture_type: Optional[int] = FieldInfo(alias="SceneCaptureType", default=None) + + shutter_speed_value: Optional[float] = FieldInfo(alias="ShutterSpeedValue", default=None) + + sub_sec_time: Optional[str] = FieldInfo(alias="SubSecTime", default=None) + + white_balance: Optional[int] = FieldInfo(alias="WhiteBalance", default=None) + + +class ExifGps(BaseModel): + """Object containing GPS information.""" + + gps_version_id: Optional[List[int]] = FieldInfo(alias="GPSVersionID", default=None) + + +class ExifImage(BaseModel): + """Object containing EXIF image information.""" + + exif_offset: Optional[int] = FieldInfo(alias="ExifOffset", default=None) + + gps_info: Optional[int] = FieldInfo(alias="GPSInfo", default=None) + + make: Optional[str] = FieldInfo(alias="Make", default=None) + + model: Optional[str] = FieldInfo(alias="Model", default=None) + + modify_date: Optional[str] = FieldInfo(alias="ModifyDate", default=None) + + orientation: Optional[int] = FieldInfo(alias="Orientation", default=None) + + resolution_unit: Optional[int] = FieldInfo(alias="ResolutionUnit", default=None) + + software: Optional[str] = FieldInfo(alias="Software", default=None) + + x_resolution: Optional[int] = FieldInfo(alias="XResolution", default=None) + + y_cb_cr_positioning: Optional[int] = FieldInfo(alias="YCbCrPositioning", default=None) + + y_resolution: Optional[int] = FieldInfo(alias="YResolution", default=None) + + +class ExifInteroperability(BaseModel): + """JSON object.""" + + interop_index: Optional[str] = FieldInfo(alias="InteropIndex", default=None) + + interop_version: Optional[str] = FieldInfo(alias="InteropVersion", default=None) + + +class ExifThumbnail(BaseModel): + """Object containing Thumbnail information.""" + + compression: Optional[int] = FieldInfo(alias="Compression", default=None) + + resolution_unit: Optional[int] = FieldInfo(alias="ResolutionUnit", default=None) + + thumbnail_length: Optional[int] = FieldInfo(alias="ThumbnailLength", default=None) + + thumbnail_offset: Optional[int] = FieldInfo(alias="ThumbnailOffset", default=None) + + x_resolution: Optional[int] = FieldInfo(alias="XResolution", default=None) + + y_resolution: Optional[int] = FieldInfo(alias="YResolution", default=None) + + +class Exif(BaseModel): + exif: Optional[ExifExif] = None + """Object containing Exif details.""" + + gps: Optional[ExifGps] = None + """Object containing GPS information.""" + + image: Optional[ExifImage] = None + """Object containing EXIF image information.""" + + interoperability: Optional[ExifInteroperability] = None + """JSON object.""" + + makernote: Optional[Dict[str, object]] = None + + thumbnail: Optional[ExifThumbnail] = None + """Object containing Thumbnail information.""" + + +class Metadata(BaseModel): + """JSON object containing metadata.""" + + audio_codec: Optional[str] = FieldInfo(alias="audioCodec", default=None) + """The audio codec used in the video (only for video).""" + + bit_rate: Optional[int] = FieldInfo(alias="bitRate", default=None) + """The bit rate of the video in kbps (only for video).""" + + density: Optional[int] = None + """The density of the image in DPI.""" + + duration: Optional[int] = None + """The duration of the video in seconds (only for video).""" + + exif: Optional[Exif] = None + + format: Optional[str] = None + """The format of the file (e.g., 'jpg', 'mp4').""" + + has_color_profile: Optional[bool] = FieldInfo(alias="hasColorProfile", default=None) + """Indicates if the image has a color profile.""" + + has_transparency: Optional[bool] = FieldInfo(alias="hasTransparency", default=None) + """Indicates if the image contains transparent areas.""" + + height: Optional[int] = None + """The height of the image or video in pixels.""" + + p_hash: Optional[str] = FieldInfo(alias="pHash", default=None) + """Perceptual hash of the image.""" + + quality: Optional[int] = None + """The quality indicator of the image.""" + + size: Optional[int] = None + """The file size in bytes.""" + + video_codec: Optional[str] = FieldInfo(alias="videoCodec", default=None) + """The video codec used in the video (only for video).""" + + width: Optional[int] = None + """The width of the image or video in pixels.""" diff --git a/src/imagekitio/types/shared/__init__.py b/src/imagekitio/types/shared/__init__.py new file mode 100644 index 00000000..49f3e91b --- /dev/null +++ b/src/imagekitio/types/shared/__init__.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .overlay import Overlay as Overlay +from .extensions import Extensions as Extensions +from .src_options import SrcOptions as SrcOptions +from .base_overlay import BaseOverlay as BaseOverlay +from .text_overlay import TextOverlay as TextOverlay +from .image_overlay import ImageOverlay as ImageOverlay +from .video_overlay import VideoOverlay as VideoOverlay +from .overlay_timing import OverlayTiming as OverlayTiming +from .transformation import Transformation as Transformation +from .overlay_position import OverlayPosition as OverlayPosition +from .subtitle_overlay import SubtitleOverlay as SubtitleOverlay +from .solid_color_overlay import SolidColorOverlay as SolidColorOverlay +from .streaming_resolution import StreamingResolution as StreamingResolution +from .transformation_position import TransformationPosition as TransformationPosition +from .responsive_image_attributes import ResponsiveImageAttributes as ResponsiveImageAttributes +from .text_overlay_transformation import TextOverlayTransformation as TextOverlayTransformation +from .get_image_attributes_options import GetImageAttributesOptions as GetImageAttributesOptions +from .subtitle_overlay_transformation import SubtitleOverlayTransformation as SubtitleOverlayTransformation +from .solid_color_overlay_transformation import SolidColorOverlayTransformation as SolidColorOverlayTransformation diff --git a/src/imagekitio/types/shared/base_overlay.py b/src/imagekitio/types/shared/base_overlay.py new file mode 100644 index 00000000..fa490a4c --- /dev/null +++ b/src/imagekitio/types/shared/base_overlay.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel +from .overlay_timing import OverlayTiming +from .overlay_position import OverlayPosition + +__all__ = ["BaseOverlay"] + + +class BaseOverlay(BaseModel): + position: Optional[OverlayPosition] = None + + timing: Optional[OverlayTiming] = None diff --git a/src/imagekitio/types/shared/extensions.py b/src/imagekitio/types/shared/extensions.py new file mode 100644 index 00000000..36d0a051 --- /dev/null +++ b/src/imagekitio/types/shared/extensions.py @@ -0,0 +1,78 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias + +from pydantic import Field as FieldInfo + +from ..._utils import PropertyInfo +from ..._models import BaseModel + +__all__ = [ + "Extensions", + "ExtensionItem", + "ExtensionItemRemoveBg", + "ExtensionItemRemoveBgOptions", + "ExtensionItemAutoTaggingExtension", + "ExtensionItemAIAutoDescription", +] + + +class ExtensionItemRemoveBgOptions(BaseModel): + add_shadow: Optional[bool] = None + """Whether to add an artificial shadow to the result. + + Default is false. Note: Adding shadows is currently only supported for car + photos. + """ + + bg_color: Optional[str] = None + """ + Specifies a solid color background using hex code (e.g., "81d4fa", "fff") or + color name (e.g., "green"). If this parameter is set, `bg_image_url` must be + empty. + """ + + bg_image_url: Optional[str] = None + """Sets a background image from a URL. + + If this parameter is set, `bg_color` must be empty. + """ + + semitransparency: Optional[bool] = None + """Allows semi-transparent regions in the result. + + Default is true. Note: Semitransparency is currently only supported for car + windows. + """ + + +class ExtensionItemRemoveBg(BaseModel): + name: Literal["remove-bg"] + """Specifies the background removal extension.""" + + options: Optional[ExtensionItemRemoveBgOptions] = None + + +class ExtensionItemAutoTaggingExtension(BaseModel): + max_tags: int = FieldInfo(alias="maxTags") + """Maximum number of tags to attach to the asset.""" + + min_confidence: int = FieldInfo(alias="minConfidence") + """Minimum confidence level for tags to be considered valid.""" + + name: Literal["google-auto-tagging", "aws-auto-tagging"] + """Specifies the auto-tagging extension used.""" + + +class ExtensionItemAIAutoDescription(BaseModel): + name: Literal["ai-auto-description"] + """Specifies the auto description extension.""" + + +ExtensionItem: TypeAlias = Annotated[ + Union[ExtensionItemRemoveBg, ExtensionItemAutoTaggingExtension, ExtensionItemAIAutoDescription], + PropertyInfo(discriminator="name"), +] + +Extensions: TypeAlias = List[ExtensionItem] diff --git a/src/imagekitio/types/shared/get_image_attributes_options.py b/src/imagekitio/types/shared/get_image_attributes_options.py new file mode 100644 index 00000000..2203f5f5 --- /dev/null +++ b/src/imagekitio/types/shared/get_image_attributes_options.py @@ -0,0 +1,59 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Optional + +from pydantic import Field as FieldInfo + +from .src_options import SrcOptions + +__all__ = ["GetImageAttributesOptions"] + + +class GetImageAttributesOptions(SrcOptions): + """ + Options for generating responsive image attributes including `src`, `srcSet`, and `sizes` for HTML `` elements. + This schema extends `SrcOptions` to add support for responsive image generation with breakpoints. + """ + + device_breakpoints: Optional[List[float]] = FieldInfo(alias="deviceBreakpoints", default=None) + """ + Custom list of **device-width breakpoints** in pixels. These define common + screen widths for responsive image generation. + + Defaults to `[640, 750, 828, 1080, 1200, 1920, 2048, 3840]`. Sorted + automatically. + """ + + image_breakpoints: Optional[List[float]] = FieldInfo(alias="imageBreakpoints", default=None) + """ + Custom list of **image-specific breakpoints** in pixels. Useful for generating + small variants (e.g., placeholders or thumbnails). + + Merged with `deviceBreakpoints` before calculating `srcSet`. Defaults to + `[16, 32, 48, 64, 96, 128, 256, 384]`. Sorted automatically. + """ + + sizes: Optional[str] = None + """ + The value for the HTML `sizes` attribute (e.g., `"100vw"` or + `"(min-width:768px) 50vw, 100vw"`). + + - If it includes one or more `vw` units, breakpoints smaller than the + corresponding percentage of the smallest device width are excluded. + - If it contains no `vw` units, the full breakpoint list is used. + + Enables a width-based strategy and generates `w` descriptors in `srcSet`. + """ + + width: Optional[float] = None + """ + The intended display width of the image in pixels, used **only when the `sizes` + attribute is not provided**. + + Triggers a DPR-based strategy (1x and 2x variants) and generates `x` descriptors + in `srcSet`. + + Ignored if `sizes` is present. + """ diff --git a/src/imagekitio/types/shared/image_overlay.py b/src/imagekitio/types/shared/image_overlay.py new file mode 100644 index 00000000..178864c1 --- /dev/null +++ b/src/imagekitio/types/shared/image_overlay.py @@ -0,0 +1,38 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Optional +from typing_extensions import Literal + +from .base_overlay import BaseOverlay + +__all__ = ["ImageOverlay"] + + +class ImageOverlay(BaseOverlay): + input: str + """Specifies the relative path to the image used as an overlay.""" + + type: Literal["image"] + + encoding: Optional[Literal["auto", "plain", "base64"]] = None + """ + The input path can be included in the layer as either `i-{input}` or + `ie-{base64_encoded_input}`. By default, the SDK determines the appropriate + format automatically. To always use base64 encoding (`ie-{base64}`), set this + parameter to `base64`. To always use plain text (`i-{input}`), set it to + `plain`. + """ + + transformation: Optional[List["Transformation"]] = None + """Array of transformations to be applied to the overlay image. + + Supported transformations depends on the base/parent asset. See overlays on + [Images](https://imagekit.io/docs/add-overlays-on-images#list-of-supported-image-transformations-in-image-layers) + and + [Videos](https://imagekit.io/docs/add-overlays-on-videos#list-of-transformations-supported-on-image-overlay). + """ + + +from .transformation import Transformation diff --git a/src/imagekitio/types/shared/overlay.py b/src/imagekitio/types/shared/overlay.py new file mode 100644 index 00000000..f7c120d5 --- /dev/null +++ b/src/imagekitio/types/shared/overlay.py @@ -0,0 +1,31 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Union +from typing_extensions import Annotated, TypeAlias, TypeAliasType + +from ..._utils import PropertyInfo +from ..._compat import PYDANTIC_V1 +from .text_overlay import TextOverlay +from .subtitle_overlay import SubtitleOverlay +from .solid_color_overlay import SolidColorOverlay + +__all__ = ["Overlay"] + +if TYPE_CHECKING or not PYDANTIC_V1: + Overlay = TypeAliasType( + "Overlay", + Annotated[ + Union[TextOverlay, "ImageOverlay", "VideoOverlay", SubtitleOverlay, SolidColorOverlay], + PropertyInfo(discriminator="type"), + ], + ) +else: + Overlay: TypeAlias = Annotated[ + Union[TextOverlay, "ImageOverlay", "VideoOverlay", SubtitleOverlay, SolidColorOverlay], + PropertyInfo(discriminator="type"), + ] + +from .image_overlay import ImageOverlay +from .video_overlay import VideoOverlay diff --git a/src/imagekitio/types/shared/overlay_position.py b/src/imagekitio/types/shared/overlay_position.py new file mode 100644 index 00000000..a6fe5f89 --- /dev/null +++ b/src/imagekitio/types/shared/overlay_position.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union, Optional +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["OverlayPosition"] + + +class OverlayPosition(BaseModel): + focus: Optional[ + Literal["center", "top", "left", "bottom", "right", "top_left", "top_right", "bottom_left", "bottom_right"] + ] = None + """ + Specifies the position of the overlay relative to the parent image or video. + Maps to `lfo` in the URL. + """ + + x: Union[float, str, None] = None + """ + Specifies the x-coordinate of the top-left corner of the base asset where the + overlay's top-left corner will be positioned. It also accepts arithmetic + expressions such as `bw_mul_0.4` or `bw_sub_cw`. Maps to `lx` in the URL. Learn + about + [Arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations). + """ + + y: Union[float, str, None] = None + """ + Specifies the y-coordinate of the top-left corner of the base asset where the + overlay's top-left corner will be positioned. It also accepts arithmetic + expressions such as `bh_mul_0.4` or `bh_sub_ch`. Maps to `ly` in the URL. Learn + about + [Arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations). + """ diff --git a/src/imagekitio/types/shared/overlay_timing.py b/src/imagekitio/types/shared/overlay_timing.py new file mode 100644 index 00000000..f4d9c67a --- /dev/null +++ b/src/imagekitio/types/shared/overlay_timing.py @@ -0,0 +1,34 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union + +from ..._models import BaseModel + +__all__ = ["OverlayTiming"] + + +class OverlayTiming(BaseModel): + duration: Union[float, str, None] = None + """ + Specifies the duration (in seconds) during which the overlay should appear on + the base video. Accepts a positive number up to two decimal places (e.g., `20` + or `20.50`) and arithmetic expressions such as `bdu_mul_0.4` or `bdu_sub_idu`. + Applies only if the base asset is a video. Maps to `ldu` in the URL. + """ + + end: Union[float, str, None] = None + """ + Specifies the end time (in seconds) for when the overlay should disappear from + the base video. If both end and duration are provided, duration is ignored. + Accepts a positive number up to two decimal places (e.g., `20` or `20.50`) and + arithmetic expressions such as `bdu_mul_0.4` or `bdu_sub_idu`. Applies only if + the base asset is a video. Maps to `leo` in the URL. + """ + + start: Union[float, str, None] = None + """ + Specifies the start time (in seconds) for when the overlay should appear on the + base video. Accepts a positive number up to two decimal places (e.g., `20` or + `20.50`) and arithmetic expressions such as `bdu_mul_0.4` or `bdu_sub_idu`. + Applies only if the base asset is a video. Maps to `lso` in the URL. + """ diff --git a/src/imagekitio/types/shared/responsive_image_attributes.py b/src/imagekitio/types/shared/responsive_image_attributes.py new file mode 100644 index 00000000..3e9c0ed4 --- /dev/null +++ b/src/imagekitio/types/shared/responsive_image_attributes.py @@ -0,0 +1,34 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["ResponsiveImageAttributes"] + + +class ResponsiveImageAttributes(BaseModel): + """ + Resulting set of attributes suitable for an HTML `` element. + Useful for enabling responsive image loading with `srcSet` and `sizes`. + """ + + src: str + """URL for the _largest_ candidate (assigned to plain `src`).""" + + sizes: Optional[str] = None + """`sizes` returned (or synthesised as `100vw`). + + The value for the HTML `sizes` attribute. + """ + + src_set: Optional[str] = FieldInfo(alias="srcSet", default=None) + """Candidate set with `w` or `x` descriptors. + + Multiple image URLs separated by commas, each with a descriptor. + """ + + width: Optional[float] = None + """Width as a number (if `width` was provided in the input options).""" diff --git a/src/imagekitio/types/shared/solid_color_overlay.py b/src/imagekitio/types/shared/solid_color_overlay.py new file mode 100644 index 00000000..49c11a9d --- /dev/null +++ b/src/imagekitio/types/shared/solid_color_overlay.py @@ -0,0 +1,30 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .base_overlay import BaseOverlay +from .solid_color_overlay_transformation import SolidColorOverlayTransformation + +__all__ = ["SolidColorOverlay"] + + +class SolidColorOverlay(BaseOverlay): + color: str + """ + Specifies the color of the block using an RGB hex code (e.g., `FF0000`), an RGBA + code (e.g., `FFAABB50`), or a color name (e.g., `red`). If an 8-character value + is provided, the last two characters represent the opacity level (from `00` for + 0.00 to `99` for 0.99). + """ + + type: Literal["solidColor"] + + transformation: Optional[List[SolidColorOverlayTransformation]] = None + """Control width and height of the solid color overlay. + + Supported transformations depend on the base/parent asset. See overlays on + [Images](https://imagekit.io/docs/add-overlays-on-images#apply-transformation-on-solid-color-overlay) + and + [Videos](https://imagekit.io/docs/add-overlays-on-videos#apply-transformations-on-solid-color-block-overlay). + """ diff --git a/src/imagekitio/types/shared/solid_color_overlay_transformation.py b/src/imagekitio/types/shared/solid_color_overlay_transformation.py new file mode 100644 index 00000000..4e0f1733 --- /dev/null +++ b/src/imagekitio/types/shared/solid_color_overlay_transformation.py @@ -0,0 +1,53 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union, Optional +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["SolidColorOverlayTransformation"] + + +class SolidColorOverlayTransformation(BaseModel): + alpha: Optional[float] = None + """Specifies the transparency level of the solid color overlay. + + Accepts integers from `1` to `9`. + """ + + background: Optional[str] = None + """Specifies the background color of the solid color overlay. + + Accepts an RGB hex code (e.g., `FF0000`), an RGBA code (e.g., `FFAABB50`), or a + color name. + """ + + gradient: Union[Literal[True], str, None] = None + """Creates a linear gradient with two colors. + + Pass `true` for a default gradient, or provide a string for a custom gradient. + Only works if the base asset is an image. See + [gradient](https://imagekit.io/docs/effects-and-enhancements#gradient---e-gradient). + """ + + height: Union[float, str, None] = None + """Controls the height of the solid color overlay. + + Accepts a numeric value or an arithmetic expression. Learn about + [arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations). + """ + + radius: Union[float, Literal["max"], None] = None + """Specifies the corner radius of the solid color overlay. + + Set to `max` for circular or oval shape. See + [radius](https://imagekit.io/docs/effects-and-enhancements#radius---r). + """ + + width: Union[float, str, None] = None + """Controls the width of the solid color overlay. + + Accepts a numeric value or an arithmetic expression (e.g., `bw_mul_0.2` or + `bh_div_2`). Learn about + [arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations). + """ diff --git a/src/imagekitio/types/shared/src_options.py b/src/imagekitio/types/shared/src_options.py new file mode 100644 index 00000000..7f833284 --- /dev/null +++ b/src/imagekitio/types/shared/src_options.py @@ -0,0 +1,82 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, List, Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel +from .transformation_position import TransformationPosition + +__all__ = ["SrcOptions"] + + +class SrcOptions(BaseModel): + """Options for generating ImageKit URLs with transformations. + + See the [Transformations guide](https://imagekit.io/docs/transformations). + """ + + src: str + """Accepts a relative or absolute path of the resource. + + If a relative path is provided, it is appended to the `urlEndpoint`. If an + absolute path is provided, `urlEndpoint` is ignored. + """ + + url_endpoint: str = FieldInfo(alias="urlEndpoint") + """ + Get your urlEndpoint from the + [ImageKit dashboard](https://imagekit.io/dashboard/url-endpoints). + """ + + expires_in: Optional[float] = FieldInfo(alias="expiresIn", default=None) + """When you want the signed URL to expire, specified in seconds. + + If `expiresIn` is anything above 0, the URL will always be signed even if + `signed` is set to false. If not specified and `signed` is `true`, the signed + URL will not expire (valid indefinitely). + + Example: Setting `expiresIn: 3600` will make the URL expire 1 hour from + generation time. After the expiry time, the signed URL will no longer be valid + and ImageKit will return a 401 Unauthorized status code. + + [Learn more](https://imagekit.io/docs/media-delivery-basic-security#how-to-generate-signed-urls). + """ + + query_parameters: Optional[Dict[str, str]] = FieldInfo(alias="queryParameters", default=None) + """ + These are additional query parameters that you want to add to the final URL. + They can be any query parameters and not necessarily related to ImageKit. This + is especially useful if you want to add a versioning parameter to your URLs. + """ + + signed: Optional[bool] = None + """Whether to sign the URL or not. + + Set this to `true` if you want to generate a signed URL. If `signed` is `true` + and `expiresIn` is not specified, the signed URL will not expire (valid + indefinitely). Note: If `expiresIn` is set to any value above 0, the URL will + always be signed regardless of this setting. + [Learn more](https://imagekit.io/docs/media-delivery-basic-security#how-to-generate-signed-urls). + """ + + transformation: Optional[List["Transformation"]] = None + """An array of objects specifying the transformations to be applied in the URL. + + If more than one transformation is specified, they are applied in the order they + are specified as chained transformations. See + [Chained transformations](https://imagekit.io/docs/transformations#chained-transformations). + """ + + transformation_position: Optional[TransformationPosition] = FieldInfo(alias="transformationPosition", default=None) + """ + By default, the transformation string is added as a query parameter in the URL, + e.g., `?tr=w-100,h-100`. If you want to add the transformation string in the + path of the URL, set this to `path`. Learn more in the + [Transformations guide](https://imagekit.io/docs/transformations). + """ + + +from .transformation import Transformation diff --git a/src/imagekitio/types/shared/streaming_resolution.py b/src/imagekitio/types/shared/streaming_resolution.py new file mode 100644 index 00000000..6eb30085 --- /dev/null +++ b/src/imagekitio/types/shared/streaming_resolution.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["StreamingResolution"] + +StreamingResolution: TypeAlias = Literal["240", "360", "480", "720", "1080", "1440", "2160"] diff --git a/src/imagekitio/types/shared/subtitle_overlay.py b/src/imagekitio/types/shared/subtitle_overlay.py new file mode 100644 index 00000000..f44f3c4e --- /dev/null +++ b/src/imagekitio/types/shared/subtitle_overlay.py @@ -0,0 +1,32 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .base_overlay import BaseOverlay +from .subtitle_overlay_transformation import SubtitleOverlayTransformation + +__all__ = ["SubtitleOverlay"] + + +class SubtitleOverlay(BaseOverlay): + input: str + """Specifies the relative path to the subtitle file used as an overlay.""" + + type: Literal["subtitle"] + + encoding: Optional[Literal["auto", "plain", "base64"]] = None + """ + The input path can be included in the layer as either `i-{input}` or + `ie-{base64_encoded_input}`. By default, the SDK determines the appropriate + format automatically. To always use base64 encoding (`ie-{base64}`), set this + parameter to `base64`. To always use plain text (`i-{input}`), set it to + `plain`. + """ + + transformation: Optional[List[SubtitleOverlayTransformation]] = None + """Control styling of the subtitle. + + See + [Styling subtitles](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer). + """ diff --git a/src/imagekitio/types/shared/subtitle_overlay_transformation.py b/src/imagekitio/types/shared/subtitle_overlay_transformation.py new file mode 100644 index 00000000..2f7c739d --- /dev/null +++ b/src/imagekitio/types/shared/subtitle_overlay_transformation.py @@ -0,0 +1,80 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["SubtitleOverlayTransformation"] + + +class SubtitleOverlayTransformation(BaseModel): + """Subtitle styling options. + + [Learn more](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) from the docs. + """ + + background: Optional[str] = None + """ + Specifies the subtitle background color using a standard color name, an RGB + color code (e.g., FF0000), or an RGBA color code (e.g., FFAABB50). + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ + + color: Optional[str] = None + """ + Sets the font color of the subtitle text using a standard color name, an RGB + color code (e.g., FF0000), or an RGBA color code (e.g., FFAABB50). + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ + + font_family: Optional[str] = FieldInfo(alias="fontFamily", default=None) + """Font family for subtitles. + + Refer to the + [supported fonts](https://imagekit.io/docs/add-overlays-on-images#supported-text-font-list). + """ + + font_outline: Optional[str] = FieldInfo(alias="fontOutline", default=None) + """Sets the font outline of the subtitle text. + + Requires the outline width (an integer) and the outline color (as an RGB color + code, RGBA color code, or standard web color name) separated by an underscore. + Example: `fol-2_blue` (outline width of 2px and outline color blue), + `fol-2_A1CCDD` (outline width of 2px and outline color `#A1CCDD`) and + `fol-2_A1CCDD50` (outline width of 2px and outline color `#A1CCDD` at 50% + opacity). + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ + + font_shadow: Optional[str] = FieldInfo(alias="fontShadow", default=None) + """Sets the font shadow for the subtitle text. + + Requires the shadow color (as an RGB color code, RGBA color code, or standard + web color name) and shadow indent (an integer) separated by an underscore. + Example: `fsh-blue_2` (shadow color blue, indent of 2px), `fsh-A1CCDD_3` (shadow + color `#A1CCDD`, indent of 3px), `fsh-A1CCDD50_3` (shadow color `#A1CCDD` at 50% + opacity, indent of 3px). + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ + + font_size: Optional[float] = FieldInfo(alias="fontSize", default=None) + """Sets the font size of subtitle text. + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ + + typography: Optional[Literal["b", "i", "b_i"]] = None + """Sets the typography style of the subtitle text. + + Supports values are `b` for bold, `i` for italics, and `b_i` for bold with + italics. + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ diff --git a/src/imagekitio/types/shared/text_overlay.py b/src/imagekitio/types/shared/text_overlay.py new file mode 100644 index 00000000..b1568340 --- /dev/null +++ b/src/imagekitio/types/shared/text_overlay.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .base_overlay import BaseOverlay +from .text_overlay_transformation import TextOverlayTransformation + +__all__ = ["TextOverlay"] + + +class TextOverlay(BaseOverlay): + text: str + """Specifies the text to be displayed in the overlay. + + The SDK automatically handles special characters and encoding. + """ + + type: Literal["text"] + + encoding: Optional[Literal["auto", "plain", "base64"]] = None + """ + Text can be included in the layer as either `i-{input}` (plain text) or + `ie-{base64_encoded_input}` (base64). By default, the SDK selects the + appropriate format based on the input text. To always use base64 + (`ie-{base64}`), set this parameter to `base64`. To always use plain text + (`i-{input}`), set it to `plain`. + """ + + transformation: Optional[List[TextOverlayTransformation]] = None + """Control styling of the text overlay. + + See + [Text overlays](https://imagekit.io/docs/add-overlays-on-images#text-overlay). + """ diff --git a/src/imagekitio/types/shared/text_overlay_transformation.py b/src/imagekitio/types/shared/text_overlay_transformation.py new file mode 100644 index 00000000..8aa07111 --- /dev/null +++ b/src/imagekitio/types/shared/text_overlay_transformation.py @@ -0,0 +1,99 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union, Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["TextOverlayTransformation"] + + +class TextOverlayTransformation(BaseModel): + alpha: Optional[float] = None + """Specifies the transparency level of the text overlay. + + Accepts integers from `1` to `9`. + """ + + background: Optional[str] = None + """ + Specifies the background color of the text overlay. Accepts an RGB hex code, an + RGBA code, or a color name. + """ + + flip: Optional[Literal["h", "v", "h_v", "v_h"]] = None + """Flip the text overlay horizontally, vertically, or both.""" + + font_color: Optional[str] = FieldInfo(alias="fontColor", default=None) + """Specifies the font color of the overlaid text. + + Accepts an RGB hex code (e.g., `FF0000`), an RGBA code (e.g., `FFAABB50`), or a + color name. + """ + + font_family: Optional[str] = FieldInfo(alias="fontFamily", default=None) + """Specifies the font family of the overlaid text. + + Choose from the supported fonts list or use a custom font. See + [Supported fonts](https://imagekit.io/docs/add-overlays-on-images#supported-text-font-list) + and + [Custom font](https://imagekit.io/docs/add-overlays-on-images#change-font-family-in-text-overlay). + """ + + font_size: Union[float, str, None] = FieldInfo(alias="fontSize", default=None) + """Specifies the font size of the overlaid text. + + Accepts a numeric value or an arithmetic expression. + """ + + inner_alignment: Optional[Literal["left", "right", "center"]] = FieldInfo(alias="innerAlignment", default=None) + """ + Specifies the inner alignment of the text when width is more than the text + length. + """ + + line_height: Union[float, str, None] = FieldInfo(alias="lineHeight", default=None) + """Specifies the line height of the text overlay. + + Accepts integer values representing line height in points. It can also accept + [arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations) + such as `bw_mul_0.2`, or `bh_div_20`. + """ + + padding: Union[float, str, None] = None + """ + Specifies the padding around the overlaid text. Can be provided as a single + positive integer or multiple values separated by underscores (following CSS + shorthand order). Arithmetic expressions are also accepted. + """ + + radius: Union[float, Literal["max"], None] = None + """ + Specifies the corner radius of the text overlay. Set to `max` to achieve a + circular or oval shape. + """ + + rotation: Union[float, str, None] = None + """ + Specifies the rotation angle of the text overlay. Accepts a numeric value for + clockwise rotation or a string prefixed with "N" for counter-clockwise rotation. + """ + + typography: Optional[str] = None + """Specifies the typography style of the text. Supported values: + + - Single styles: `b` (bold), `i` (italic), `strikethrough`. + - Combinations: Any combination separated by underscores, e.g., `b_i`, + `b_i_strikethrough`. + """ + + width: Union[float, str, None] = None + """Specifies the maximum width (in pixels) of the overlaid text. + + The text wraps automatically, and arithmetic expressions (e.g., `bw_mul_0.2` or + `bh_div_2`) are supported. Useful when used in conjunction with the + `background`. Learn about + [Arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations). + """ diff --git a/src/imagekitio/types/shared/transformation.py b/src/imagekitio/types/shared/transformation.py new file mode 100644 index 00000000..c0f42d1b --- /dev/null +++ b/src/imagekitio/types/shared/transformation.py @@ -0,0 +1,434 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Union, Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel +from .streaming_resolution import StreamingResolution + +__all__ = ["Transformation"] + + +class Transformation(BaseModel): + """The SDK provides easy-to-use names for transformations. + + These names are converted to the corresponding transformation string before being added to the URL. + SDKs are updated regularly to support new transformations. If you want to use a transformation that is not supported by the SDK, + You can use the `raw` parameter to pass the transformation string directly. + See the [Transformations documentation](https://imagekit.io/docs/transformations). + """ + + ai_change_background: Optional[str] = FieldInfo(alias="aiChangeBackground", default=None) + """Uses AI to change the background. + + Provide a text prompt or a base64-encoded prompt, e.g., `prompt-snow road` or + `prompte-[urlencoded_base64_encoded_text]`. Not supported inside overlay. See + [AI Change Background](https://imagekit.io/docs/ai-transformations#change-background-e-changebg). + """ + + ai_drop_shadow: Union[Literal[True], str, None] = FieldInfo(alias="aiDropShadow", default=None) + """ + Adds an AI-based drop shadow around a foreground object on a transparent or + removed background. Optionally, control the direction, elevation, and saturation + of the light source (e.g., `az-45` to change light direction). Pass `true` for + the default drop shadow, or provide a string for a custom drop shadow. Supported + inside overlay. See + [AI Drop Shadow](https://imagekit.io/docs/ai-transformations#ai-drop-shadow-e-dropshadow). + """ + + ai_edit: Optional[str] = FieldInfo(alias="aiEdit", default=None) + """Uses AI to edit images based on a text prompt. + + Provide a text prompt or a base64-encoded prompt, e.g., `prompt-snow road` or + `prompte-[urlencoded_base64_encoded_text]`. Not supported inside overlay. + See [AI Edit](https://imagekit.io/docs/ai-transformations#edit-image-e-edit). + """ + + ai_remove_background: Optional[Literal[True]] = FieldInfo(alias="aiRemoveBackground", default=None) + """Applies ImageKit's in-house background removal. + + Supported inside overlay. See + [AI Background Removal](https://imagekit.io/docs/ai-transformations#imagekit-background-removal-e-bgremove). + """ + + ai_remove_background_external: Optional[Literal[True]] = FieldInfo(alias="aiRemoveBackgroundExternal", default=None) + """Uses third-party background removal. + + Note: It is recommended to use aiRemoveBackground, ImageKit's in-house solution, + which is more cost-effective. Supported inside overlay. See + [External Background Removal](https://imagekit.io/docs/ai-transformations#background-removal-e-removedotbg). + """ + + ai_retouch: Optional[Literal[True]] = FieldInfo(alias="aiRetouch", default=None) + """Performs AI-based retouching to improve faces or product shots. + + Not supported inside overlay. See + [AI Retouch](https://imagekit.io/docs/ai-transformations#retouch-e-retouch). + """ + + ai_upscale: Optional[Literal[True]] = FieldInfo(alias="aiUpscale", default=None) + """Upscales images beyond their original dimensions using AI. + + Not supported inside overlay. See + [AI Upscale](https://imagekit.io/docs/ai-transformations#upscale-e-upscale). + """ + + ai_variation: Optional[Literal[True]] = FieldInfo(alias="aiVariation", default=None) + """Generates a variation of an image using AI. + + This produces a new image with slight variations from the original, such as + changes in color, texture, and other visual elements, while preserving the + structure and essence of the original image. Not supported inside overlay. See + [AI Generate Variations](https://imagekit.io/docs/ai-transformations#generate-variations-of-an-image-e-genvar). + """ + + aspect_ratio: Union[float, str, None] = FieldInfo(alias="aspectRatio", default=None) + """Specifies the aspect ratio for the output, e.g., "ar-4-3". + + Typically used with either width or height (but not both). For example: + aspectRatio = `4:3`, `4_3`, or an expression like `iar_div_2`. See + [Image resize and crop – Aspect ratio](https://imagekit.io/docs/image-resize-and-crop#aspect-ratio---ar). + """ + + audio_codec: Optional[Literal["aac", "opus", "none"]] = FieldInfo(alias="audioCodec", default=None) + """Specifies the audio codec, e.g., `aac`, `opus`, or `none`. + + See [Audio codec](https://imagekit.io/docs/video-optimization#audio-codec---ac). + """ + + background: Optional[str] = None + """ + Specifies the background to be used in conjunction with certain cropping + strategies when resizing an image. + + - A solid color: e.g., `red`, `F3F3F3`, `AAFF0010`. See + [Solid color background](https://imagekit.io/docs/effects-and-enhancements#solid-color-background). + - A blurred background: e.g., `blurred`, `blurred_25_N15`, etc. See + [Blurred background](https://imagekit.io/docs/effects-and-enhancements#blurred-background). + - Expand the image boundaries using generative fill: `genfill`. Not supported + inside overlay. Optionally, control the background scene by passing a text + prompt: `genfill[:-prompt-${text}]` or + `genfill[:-prompte-${urlencoded_base64_encoded_text}]`. See + [Generative fill background](https://imagekit.io/docs/ai-transformations#generative-fill-bg-genfill). + """ + + blur: Optional[float] = None + """Specifies the Gaussian blur level. + + Accepts an integer value between 1 and 100, or an expression like `bl-10`. See + [Blur](https://imagekit.io/docs/effects-and-enhancements#blur---bl). + """ + + border: Optional[str] = None + """Adds a border to the output media. + + Accepts a string in the format `_` (e.g., `5_FFF000` for + a 5px yellow border), or an expression like `ih_div_20_FF00FF`. See + [Border](https://imagekit.io/docs/effects-and-enhancements#border---b). + """ + + color_profile: Optional[bool] = FieldInfo(alias="colorProfile", default=None) + """ + Indicates whether the output image should retain the original color profile. See + [Color profile](https://imagekit.io/docs/image-optimization#color-profile---cp). + """ + + contrast_stretch: Optional[Literal[True]] = FieldInfo(alias="contrastStretch", default=None) + """ + Automatically enhances the contrast of an image (contrast stretch). See + [Contrast Stretch](https://imagekit.io/docs/effects-and-enhancements#contrast-stretch---e-contrast). + """ + + crop: Optional[Literal["force", "at_max", "at_max_enlarge", "at_least", "maintain_ratio"]] = None + """Crop modes for image resizing. + + See + [Crop modes & focus](https://imagekit.io/docs/image-resize-and-crop#crop-crop-modes--focus). + """ + + crop_mode: Optional[Literal["pad_resize", "extract", "pad_extract"]] = FieldInfo(alias="cropMode", default=None) + """Additional crop modes for image resizing. + + See + [Crop modes & focus](https://imagekit.io/docs/image-resize-and-crop#crop-crop-modes--focus). + """ + + default_image: Optional[str] = FieldInfo(alias="defaultImage", default=None) + """ + Specifies a fallback image if the resource is not found, e.g., a URL or file + path. See + [Default image](https://imagekit.io/docs/image-transformation#default-image---di). + """ + + dpr: Optional[float] = None + """ + Accepts values between 0.1 and 5, or `auto` for automatic device pixel ratio + (DPR) calculation. See + [DPR](https://imagekit.io/docs/image-resize-and-crop#dpr---dpr). + """ + + duration: Union[float, str, None] = None + """Specifies the duration (in seconds) for trimming videos, e.g., `5` or `10.5`. + + Typically used with startOffset to indicate the length from the start offset. + Arithmetic expressions are supported. See + [Trim videos – Duration](https://imagekit.io/docs/trim-videos#duration---du). + """ + + end_offset: Union[float, str, None] = FieldInfo(alias="endOffset", default=None) + """Specifies the end offset (in seconds) for trimming videos, e.g., `5` or `10.5`. + + Typically used with startOffset to define a time window. Arithmetic expressions + are supported. See + [Trim videos – End offset](https://imagekit.io/docs/trim-videos#end-offset---eo). + """ + + flip: Optional[Literal["h", "v", "h_v", "v_h"]] = None + """Flips or mirrors an image either horizontally, vertically, or both. + + Acceptable values: `h` (horizontal), `v` (vertical), `h_v` (horizontal and + vertical), or `v_h`. See + [Flip](https://imagekit.io/docs/effects-and-enhancements#flip---fl). + """ + + focus: Optional[str] = None + """ + Refines padding and cropping behavior for pad resize, maintain ratio, and + extract crop modes. Supports manual positions and coordinate-based focus. With + AI-based cropping, you can automatically keep key subjects in frame—such as + faces or detected objects (e.g., `fo-face`, `fo-person`, `fo-car`)— while + resizing. + + - See [Focus](https://imagekit.io/docs/image-resize-and-crop#focus---fo). + - [Object aware cropping](https://imagekit.io/docs/image-resize-and-crop#object-aware-cropping---fo-object-name) + """ + + format: Optional[Literal["auto", "webp", "jpg", "jpeg", "png", "gif", "svg", "mp4", "webm", "avif", "orig"]] = None + """ + Specifies the output format for images or videos, e.g., `jpg`, `png`, `webp`, + `mp4`, or `auto`. You can also pass `orig` for images to return the original + format. ImageKit automatically delivers images and videos in the optimal format + based on device support unless overridden by the dashboard settings or the + format parameter. See + [Image format](https://imagekit.io/docs/image-optimization#format---f) and + [Video format](https://imagekit.io/docs/video-optimization#format---f). + """ + + gradient: Union[Literal[True], str, None] = None + """Creates a linear gradient with two colors. + + Pass `true` for a default gradient, or provide a string for a custom gradient. + See + [Gradient](https://imagekit.io/docs/effects-and-enhancements#gradient---e-gradient). + """ + + grayscale: Optional[Literal[True]] = None + """Enables a grayscale effect for images. + + See + [Grayscale](https://imagekit.io/docs/effects-and-enhancements#grayscale---e-grayscale). + """ + + height: Union[float, str, None] = None + """Specifies the height of the output. + + If a value between 0 and 1 is provided, it is treated as a percentage (e.g., + `0.5` represents 50% of the original height). You can also supply arithmetic + expressions (e.g., `ih_mul_0.5`). Height transformation – + [Images](https://imagekit.io/docs/image-resize-and-crop#height---h) · + [Videos](https://imagekit.io/docs/video-resize-and-crop#height---h) + """ + + lossless: Optional[bool] = None + """ + Specifies whether the output image (in JPEG or PNG) should be compressed + losslessly. See + [Lossless compression](https://imagekit.io/docs/image-optimization#lossless-webp-and-png---lo). + """ + + metadata: Optional[bool] = None + """By default, ImageKit removes all metadata during automatic image compression. + + Set this to true to preserve metadata. See + [Image metadata](https://imagekit.io/docs/image-optimization#image-metadata---md). + """ + + named: Optional[str] = None + """Named transformation reference. + + See + [Named transformations](https://imagekit.io/docs/transformations#named-transformations). + """ + + opacity: Optional[float] = None + """Specifies the opacity level of the output image. + + See [Opacity](https://imagekit.io/docs/effects-and-enhancements#opacity---o). + """ + + original: Optional[bool] = None + """ + If set to true, serves the original file without applying any transformations. + See + [Deliver original file as-is](https://imagekit.io/docs/core-delivery-features#deliver-original-file-as-is---orig-true). + """ + + overlay: Optional["Overlay"] = None + """Specifies an overlay to be applied on the parent image or video. + + ImageKit supports overlays including images, text, videos, subtitles, and solid + colors. See + [Overlay using layers](https://imagekit.io/docs/transformations#overlay-using-layers). + """ + + page: Union[float, str, None] = None + """ + Extracts a specific page or frame from multi-page or layered files (PDF, PSD, + AI). For example, specify by number (e.g., `2`), a range (e.g., `3-4` for the + 2nd and 3rd layers), or by name (e.g., `name-layer-4` for a PSD layer). See + [Thumbnail extraction](https://imagekit.io/docs/vector-and-animated-images#get-thumbnail-from-psd-pdf-ai-eps-and-animated-files). + """ + + progressive: Optional[bool] = None + """Specifies whether the output JPEG image should be rendered progressively. + + Progressive loading begins with a low-quality, pixelated version of the full + image, which gradually improves to provide a faster perceived load time. See + [Progressive images](https://imagekit.io/docs/image-optimization#progressive-image---pr). + """ + + quality: Optional[float] = None + """ + Specifies the quality of the output image for lossy formats such as JPEG, WebP, + and AVIF. A higher quality value results in a larger file size with better + quality, while a lower value produces a smaller file size with reduced quality. + See [Quality](https://imagekit.io/docs/image-optimization#quality---q). + """ + + radius: Union[float, Literal["max"], None] = None + """ + Specifies the corner radius for rounded corners (e.g., 20) or `max` for circular + or oval shape. See + [Radius](https://imagekit.io/docs/effects-and-enhancements#radius---r). + """ + + raw: Optional[str] = None + """Pass any transformation not directly supported by the SDK. + + This transformation string is appended to the URL as provided. + """ + + rotation: Union[float, str, None] = None + """Specifies the rotation angle in degrees. + + Positive values rotate the image clockwise; you can also use, for example, `N40` + for counterclockwise rotation or `auto` to use the orientation specified in the + image's EXIF data. For videos, only the following values are supported: 0, 90, + 180, 270, or 360. See + [Rotate](https://imagekit.io/docs/effects-and-enhancements#rotate---rt). + """ + + shadow: Union[Literal[True], str, None] = None + """Adds a shadow beneath solid objects in an image with a transparent background. + + For AI-based drop shadows, refer to aiDropShadow. Pass `true` for a default + shadow, or provide a string for a custom shadow. See + [Shadow](https://imagekit.io/docs/effects-and-enhancements#shadow---e-shadow). + """ + + sharpen: Union[Literal[True], float, None] = None + """Sharpens the input image, highlighting edges and finer details. + + Pass `true` for default sharpening, or provide a numeric value for custom + sharpening. See + [Sharpen](https://imagekit.io/docs/effects-and-enhancements#sharpen---e-sharpen). + """ + + start_offset: Union[float, str, None] = FieldInfo(alias="startOffset", default=None) + """Specifies the start offset (in seconds) for trimming videos, e.g., `5` or + `10.5`. + + Arithmetic expressions are also supported. See + [Trim videos – Start offset](https://imagekit.io/docs/trim-videos#start-offset---so). + """ + + streaming_resolutions: Optional[List[StreamingResolution]] = FieldInfo(alias="streamingResolutions", default=None) + """ + An array of resolutions for adaptive bitrate streaming, e.g., [`240`, `360`, + `480`, `720`, `1080`]. See + [Adaptive Bitrate Streaming](https://imagekit.io/docs/adaptive-bitrate-streaming). + """ + + trim: Union[Literal[True], float, None] = None + """Useful for images with a solid or nearly solid background and a central object. + + This parameter trims the background, leaving only the central object in the + output image. See + [Trim edges](https://imagekit.io/docs/effects-and-enhancements#trim-edges---t). + """ + + unsharp_mask: Union[Literal[True], str, None] = FieldInfo(alias="unsharpMask", default=None) + """Applies Unsharp Masking (USM), an image sharpening technique. + + Pass `true` for a default unsharp mask, or provide a string for a custom unsharp + mask. See + [Unsharp Mask](https://imagekit.io/docs/effects-and-enhancements#unsharp-mask---e-usm). + """ + + video_codec: Optional[Literal["h264", "vp9", "av1", "none"]] = FieldInfo(alias="videoCodec", default=None) + """Specifies the video codec, e.g., `h264`, `vp9`, `av1`, or `none`. + + See [Video codec](https://imagekit.io/docs/video-optimization#video-codec---vc). + """ + + width: Union[float, str, None] = None + """Specifies the width of the output. + + If a value between 0 and 1 is provided, it is treated as a percentage (e.g., + `0.4` represents 40% of the original width). You can also supply arithmetic + expressions (e.g., `iw_div_2`). Width transformation – + [Images](https://imagekit.io/docs/image-resize-and-crop#width---w) · + [Videos](https://imagekit.io/docs/video-resize-and-crop#width---w) + """ + + x: Union[float, str, None] = None + """Focus using cropped image coordinates - X coordinate. + + See + [Focus using cropped coordinates](https://imagekit.io/docs/image-resize-and-crop#example---focus-using-cropped-image-coordinates). + """ + + x_center: Union[float, str, None] = FieldInfo(alias="xCenter", default=None) + """Focus using cropped image coordinates - X center coordinate. + + See + [Focus using cropped coordinates](https://imagekit.io/docs/image-resize-and-crop#example---focus-using-cropped-image-coordinates). + """ + + y: Union[float, str, None] = None + """Focus using cropped image coordinates - Y coordinate. + + See + [Focus using cropped coordinates](https://imagekit.io/docs/image-resize-and-crop#example---focus-using-cropped-image-coordinates). + """ + + y_center: Union[float, str, None] = FieldInfo(alias="yCenter", default=None) + """Focus using cropped image coordinates - Y center coordinate. + + See + [Focus using cropped coordinates](https://imagekit.io/docs/image-resize-and-crop#example---focus-using-cropped-image-coordinates). + """ + + zoom: Optional[float] = None + """ + Accepts a numeric value that determines how much to zoom in or out of the + cropped area. It should be used in conjunction with fo-face or fo-. + See [Zoom](https://imagekit.io/docs/image-resize-and-crop#zoom---z). + """ + + +from .overlay import Overlay diff --git a/src/imagekitio/types/shared/transformation_position.py b/src/imagekitio/types/shared/transformation_position.py new file mode 100644 index 00000000..bded9e81 --- /dev/null +++ b/src/imagekitio/types/shared/transformation_position.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["TransformationPosition"] + +TransformationPosition: TypeAlias = Literal["path", "query"] diff --git a/src/imagekitio/types/shared/video_overlay.py b/src/imagekitio/types/shared/video_overlay.py new file mode 100644 index 00000000..3cc64c64 --- /dev/null +++ b/src/imagekitio/types/shared/video_overlay.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Optional +from typing_extensions import Literal + +from .base_overlay import BaseOverlay + +__all__ = ["VideoOverlay"] + + +class VideoOverlay(BaseOverlay): + input: str + """Specifies the relative path to the video used as an overlay.""" + + type: Literal["video"] + + encoding: Optional[Literal["auto", "plain", "base64"]] = None + """ + The input path can be included in the layer as either `i-{input}` or + `ie-{base64_encoded_input}`. By default, the SDK determines the appropriate + format automatically. To always use base64 encoding (`ie-{base64}`), set this + parameter to `base64`. To always use plain text (`i-{input}`), set it to + `plain`. + """ + + transformation: Optional[List["Transformation"]] = None + """Array of transformation to be applied to the overlay video. + + Except `streamingResolutions`, all other video transformations are supported. + See [Video transformations](https://imagekit.io/docs/video-transformation). + """ + + +from .transformation import Transformation diff --git a/src/imagekitio/types/shared_params/__init__.py b/src/imagekitio/types/shared_params/__init__.py new file mode 100644 index 00000000..49f3e91b --- /dev/null +++ b/src/imagekitio/types/shared_params/__init__.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .overlay import Overlay as Overlay +from .extensions import Extensions as Extensions +from .src_options import SrcOptions as SrcOptions +from .base_overlay import BaseOverlay as BaseOverlay +from .text_overlay import TextOverlay as TextOverlay +from .image_overlay import ImageOverlay as ImageOverlay +from .video_overlay import VideoOverlay as VideoOverlay +from .overlay_timing import OverlayTiming as OverlayTiming +from .transformation import Transformation as Transformation +from .overlay_position import OverlayPosition as OverlayPosition +from .subtitle_overlay import SubtitleOverlay as SubtitleOverlay +from .solid_color_overlay import SolidColorOverlay as SolidColorOverlay +from .streaming_resolution import StreamingResolution as StreamingResolution +from .transformation_position import TransformationPosition as TransformationPosition +from .responsive_image_attributes import ResponsiveImageAttributes as ResponsiveImageAttributes +from .text_overlay_transformation import TextOverlayTransformation as TextOverlayTransformation +from .get_image_attributes_options import GetImageAttributesOptions as GetImageAttributesOptions +from .subtitle_overlay_transformation import SubtitleOverlayTransformation as SubtitleOverlayTransformation +from .solid_color_overlay_transformation import SolidColorOverlayTransformation as SolidColorOverlayTransformation diff --git a/src/imagekitio/types/shared_params/base_overlay.py b/src/imagekitio/types/shared_params/base_overlay.py new file mode 100644 index 00000000..bf3bf1eb --- /dev/null +++ b/src/imagekitio/types/shared_params/base_overlay.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +from .overlay_timing import OverlayTiming +from .overlay_position import OverlayPosition + +__all__ = ["BaseOverlay"] + + +class BaseOverlay(TypedDict, total=False): + position: OverlayPosition + + timing: OverlayTiming diff --git a/src/imagekitio/types/shared_params/extensions.py b/src/imagekitio/types/shared_params/extensions.py new file mode 100644 index 00000000..f2ab9d14 --- /dev/null +++ b/src/imagekitio/types/shared_params/extensions.py @@ -0,0 +1,76 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Union +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict + +from ..._utils import PropertyInfo + +__all__ = [ + "Extensions", + "ExtensionItem", + "ExtensionItemRemoveBg", + "ExtensionItemRemoveBgOptions", + "ExtensionItemAutoTaggingExtension", + "ExtensionItemAIAutoDescription", +] + + +class ExtensionItemRemoveBgOptions(TypedDict, total=False): + add_shadow: bool + """Whether to add an artificial shadow to the result. + + Default is false. Note: Adding shadows is currently only supported for car + photos. + """ + + bg_color: str + """ + Specifies a solid color background using hex code (e.g., "81d4fa", "fff") or + color name (e.g., "green"). If this parameter is set, `bg_image_url` must be + empty. + """ + + bg_image_url: str + """Sets a background image from a URL. + + If this parameter is set, `bg_color` must be empty. + """ + + semitransparency: bool + """Allows semi-transparent regions in the result. + + Default is true. Note: Semitransparency is currently only supported for car + windows. + """ + + +class ExtensionItemRemoveBg(TypedDict, total=False): + name: Required[Literal["remove-bg"]] + """Specifies the background removal extension.""" + + options: ExtensionItemRemoveBgOptions + + +class ExtensionItemAutoTaggingExtension(TypedDict, total=False): + max_tags: Required[Annotated[int, PropertyInfo(alias="maxTags")]] + """Maximum number of tags to attach to the asset.""" + + min_confidence: Required[Annotated[int, PropertyInfo(alias="minConfidence")]] + """Minimum confidence level for tags to be considered valid.""" + + name: Required[Literal["google-auto-tagging", "aws-auto-tagging"]] + """Specifies the auto-tagging extension used.""" + + +class ExtensionItemAIAutoDescription(TypedDict, total=False): + name: Required[Literal["ai-auto-description"]] + """Specifies the auto description extension.""" + + +ExtensionItem: TypeAlias = Union[ + ExtensionItemRemoveBg, ExtensionItemAutoTaggingExtension, ExtensionItemAIAutoDescription +] + +Extensions: TypeAlias = List[ExtensionItem] diff --git a/src/imagekitio/types/shared_params/get_image_attributes_options.py b/src/imagekitio/types/shared_params/get_image_attributes_options.py new file mode 100644 index 00000000..e8bf8b6b --- /dev/null +++ b/src/imagekitio/types/shared_params/get_image_attributes_options.py @@ -0,0 +1,59 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Annotated + +from ..._utils import PropertyInfo +from .src_options import SrcOptions + +__all__ = ["GetImageAttributesOptions"] + + +class GetImageAttributesOptions(SrcOptions, total=False): + """ + Options for generating responsive image attributes including `src`, `srcSet`, and `sizes` for HTML `` elements. + This schema extends `SrcOptions` to add support for responsive image generation with breakpoints. + """ + + device_breakpoints: Annotated[Iterable[float], PropertyInfo(alias="deviceBreakpoints")] + """ + Custom list of **device-width breakpoints** in pixels. These define common + screen widths for responsive image generation. + + Defaults to `[640, 750, 828, 1080, 1200, 1920, 2048, 3840]`. Sorted + automatically. + """ + + image_breakpoints: Annotated[Iterable[float], PropertyInfo(alias="imageBreakpoints")] + """ + Custom list of **image-specific breakpoints** in pixels. Useful for generating + small variants (e.g., placeholders or thumbnails). + + Merged with `deviceBreakpoints` before calculating `srcSet`. Defaults to + `[16, 32, 48, 64, 96, 128, 256, 384]`. Sorted automatically. + """ + + sizes: str + """ + The value for the HTML `sizes` attribute (e.g., `"100vw"` or + `"(min-width:768px) 50vw, 100vw"`). + + - If it includes one or more `vw` units, breakpoints smaller than the + corresponding percentage of the smallest device width are excluded. + - If it contains no `vw` units, the full breakpoint list is used. + + Enables a width-based strategy and generates `w` descriptors in `srcSet`. + """ + + width: float + """ + The intended display width of the image in pixels, used **only when the `sizes` + attribute is not provided**. + + Triggers a DPR-based strategy (1x and 2x variants) and generates `x` descriptors + in `srcSet`. + + Ignored if `sizes` is present. + """ diff --git a/src/imagekitio/types/shared_params/image_overlay.py b/src/imagekitio/types/shared_params/image_overlay.py new file mode 100644 index 00000000..3b7d74e0 --- /dev/null +++ b/src/imagekitio/types/shared_params/image_overlay.py @@ -0,0 +1,38 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Literal, Required + +from .base_overlay import BaseOverlay + +__all__ = ["ImageOverlay"] + + +class ImageOverlay(BaseOverlay, total=False): + input: Required[str] + """Specifies the relative path to the image used as an overlay.""" + + type: Required[Literal["image"]] + + encoding: Literal["auto", "plain", "base64"] + """ + The input path can be included in the layer as either `i-{input}` or + `ie-{base64_encoded_input}`. By default, the SDK determines the appropriate + format automatically. To always use base64 encoding (`ie-{base64}`), set this + parameter to `base64`. To always use plain text (`i-{input}`), set it to + `plain`. + """ + + transformation: Iterable["Transformation"] + """Array of transformations to be applied to the overlay image. + + Supported transformations depends on the base/parent asset. See overlays on + [Images](https://imagekit.io/docs/add-overlays-on-images#list-of-supported-image-transformations-in-image-layers) + and + [Videos](https://imagekit.io/docs/add-overlays-on-videos#list-of-transformations-supported-on-image-overlay). + """ + + +from .transformation import Transformation diff --git a/src/imagekitio/types/shared_params/overlay.py b/src/imagekitio/types/shared_params/overlay.py new file mode 100644 index 00000000..ed4e79a8 --- /dev/null +++ b/src/imagekitio/types/shared_params/overlay.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Union +from typing_extensions import TypeAlias, TypeAliasType + +from ..._compat import PYDANTIC_V1 +from .text_overlay import TextOverlay +from .subtitle_overlay import SubtitleOverlay +from .solid_color_overlay import SolidColorOverlay + +__all__ = ["Overlay"] + +if TYPE_CHECKING or not PYDANTIC_V1: + Overlay = TypeAliasType( + "Overlay", Union[TextOverlay, "ImageOverlay", "VideoOverlay", SubtitleOverlay, SolidColorOverlay] + ) +else: + Overlay: TypeAlias = Union[TextOverlay, "ImageOverlay", "VideoOverlay", SubtitleOverlay, SolidColorOverlay] + +from .image_overlay import ImageOverlay +from .video_overlay import VideoOverlay diff --git a/src/imagekitio/types/shared_params/overlay_position.py b/src/imagekitio/types/shared_params/overlay_position.py new file mode 100644 index 00000000..f74e3e1b --- /dev/null +++ b/src/imagekitio/types/shared_params/overlay_position.py @@ -0,0 +1,34 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, TypedDict + +__all__ = ["OverlayPosition"] + + +class OverlayPosition(TypedDict, total=False): + focus: Literal["center", "top", "left", "bottom", "right", "top_left", "top_right", "bottom_left", "bottom_right"] + """ + Specifies the position of the overlay relative to the parent image or video. + Maps to `lfo` in the URL. + """ + + x: Union[float, str] + """ + Specifies the x-coordinate of the top-left corner of the base asset where the + overlay's top-left corner will be positioned. It also accepts arithmetic + expressions such as `bw_mul_0.4` or `bw_sub_cw`. Maps to `lx` in the URL. Learn + about + [Arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations). + """ + + y: Union[float, str] + """ + Specifies the y-coordinate of the top-left corner of the base asset where the + overlay's top-left corner will be positioned. It also accepts arithmetic + expressions such as `bh_mul_0.4` or `bh_sub_ch`. Maps to `ly` in the URL. Learn + about + [Arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations). + """ diff --git a/src/imagekitio/types/shared_params/overlay_timing.py b/src/imagekitio/types/shared_params/overlay_timing.py new file mode 100644 index 00000000..4f766d1a --- /dev/null +++ b/src/imagekitio/types/shared_params/overlay_timing.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import TypedDict + +__all__ = ["OverlayTiming"] + + +class OverlayTiming(TypedDict, total=False): + duration: Union[float, str] + """ + Specifies the duration (in seconds) during which the overlay should appear on + the base video. Accepts a positive number up to two decimal places (e.g., `20` + or `20.50`) and arithmetic expressions such as `bdu_mul_0.4` or `bdu_sub_idu`. + Applies only if the base asset is a video. Maps to `ldu` in the URL. + """ + + end: Union[float, str] + """ + Specifies the end time (in seconds) for when the overlay should disappear from + the base video. If both end and duration are provided, duration is ignored. + Accepts a positive number up to two decimal places (e.g., `20` or `20.50`) and + arithmetic expressions such as `bdu_mul_0.4` or `bdu_sub_idu`. Applies only if + the base asset is a video. Maps to `leo` in the URL. + """ + + start: Union[float, str] + """ + Specifies the start time (in seconds) for when the overlay should appear on the + base video. Accepts a positive number up to two decimal places (e.g., `20` or + `20.50`) and arithmetic expressions such as `bdu_mul_0.4` or `bdu_sub_idu`. + Applies only if the base asset is a video. Maps to `lso` in the URL. + """ diff --git a/src/imagekitio/types/shared_params/responsive_image_attributes.py b/src/imagekitio/types/shared_params/responsive_image_attributes.py new file mode 100644 index 00000000..fbf901ca --- /dev/null +++ b/src/imagekitio/types/shared_params/responsive_image_attributes.py @@ -0,0 +1,34 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from ..._utils import PropertyInfo + +__all__ = ["ResponsiveImageAttributes"] + + +class ResponsiveImageAttributes(TypedDict, total=False): + """ + Resulting set of attributes suitable for an HTML `` element. + Useful for enabling responsive image loading with `srcSet` and `sizes`. + """ + + src: Required[str] + """URL for the _largest_ candidate (assigned to plain `src`).""" + + sizes: str + """`sizes` returned (or synthesised as `100vw`). + + The value for the HTML `sizes` attribute. + """ + + src_set: Annotated[str, PropertyInfo(alias="srcSet")] + """Candidate set with `w` or `x` descriptors. + + Multiple image URLs separated by commas, each with a descriptor. + """ + + width: float + """Width as a number (if `width` was provided in the input options).""" diff --git a/src/imagekitio/types/shared_params/solid_color_overlay.py b/src/imagekitio/types/shared_params/solid_color_overlay.py new file mode 100644 index 00000000..35f36b14 --- /dev/null +++ b/src/imagekitio/types/shared_params/solid_color_overlay.py @@ -0,0 +1,32 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Literal, Required + +from .base_overlay import BaseOverlay +from .solid_color_overlay_transformation import SolidColorOverlayTransformation + +__all__ = ["SolidColorOverlay"] + + +class SolidColorOverlay(BaseOverlay, total=False): + color: Required[str] + """ + Specifies the color of the block using an RGB hex code (e.g., `FF0000`), an RGBA + code (e.g., `FFAABB50`), or a color name (e.g., `red`). If an 8-character value + is provided, the last two characters represent the opacity level (from `00` for + 0.00 to `99` for 0.99). + """ + + type: Required[Literal["solidColor"]] + + transformation: Iterable[SolidColorOverlayTransformation] + """Control width and height of the solid color overlay. + + Supported transformations depend on the base/parent asset. See overlays on + [Images](https://imagekit.io/docs/add-overlays-on-images#apply-transformation-on-solid-color-overlay) + and + [Videos](https://imagekit.io/docs/add-overlays-on-videos#apply-transformations-on-solid-color-block-overlay). + """ diff --git a/src/imagekitio/types/shared_params/solid_color_overlay_transformation.py b/src/imagekitio/types/shared_params/solid_color_overlay_transformation.py new file mode 100644 index 00000000..8bfcca71 --- /dev/null +++ b/src/imagekitio/types/shared_params/solid_color_overlay_transformation.py @@ -0,0 +1,53 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, TypedDict + +__all__ = ["SolidColorOverlayTransformation"] + + +class SolidColorOverlayTransformation(TypedDict, total=False): + alpha: float + """Specifies the transparency level of the solid color overlay. + + Accepts integers from `1` to `9`. + """ + + background: str + """Specifies the background color of the solid color overlay. + + Accepts an RGB hex code (e.g., `FF0000`), an RGBA code (e.g., `FFAABB50`), or a + color name. + """ + + gradient: Union[Literal[True], str] + """Creates a linear gradient with two colors. + + Pass `true` for a default gradient, or provide a string for a custom gradient. + Only works if the base asset is an image. See + [gradient](https://imagekit.io/docs/effects-and-enhancements#gradient---e-gradient). + """ + + height: Union[float, str] + """Controls the height of the solid color overlay. + + Accepts a numeric value or an arithmetic expression. Learn about + [arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations). + """ + + radius: Union[float, Literal["max"]] + """Specifies the corner radius of the solid color overlay. + + Set to `max` for circular or oval shape. See + [radius](https://imagekit.io/docs/effects-and-enhancements#radius---r). + """ + + width: Union[float, str] + """Controls the width of the solid color overlay. + + Accepts a numeric value or an arithmetic expression (e.g., `bw_mul_0.2` or + `bh_div_2`). Learn about + [arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations). + """ diff --git a/src/imagekitio/types/shared_params/src_options.py b/src/imagekitio/types/shared_params/src_options.py new file mode 100644 index 00000000..cd262e53 --- /dev/null +++ b/src/imagekitio/types/shared_params/src_options.py @@ -0,0 +1,81 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable +from typing_extensions import Required, Annotated, TypedDict + +from ..._utils import PropertyInfo +from ..shared.transformation_position import TransformationPosition + +__all__ = ["SrcOptions"] + + +class SrcOptions(TypedDict, total=False): + """Options for generating ImageKit URLs with transformations. + + See the [Transformations guide](https://imagekit.io/docs/transformations). + """ + + src: Required[str] + """Accepts a relative or absolute path of the resource. + + If a relative path is provided, it is appended to the `urlEndpoint`. If an + absolute path is provided, `urlEndpoint` is ignored. + """ + + url_endpoint: Required[Annotated[str, PropertyInfo(alias="urlEndpoint")]] + """ + Get your urlEndpoint from the + [ImageKit dashboard](https://imagekit.io/dashboard/url-endpoints). + """ + + expires_in: Annotated[float, PropertyInfo(alias="expiresIn")] + """When you want the signed URL to expire, specified in seconds. + + If `expiresIn` is anything above 0, the URL will always be signed even if + `signed` is set to false. If not specified and `signed` is `true`, the signed + URL will not expire (valid indefinitely). + + Example: Setting `expiresIn: 3600` will make the URL expire 1 hour from + generation time. After the expiry time, the signed URL will no longer be valid + and ImageKit will return a 401 Unauthorized status code. + + [Learn more](https://imagekit.io/docs/media-delivery-basic-security#how-to-generate-signed-urls). + """ + + query_parameters: Annotated[Dict[str, str], PropertyInfo(alias="queryParameters")] + """ + These are additional query parameters that you want to add to the final URL. + They can be any query parameters and not necessarily related to ImageKit. This + is especially useful if you want to add a versioning parameter to your URLs. + """ + + signed: bool + """Whether to sign the URL or not. + + Set this to `true` if you want to generate a signed URL. If `signed` is `true` + and `expiresIn` is not specified, the signed URL will not expire (valid + indefinitely). Note: If `expiresIn` is set to any value above 0, the URL will + always be signed regardless of this setting. + [Learn more](https://imagekit.io/docs/media-delivery-basic-security#how-to-generate-signed-urls). + """ + + transformation: Iterable["Transformation"] + """An array of objects specifying the transformations to be applied in the URL. + + If more than one transformation is specified, they are applied in the order they + are specified as chained transformations. See + [Chained transformations](https://imagekit.io/docs/transformations#chained-transformations). + """ + + transformation_position: Annotated[TransformationPosition, PropertyInfo(alias="transformationPosition")] + """ + By default, the transformation string is added as a query parameter in the URL, + e.g., `?tr=w-100,h-100`. If you want to add the transformation string in the + path of the URL, set this to `path`. Learn more in the + [Transformations guide](https://imagekit.io/docs/transformations). + """ + + +from .transformation import Transformation diff --git a/src/imagekitio/types/shared_params/streaming_resolution.py b/src/imagekitio/types/shared_params/streaming_resolution.py new file mode 100644 index 00000000..ac32902e --- /dev/null +++ b/src/imagekitio/types/shared_params/streaming_resolution.py @@ -0,0 +1,9 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypeAlias + +__all__ = ["StreamingResolution"] + +StreamingResolution: TypeAlias = Literal["240", "360", "480", "720", "1080", "1440", "2160"] diff --git a/src/imagekitio/types/shared_params/subtitle_overlay.py b/src/imagekitio/types/shared_params/subtitle_overlay.py new file mode 100644 index 00000000..71e885ee --- /dev/null +++ b/src/imagekitio/types/shared_params/subtitle_overlay.py @@ -0,0 +1,34 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Literal, Required + +from .base_overlay import BaseOverlay +from .subtitle_overlay_transformation import SubtitleOverlayTransformation + +__all__ = ["SubtitleOverlay"] + + +class SubtitleOverlay(BaseOverlay, total=False): + input: Required[str] + """Specifies the relative path to the subtitle file used as an overlay.""" + + type: Required[Literal["subtitle"]] + + encoding: Literal["auto", "plain", "base64"] + """ + The input path can be included in the layer as either `i-{input}` or + `ie-{base64_encoded_input}`. By default, the SDK determines the appropriate + format automatically. To always use base64 encoding (`ie-{base64}`), set this + parameter to `base64`. To always use plain text (`i-{input}`), set it to + `plain`. + """ + + transformation: Iterable[SubtitleOverlayTransformation] + """Control styling of the subtitle. + + See + [Styling subtitles](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer). + """ diff --git a/src/imagekitio/types/shared_params/subtitle_overlay_transformation.py b/src/imagekitio/types/shared_params/subtitle_overlay_transformation.py new file mode 100644 index 00000000..08b8de57 --- /dev/null +++ b/src/imagekitio/types/shared_params/subtitle_overlay_transformation.py @@ -0,0 +1,79 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Annotated, TypedDict + +from ..._utils import PropertyInfo + +__all__ = ["SubtitleOverlayTransformation"] + + +class SubtitleOverlayTransformation(TypedDict, total=False): + """Subtitle styling options. + + [Learn more](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) from the docs. + """ + + background: str + """ + Specifies the subtitle background color using a standard color name, an RGB + color code (e.g., FF0000), or an RGBA color code (e.g., FFAABB50). + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ + + color: str + """ + Sets the font color of the subtitle text using a standard color name, an RGB + color code (e.g., FF0000), or an RGBA color code (e.g., FFAABB50). + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ + + font_family: Annotated[str, PropertyInfo(alias="fontFamily")] + """Font family for subtitles. + + Refer to the + [supported fonts](https://imagekit.io/docs/add-overlays-on-images#supported-text-font-list). + """ + + font_outline: Annotated[str, PropertyInfo(alias="fontOutline")] + """Sets the font outline of the subtitle text. + + Requires the outline width (an integer) and the outline color (as an RGB color + code, RGBA color code, or standard web color name) separated by an underscore. + Example: `fol-2_blue` (outline width of 2px and outline color blue), + `fol-2_A1CCDD` (outline width of 2px and outline color `#A1CCDD`) and + `fol-2_A1CCDD50` (outline width of 2px and outline color `#A1CCDD` at 50% + opacity). + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ + + font_shadow: Annotated[str, PropertyInfo(alias="fontShadow")] + """Sets the font shadow for the subtitle text. + + Requires the shadow color (as an RGB color code, RGBA color code, or standard + web color name) and shadow indent (an integer) separated by an underscore. + Example: `fsh-blue_2` (shadow color blue, indent of 2px), `fsh-A1CCDD_3` (shadow + color `#A1CCDD`, indent of 3px), `fsh-A1CCDD50_3` (shadow color `#A1CCDD` at 50% + opacity, indent of 3px). + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ + + font_size: Annotated[float, PropertyInfo(alias="fontSize")] + """Sets the font size of subtitle text. + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ + + typography: Literal["b", "i", "b_i"] + """Sets the typography style of the subtitle text. + + Supports values are `b` for bold, `i` for italics, and `b_i` for bold with + italics. + + [Subtitle styling options](https://imagekit.io/docs/add-overlays-on-videos#styling-controls-for-subtitles-layer) + """ diff --git a/src/imagekitio/types/shared_params/text_overlay.py b/src/imagekitio/types/shared_params/text_overlay.py new file mode 100644 index 00000000..62ebe4cc --- /dev/null +++ b/src/imagekitio/types/shared_params/text_overlay.py @@ -0,0 +1,37 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Literal, Required + +from .base_overlay import BaseOverlay +from .text_overlay_transformation import TextOverlayTransformation + +__all__ = ["TextOverlay"] + + +class TextOverlay(BaseOverlay, total=False): + text: Required[str] + """Specifies the text to be displayed in the overlay. + + The SDK automatically handles special characters and encoding. + """ + + type: Required[Literal["text"]] + + encoding: Literal["auto", "plain", "base64"] + """ + Text can be included in the layer as either `i-{input}` (plain text) or + `ie-{base64_encoded_input}` (base64). By default, the SDK selects the + appropriate format based on the input text. To always use base64 + (`ie-{base64}`), set this parameter to `base64`. To always use plain text + (`i-{input}`), set it to `plain`. + """ + + transformation: Iterable[TextOverlayTransformation] + """Control styling of the text overlay. + + See + [Text overlays](https://imagekit.io/docs/add-overlays-on-images#text-overlay). + """ diff --git a/src/imagekitio/types/shared_params/text_overlay_transformation.py b/src/imagekitio/types/shared_params/text_overlay_transformation.py new file mode 100644 index 00000000..5f05fbd2 --- /dev/null +++ b/src/imagekitio/types/shared_params/text_overlay_transformation.py @@ -0,0 +1,99 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, Annotated, TypedDict + +from ..._utils import PropertyInfo + +__all__ = ["TextOverlayTransformation"] + + +class TextOverlayTransformation(TypedDict, total=False): + alpha: float + """Specifies the transparency level of the text overlay. + + Accepts integers from `1` to `9`. + """ + + background: str + """ + Specifies the background color of the text overlay. Accepts an RGB hex code, an + RGBA code, or a color name. + """ + + flip: Literal["h", "v", "h_v", "v_h"] + """Flip the text overlay horizontally, vertically, or both.""" + + font_color: Annotated[str, PropertyInfo(alias="fontColor")] + """Specifies the font color of the overlaid text. + + Accepts an RGB hex code (e.g., `FF0000`), an RGBA code (e.g., `FFAABB50`), or a + color name. + """ + + font_family: Annotated[str, PropertyInfo(alias="fontFamily")] + """Specifies the font family of the overlaid text. + + Choose from the supported fonts list or use a custom font. See + [Supported fonts](https://imagekit.io/docs/add-overlays-on-images#supported-text-font-list) + and + [Custom font](https://imagekit.io/docs/add-overlays-on-images#change-font-family-in-text-overlay). + """ + + font_size: Annotated[Union[float, str], PropertyInfo(alias="fontSize")] + """Specifies the font size of the overlaid text. + + Accepts a numeric value or an arithmetic expression. + """ + + inner_alignment: Annotated[Literal["left", "right", "center"], PropertyInfo(alias="innerAlignment")] + """ + Specifies the inner alignment of the text when width is more than the text + length. + """ + + line_height: Annotated[Union[float, str], PropertyInfo(alias="lineHeight")] + """Specifies the line height of the text overlay. + + Accepts integer values representing line height in points. It can also accept + [arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations) + such as `bw_mul_0.2`, or `bh_div_20`. + """ + + padding: Union[float, str] + """ + Specifies the padding around the overlaid text. Can be provided as a single + positive integer or multiple values separated by underscores (following CSS + shorthand order). Arithmetic expressions are also accepted. + """ + + radius: Union[float, Literal["max"]] + """ + Specifies the corner radius of the text overlay. Set to `max` to achieve a + circular or oval shape. + """ + + rotation: Union[float, str] + """ + Specifies the rotation angle of the text overlay. Accepts a numeric value for + clockwise rotation or a string prefixed with "N" for counter-clockwise rotation. + """ + + typography: str + """Specifies the typography style of the text. Supported values: + + - Single styles: `b` (bold), `i` (italic), `strikethrough`. + - Combinations: Any combination separated by underscores, e.g., `b_i`, + `b_i_strikethrough`. + """ + + width: Union[float, str] + """Specifies the maximum width (in pixels) of the overlaid text. + + The text wraps automatically, and arithmetic expressions (e.g., `bw_mul_0.2` or + `bh_div_2`) are supported. Useful when used in conjunction with the + `background`. Learn about + [Arithmetic expressions](https://imagekit.io/docs/arithmetic-expressions-in-transformations). + """ diff --git a/src/imagekitio/types/shared_params/transformation.py b/src/imagekitio/types/shared_params/transformation.py new file mode 100644 index 00000000..a48ddf8e --- /dev/null +++ b/src/imagekitio/types/shared_params/transformation.py @@ -0,0 +1,432 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Union +from typing_extensions import Literal, Annotated, TypedDict + +from ..._utils import PropertyInfo +from ..shared.streaming_resolution import StreamingResolution + +__all__ = ["Transformation"] + + +class Transformation(TypedDict, total=False): + """The SDK provides easy-to-use names for transformations. + + These names are converted to the corresponding transformation string before being added to the URL. + SDKs are updated regularly to support new transformations. If you want to use a transformation that is not supported by the SDK, + You can use the `raw` parameter to pass the transformation string directly. + See the [Transformations documentation](https://imagekit.io/docs/transformations). + """ + + ai_change_background: Annotated[str, PropertyInfo(alias="aiChangeBackground")] + """Uses AI to change the background. + + Provide a text prompt or a base64-encoded prompt, e.g., `prompt-snow road` or + `prompte-[urlencoded_base64_encoded_text]`. Not supported inside overlay. See + [AI Change Background](https://imagekit.io/docs/ai-transformations#change-background-e-changebg). + """ + + ai_drop_shadow: Annotated[Union[Literal[True], str], PropertyInfo(alias="aiDropShadow")] + """ + Adds an AI-based drop shadow around a foreground object on a transparent or + removed background. Optionally, control the direction, elevation, and saturation + of the light source (e.g., `az-45` to change light direction). Pass `true` for + the default drop shadow, or provide a string for a custom drop shadow. Supported + inside overlay. See + [AI Drop Shadow](https://imagekit.io/docs/ai-transformations#ai-drop-shadow-e-dropshadow). + """ + + ai_edit: Annotated[str, PropertyInfo(alias="aiEdit")] + """Uses AI to edit images based on a text prompt. + + Provide a text prompt or a base64-encoded prompt, e.g., `prompt-snow road` or + `prompte-[urlencoded_base64_encoded_text]`. Not supported inside overlay. + See [AI Edit](https://imagekit.io/docs/ai-transformations#edit-image-e-edit). + """ + + ai_remove_background: Annotated[Literal[True], PropertyInfo(alias="aiRemoveBackground")] + """Applies ImageKit's in-house background removal. + + Supported inside overlay. See + [AI Background Removal](https://imagekit.io/docs/ai-transformations#imagekit-background-removal-e-bgremove). + """ + + ai_remove_background_external: Annotated[Literal[True], PropertyInfo(alias="aiRemoveBackgroundExternal")] + """Uses third-party background removal. + + Note: It is recommended to use aiRemoveBackground, ImageKit's in-house solution, + which is more cost-effective. Supported inside overlay. See + [External Background Removal](https://imagekit.io/docs/ai-transformations#background-removal-e-removedotbg). + """ + + ai_retouch: Annotated[Literal[True], PropertyInfo(alias="aiRetouch")] + """Performs AI-based retouching to improve faces or product shots. + + Not supported inside overlay. See + [AI Retouch](https://imagekit.io/docs/ai-transformations#retouch-e-retouch). + """ + + ai_upscale: Annotated[Literal[True], PropertyInfo(alias="aiUpscale")] + """Upscales images beyond their original dimensions using AI. + + Not supported inside overlay. See + [AI Upscale](https://imagekit.io/docs/ai-transformations#upscale-e-upscale). + """ + + ai_variation: Annotated[Literal[True], PropertyInfo(alias="aiVariation")] + """Generates a variation of an image using AI. + + This produces a new image with slight variations from the original, such as + changes in color, texture, and other visual elements, while preserving the + structure and essence of the original image. Not supported inside overlay. See + [AI Generate Variations](https://imagekit.io/docs/ai-transformations#generate-variations-of-an-image-e-genvar). + """ + + aspect_ratio: Annotated[Union[float, str], PropertyInfo(alias="aspectRatio")] + """Specifies the aspect ratio for the output, e.g., "ar-4-3". + + Typically used with either width or height (but not both). For example: + aspectRatio = `4:3`, `4_3`, or an expression like `iar_div_2`. See + [Image resize and crop – Aspect ratio](https://imagekit.io/docs/image-resize-and-crop#aspect-ratio---ar). + """ + + audio_codec: Annotated[Literal["aac", "opus", "none"], PropertyInfo(alias="audioCodec")] + """Specifies the audio codec, e.g., `aac`, `opus`, or `none`. + + See [Audio codec](https://imagekit.io/docs/video-optimization#audio-codec---ac). + """ + + background: str + """ + Specifies the background to be used in conjunction with certain cropping + strategies when resizing an image. + + - A solid color: e.g., `red`, `F3F3F3`, `AAFF0010`. See + [Solid color background](https://imagekit.io/docs/effects-and-enhancements#solid-color-background). + - A blurred background: e.g., `blurred`, `blurred_25_N15`, etc. See + [Blurred background](https://imagekit.io/docs/effects-and-enhancements#blurred-background). + - Expand the image boundaries using generative fill: `genfill`. Not supported + inside overlay. Optionally, control the background scene by passing a text + prompt: `genfill[:-prompt-${text}]` or + `genfill[:-prompte-${urlencoded_base64_encoded_text}]`. See + [Generative fill background](https://imagekit.io/docs/ai-transformations#generative-fill-bg-genfill). + """ + + blur: float + """Specifies the Gaussian blur level. + + Accepts an integer value between 1 and 100, or an expression like `bl-10`. See + [Blur](https://imagekit.io/docs/effects-and-enhancements#blur---bl). + """ + + border: str + """Adds a border to the output media. + + Accepts a string in the format `_` (e.g., `5_FFF000` for + a 5px yellow border), or an expression like `ih_div_20_FF00FF`. See + [Border](https://imagekit.io/docs/effects-and-enhancements#border---b). + """ + + color_profile: Annotated[bool, PropertyInfo(alias="colorProfile")] + """ + Indicates whether the output image should retain the original color profile. See + [Color profile](https://imagekit.io/docs/image-optimization#color-profile---cp). + """ + + contrast_stretch: Annotated[Literal[True], PropertyInfo(alias="contrastStretch")] + """ + Automatically enhances the contrast of an image (contrast stretch). See + [Contrast Stretch](https://imagekit.io/docs/effects-and-enhancements#contrast-stretch---e-contrast). + """ + + crop: Literal["force", "at_max", "at_max_enlarge", "at_least", "maintain_ratio"] + """Crop modes for image resizing. + + See + [Crop modes & focus](https://imagekit.io/docs/image-resize-and-crop#crop-crop-modes--focus). + """ + + crop_mode: Annotated[Literal["pad_resize", "extract", "pad_extract"], PropertyInfo(alias="cropMode")] + """Additional crop modes for image resizing. + + See + [Crop modes & focus](https://imagekit.io/docs/image-resize-and-crop#crop-crop-modes--focus). + """ + + default_image: Annotated[str, PropertyInfo(alias="defaultImage")] + """ + Specifies a fallback image if the resource is not found, e.g., a URL or file + path. See + [Default image](https://imagekit.io/docs/image-transformation#default-image---di). + """ + + dpr: float + """ + Accepts values between 0.1 and 5, or `auto` for automatic device pixel ratio + (DPR) calculation. See + [DPR](https://imagekit.io/docs/image-resize-and-crop#dpr---dpr). + """ + + duration: Union[float, str] + """Specifies the duration (in seconds) for trimming videos, e.g., `5` or `10.5`. + + Typically used with startOffset to indicate the length from the start offset. + Arithmetic expressions are supported. See + [Trim videos – Duration](https://imagekit.io/docs/trim-videos#duration---du). + """ + + end_offset: Annotated[Union[float, str], PropertyInfo(alias="endOffset")] + """Specifies the end offset (in seconds) for trimming videos, e.g., `5` or `10.5`. + + Typically used with startOffset to define a time window. Arithmetic expressions + are supported. See + [Trim videos – End offset](https://imagekit.io/docs/trim-videos#end-offset---eo). + """ + + flip: Literal["h", "v", "h_v", "v_h"] + """Flips or mirrors an image either horizontally, vertically, or both. + + Acceptable values: `h` (horizontal), `v` (vertical), `h_v` (horizontal and + vertical), or `v_h`. See + [Flip](https://imagekit.io/docs/effects-and-enhancements#flip---fl). + """ + + focus: str + """ + Refines padding and cropping behavior for pad resize, maintain ratio, and + extract crop modes. Supports manual positions and coordinate-based focus. With + AI-based cropping, you can automatically keep key subjects in frame—such as + faces or detected objects (e.g., `fo-face`, `fo-person`, `fo-car`)— while + resizing. + + - See [Focus](https://imagekit.io/docs/image-resize-and-crop#focus---fo). + - [Object aware cropping](https://imagekit.io/docs/image-resize-and-crop#object-aware-cropping---fo-object-name) + """ + + format: Literal["auto", "webp", "jpg", "jpeg", "png", "gif", "svg", "mp4", "webm", "avif", "orig"] + """ + Specifies the output format for images or videos, e.g., `jpg`, `png`, `webp`, + `mp4`, or `auto`. You can also pass `orig` for images to return the original + format. ImageKit automatically delivers images and videos in the optimal format + based on device support unless overridden by the dashboard settings or the + format parameter. See + [Image format](https://imagekit.io/docs/image-optimization#format---f) and + [Video format](https://imagekit.io/docs/video-optimization#format---f). + """ + + gradient: Union[Literal[True], str] + """Creates a linear gradient with two colors. + + Pass `true` for a default gradient, or provide a string for a custom gradient. + See + [Gradient](https://imagekit.io/docs/effects-and-enhancements#gradient---e-gradient). + """ + + grayscale: Literal[True] + """Enables a grayscale effect for images. + + See + [Grayscale](https://imagekit.io/docs/effects-and-enhancements#grayscale---e-grayscale). + """ + + height: Union[float, str] + """Specifies the height of the output. + + If a value between 0 and 1 is provided, it is treated as a percentage (e.g., + `0.5` represents 50% of the original height). You can also supply arithmetic + expressions (e.g., `ih_mul_0.5`). Height transformation – + [Images](https://imagekit.io/docs/image-resize-and-crop#height---h) · + [Videos](https://imagekit.io/docs/video-resize-and-crop#height---h) + """ + + lossless: bool + """ + Specifies whether the output image (in JPEG or PNG) should be compressed + losslessly. See + [Lossless compression](https://imagekit.io/docs/image-optimization#lossless-webp-and-png---lo). + """ + + metadata: bool + """By default, ImageKit removes all metadata during automatic image compression. + + Set this to true to preserve metadata. See + [Image metadata](https://imagekit.io/docs/image-optimization#image-metadata---md). + """ + + named: str + """Named transformation reference. + + See + [Named transformations](https://imagekit.io/docs/transformations#named-transformations). + """ + + opacity: float + """Specifies the opacity level of the output image. + + See [Opacity](https://imagekit.io/docs/effects-and-enhancements#opacity---o). + """ + + original: bool + """ + If set to true, serves the original file without applying any transformations. + See + [Deliver original file as-is](https://imagekit.io/docs/core-delivery-features#deliver-original-file-as-is---orig-true). + """ + + overlay: "Overlay" + """Specifies an overlay to be applied on the parent image or video. + + ImageKit supports overlays including images, text, videos, subtitles, and solid + colors. See + [Overlay using layers](https://imagekit.io/docs/transformations#overlay-using-layers). + """ + + page: Union[float, str] + """ + Extracts a specific page or frame from multi-page or layered files (PDF, PSD, + AI). For example, specify by number (e.g., `2`), a range (e.g., `3-4` for the + 2nd and 3rd layers), or by name (e.g., `name-layer-4` for a PSD layer). See + [Thumbnail extraction](https://imagekit.io/docs/vector-and-animated-images#get-thumbnail-from-psd-pdf-ai-eps-and-animated-files). + """ + + progressive: bool + """Specifies whether the output JPEG image should be rendered progressively. + + Progressive loading begins with a low-quality, pixelated version of the full + image, which gradually improves to provide a faster perceived load time. See + [Progressive images](https://imagekit.io/docs/image-optimization#progressive-image---pr). + """ + + quality: float + """ + Specifies the quality of the output image for lossy formats such as JPEG, WebP, + and AVIF. A higher quality value results in a larger file size with better + quality, while a lower value produces a smaller file size with reduced quality. + See [Quality](https://imagekit.io/docs/image-optimization#quality---q). + """ + + radius: Union[float, Literal["max"]] + """ + Specifies the corner radius for rounded corners (e.g., 20) or `max` for circular + or oval shape. See + [Radius](https://imagekit.io/docs/effects-and-enhancements#radius---r). + """ + + raw: str + """Pass any transformation not directly supported by the SDK. + + This transformation string is appended to the URL as provided. + """ + + rotation: Union[float, str] + """Specifies the rotation angle in degrees. + + Positive values rotate the image clockwise; you can also use, for example, `N40` + for counterclockwise rotation or `auto` to use the orientation specified in the + image's EXIF data. For videos, only the following values are supported: 0, 90, + 180, 270, or 360. See + [Rotate](https://imagekit.io/docs/effects-and-enhancements#rotate---rt). + """ + + shadow: Union[Literal[True], str] + """Adds a shadow beneath solid objects in an image with a transparent background. + + For AI-based drop shadows, refer to aiDropShadow. Pass `true` for a default + shadow, or provide a string for a custom shadow. See + [Shadow](https://imagekit.io/docs/effects-and-enhancements#shadow---e-shadow). + """ + + sharpen: Union[Literal[True], float] + """Sharpens the input image, highlighting edges and finer details. + + Pass `true` for default sharpening, or provide a numeric value for custom + sharpening. See + [Sharpen](https://imagekit.io/docs/effects-and-enhancements#sharpen---e-sharpen). + """ + + start_offset: Annotated[Union[float, str], PropertyInfo(alias="startOffset")] + """Specifies the start offset (in seconds) for trimming videos, e.g., `5` or + `10.5`. + + Arithmetic expressions are also supported. See + [Trim videos – Start offset](https://imagekit.io/docs/trim-videos#start-offset---so). + """ + + streaming_resolutions: Annotated[List[StreamingResolution], PropertyInfo(alias="streamingResolutions")] + """ + An array of resolutions for adaptive bitrate streaming, e.g., [`240`, `360`, + `480`, `720`, `1080`]. See + [Adaptive Bitrate Streaming](https://imagekit.io/docs/adaptive-bitrate-streaming). + """ + + trim: Union[Literal[True], float] + """Useful for images with a solid or nearly solid background and a central object. + + This parameter trims the background, leaving only the central object in the + output image. See + [Trim edges](https://imagekit.io/docs/effects-and-enhancements#trim-edges---t). + """ + + unsharp_mask: Annotated[Union[Literal[True], str], PropertyInfo(alias="unsharpMask")] + """Applies Unsharp Masking (USM), an image sharpening technique. + + Pass `true` for a default unsharp mask, or provide a string for a custom unsharp + mask. See + [Unsharp Mask](https://imagekit.io/docs/effects-and-enhancements#unsharp-mask---e-usm). + """ + + video_codec: Annotated[Literal["h264", "vp9", "av1", "none"], PropertyInfo(alias="videoCodec")] + """Specifies the video codec, e.g., `h264`, `vp9`, `av1`, or `none`. + + See [Video codec](https://imagekit.io/docs/video-optimization#video-codec---vc). + """ + + width: Union[float, str] + """Specifies the width of the output. + + If a value between 0 and 1 is provided, it is treated as a percentage (e.g., + `0.4` represents 40% of the original width). You can also supply arithmetic + expressions (e.g., `iw_div_2`). Width transformation – + [Images](https://imagekit.io/docs/image-resize-and-crop#width---w) · + [Videos](https://imagekit.io/docs/video-resize-and-crop#width---w) + """ + + x: Union[float, str] + """Focus using cropped image coordinates - X coordinate. + + See + [Focus using cropped coordinates](https://imagekit.io/docs/image-resize-and-crop#example---focus-using-cropped-image-coordinates). + """ + + x_center: Annotated[Union[float, str], PropertyInfo(alias="xCenter")] + """Focus using cropped image coordinates - X center coordinate. + + See + [Focus using cropped coordinates](https://imagekit.io/docs/image-resize-and-crop#example---focus-using-cropped-image-coordinates). + """ + + y: Union[float, str] + """Focus using cropped image coordinates - Y coordinate. + + See + [Focus using cropped coordinates](https://imagekit.io/docs/image-resize-and-crop#example---focus-using-cropped-image-coordinates). + """ + + y_center: Annotated[Union[float, str], PropertyInfo(alias="yCenter")] + """Focus using cropped image coordinates - Y center coordinate. + + See + [Focus using cropped coordinates](https://imagekit.io/docs/image-resize-and-crop#example---focus-using-cropped-image-coordinates). + """ + + zoom: float + """ + Accepts a numeric value that determines how much to zoom in or out of the + cropped area. It should be used in conjunction with fo-face or fo-. + See [Zoom](https://imagekit.io/docs/image-resize-and-crop#zoom---z). + """ + + +from .overlay import Overlay diff --git a/src/imagekitio/types/shared_params/transformation_position.py b/src/imagekitio/types/shared_params/transformation_position.py new file mode 100644 index 00000000..3959993f --- /dev/null +++ b/src/imagekitio/types/shared_params/transformation_position.py @@ -0,0 +1,9 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypeAlias + +__all__ = ["TransformationPosition"] + +TransformationPosition: TypeAlias = Literal["path", "query"] diff --git a/src/imagekitio/types/shared_params/video_overlay.py b/src/imagekitio/types/shared_params/video_overlay.py new file mode 100644 index 00000000..6c020fa2 --- /dev/null +++ b/src/imagekitio/types/shared_params/video_overlay.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Literal, Required + +from .base_overlay import BaseOverlay + +__all__ = ["VideoOverlay"] + + +class VideoOverlay(BaseOverlay, total=False): + input: Required[str] + """Specifies the relative path to the video used as an overlay.""" + + type: Required[Literal["video"]] + + encoding: Literal["auto", "plain", "base64"] + """ + The input path can be included in the layer as either `i-{input}` or + `ie-{base64_encoded_input}`. By default, the SDK determines the appropriate + format automatically. To always use base64 encoding (`ie-{base64}`), set this + parameter to `base64`. To always use plain text (`i-{input}`), set it to + `plain`. + """ + + transformation: Iterable["Transformation"] + """Array of transformation to be applied to the overlay video. + + Except `streamingResolutions`, all other video transformations are supported. + See [Video transformations](https://imagekit.io/docs/video-transformation). + """ + + +from .transformation import Transformation diff --git a/src/imagekitio/types/unsafe_unwrap_webhook_event.py b/src/imagekitio/types/unsafe_unwrap_webhook_event.py new file mode 100644 index 00000000..9ed05b32 --- /dev/null +++ b/src/imagekitio/types/unsafe_unwrap_webhook_event.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from .._utils import PropertyInfo +from .upload_pre_transform_error_event import UploadPreTransformErrorEvent +from .video_transformation_error_event import VideoTransformationErrorEvent +from .video_transformation_ready_event import VideoTransformationReadyEvent +from .upload_post_transform_error_event import UploadPostTransformErrorEvent +from .upload_pre_transform_success_event import UploadPreTransformSuccessEvent +from .upload_post_transform_success_event import UploadPostTransformSuccessEvent +from .video_transformation_accepted_event import VideoTransformationAcceptedEvent + +__all__ = ["UnsafeUnwrapWebhookEvent"] + +UnsafeUnwrapWebhookEvent: TypeAlias = Annotated[ + Union[ + VideoTransformationAcceptedEvent, + VideoTransformationReadyEvent, + VideoTransformationErrorEvent, + UploadPreTransformSuccessEvent, + UploadPreTransformErrorEvent, + UploadPostTransformSuccessEvent, + UploadPostTransformErrorEvent, + ], + PropertyInfo(discriminator="type"), +] diff --git a/src/imagekitio/types/unwrap_webhook_event.py b/src/imagekitio/types/unwrap_webhook_event.py new file mode 100644 index 00000000..e67355f5 --- /dev/null +++ b/src/imagekitio/types/unwrap_webhook_event.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from .._utils import PropertyInfo +from .upload_pre_transform_error_event import UploadPreTransformErrorEvent +from .video_transformation_error_event import VideoTransformationErrorEvent +from .video_transformation_ready_event import VideoTransformationReadyEvent +from .upload_post_transform_error_event import UploadPostTransformErrorEvent +from .upload_pre_transform_success_event import UploadPreTransformSuccessEvent +from .upload_post_transform_success_event import UploadPostTransformSuccessEvent +from .video_transformation_accepted_event import VideoTransformationAcceptedEvent + +__all__ = ["UnwrapWebhookEvent"] + +UnwrapWebhookEvent: TypeAlias = Annotated[ + Union[ + VideoTransformationAcceptedEvent, + VideoTransformationReadyEvent, + VideoTransformationErrorEvent, + UploadPreTransformSuccessEvent, + UploadPreTransformErrorEvent, + UploadPostTransformSuccessEvent, + UploadPostTransformErrorEvent, + ], + PropertyInfo(discriminator="type"), +] diff --git a/src/imagekitio/types/update_file_request_param.py b/src/imagekitio/types/update_file_request_param.py new file mode 100644 index 00000000..8b90827f --- /dev/null +++ b/src/imagekitio/types/update_file_request_param.py @@ -0,0 +1,85 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Optional +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo +from .shared_params.extensions import Extensions + +__all__ = ["UpdateFileRequestParam", "UpdateFileDetails", "ChangePublicationStatus", "ChangePublicationStatusPublish"] + + +class UpdateFileDetails(TypedDict, total=False): + custom_coordinates: Annotated[Optional[str], PropertyInfo(alias="customCoordinates")] + """Define an important area in the image in the format `x,y,width,height` e.g. + + `10,10,100,100`. Send `null` to unset this value. + """ + + custom_metadata: Annotated[Dict[str, object], PropertyInfo(alias="customMetadata")] + """A key-value data to be associated with the asset. + + To unset a key, send `null` value for that key. Before setting any custom + metadata on an asset you have to create the field using custom metadata fields + API. + """ + + description: str + """Optional text to describe the contents of the file.""" + + extensions: Extensions + """Array of extensions to be applied to the asset. + + Each extension can be configured with specific parameters based on the extension + type. + """ + + remove_ai_tags: Annotated[Union[SequenceNotStr[str], Literal["all"]], PropertyInfo(alias="removeAITags")] + """An array of AITags associated with the file that you want to remove, e.g. + + `["car", "vehicle", "motorsports"]`. + + If you want to remove all AITags associated with the file, send a string - + "all". + + Note: The remove operation for `AITags` executes before any of the `extensions` + are processed. + """ + + tags: Optional[SequenceNotStr[str]] + """An array of tags associated with the file, such as `["tag1", "tag2"]`. + + Send `null` to unset all tags associated with the file. + """ + + webhook_url: Annotated[str, PropertyInfo(alias="webhookUrl")] + """ + The final status of extensions after they have completed execution will be + delivered to this endpoint as a POST request. + [Learn more](/docs/api-reference/digital-asset-management-dam/managing-assets/update-file-details#webhook-payload-structure) + about the webhook payload structure. + """ + + +class ChangePublicationStatusPublish(TypedDict, total=False): + """Configure the publication status of a file and its versions.""" + + is_published: Required[Annotated[bool, PropertyInfo(alias="isPublished")]] + """Set to `true` to publish the file. Set to `false` to unpublish the file.""" + + include_file_versions: Annotated[bool, PropertyInfo(alias="includeFileVersions")] + """Set to `true` to publish/unpublish all versions of the file. + + Set to `false` to publish/unpublish only the current version of the file. + """ + + +class ChangePublicationStatus(TypedDict, total=False): + publish: ChangePublicationStatusPublish + """Configure the publication status of a file and its versions.""" + + +UpdateFileRequestParam: TypeAlias = Union[UpdateFileDetails, ChangePublicationStatus] diff --git a/src/imagekitio/types/upload_post_transform_error_event.py b/src/imagekitio/types/upload_post_transform_error_event.py new file mode 100644 index 00000000..8f0a4ce3 --- /dev/null +++ b/src/imagekitio/types/upload_post_transform_error_event.py @@ -0,0 +1,78 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .base_webhook_event import BaseWebhookEvent + +__all__ = [ + "UploadPostTransformErrorEvent", + "UploadPostTransformErrorEventData", + "UploadPostTransformErrorEventDataTransformation", + "UploadPostTransformErrorEventDataTransformationError", + "UploadPostTransformErrorEventRequest", + "UploadPostTransformErrorEventRequestTransformation", +] + + +class UploadPostTransformErrorEventDataTransformationError(BaseModel): + reason: str + """Reason for the post-transformation failure.""" + + +class UploadPostTransformErrorEventDataTransformation(BaseModel): + error: UploadPostTransformErrorEventDataTransformationError + + +class UploadPostTransformErrorEventData(BaseModel): + file_id: str = FieldInfo(alias="fileId") + """Unique identifier of the originally uploaded file.""" + + name: str + """Name of the file.""" + + path: str + """Path of the file.""" + + transformation: UploadPostTransformErrorEventDataTransformation + + url: str + """URL of the attempted post-transformation.""" + + +class UploadPostTransformErrorEventRequestTransformation(BaseModel): + type: Literal["transformation", "abs", "gif-to-video", "thumbnail"] + """Type of the requested post-transformation.""" + + protocol: Optional[Literal["hls", "dash"]] = None + """Only applicable if transformation type is 'abs'. Streaming protocol used.""" + + value: Optional[str] = None + """Value for the requested transformation type.""" + + +class UploadPostTransformErrorEventRequest(BaseModel): + transformation: UploadPostTransformErrorEventRequestTransformation + + x_request_id: str + """Unique identifier for the originating request.""" + + +class UploadPostTransformErrorEvent(BaseWebhookEvent): + """Triggered when a post-transformation fails. + + The original file remains available, but the requested transformation could not be generated. + """ + + created_at: datetime + """Timestamp of when the event occurred in ISO8601 format.""" + + data: UploadPostTransformErrorEventData + + request: UploadPostTransformErrorEventRequest + + type: Literal["upload.post-transform.error"] # type: ignore diff --git a/src/imagekitio/types/upload_post_transform_success_event.py b/src/imagekitio/types/upload_post_transform_success_event.py new file mode 100644 index 00000000..10e4ad7f --- /dev/null +++ b/src/imagekitio/types/upload_post_transform_success_event.py @@ -0,0 +1,62 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .base_webhook_event import BaseWebhookEvent + +__all__ = [ + "UploadPostTransformSuccessEvent", + "UploadPostTransformSuccessEventData", + "UploadPostTransformSuccessEventRequest", + "UploadPostTransformSuccessEventRequestTransformation", +] + + +class UploadPostTransformSuccessEventData(BaseModel): + file_id: str = FieldInfo(alias="fileId") + """Unique identifier of the originally uploaded file.""" + + name: str + """Name of the file.""" + + url: str + """URL of the generated post-transformation.""" + + +class UploadPostTransformSuccessEventRequestTransformation(BaseModel): + type: Literal["transformation", "abs", "gif-to-video", "thumbnail"] + """Type of the requested post-transformation.""" + + protocol: Optional[Literal["hls", "dash"]] = None + """Only applicable if transformation type is 'abs'. Streaming protocol used.""" + + value: Optional[str] = None + """Value for the requested transformation type.""" + + +class UploadPostTransformSuccessEventRequest(BaseModel): + transformation: UploadPostTransformSuccessEventRequestTransformation + + x_request_id: str + """Unique identifier for the originating request.""" + + +class UploadPostTransformSuccessEvent(BaseWebhookEvent): + """Triggered when a post-transformation completes successfully. + + The transformed version of the file is now ready and can be accessed via the provided URL. Note that each post-transformation generates a separate webhook event. + """ + + created_at: datetime + """Timestamp of when the event occurred in ISO8601 format.""" + + data: UploadPostTransformSuccessEventData + + request: UploadPostTransformSuccessEventRequest + + type: Literal["upload.post-transform.success"] # type: ignore diff --git a/src/imagekitio/types/upload_pre_transform_error_event.py b/src/imagekitio/types/upload_pre_transform_error_event.py new file mode 100644 index 00000000..de907898 --- /dev/null +++ b/src/imagekitio/types/upload_pre_transform_error_event.py @@ -0,0 +1,58 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel +from .base_webhook_event import BaseWebhookEvent + +__all__ = [ + "UploadPreTransformErrorEvent", + "UploadPreTransformErrorEventData", + "UploadPreTransformErrorEventDataTransformation", + "UploadPreTransformErrorEventDataTransformationError", + "UploadPreTransformErrorEventRequest", +] + + +class UploadPreTransformErrorEventDataTransformationError(BaseModel): + reason: str + """Reason for the pre-transformation failure.""" + + +class UploadPreTransformErrorEventDataTransformation(BaseModel): + error: UploadPreTransformErrorEventDataTransformationError + + +class UploadPreTransformErrorEventData(BaseModel): + name: str + """Name of the file.""" + + path: str + """Path of the file.""" + + transformation: UploadPreTransformErrorEventDataTransformation + + +class UploadPreTransformErrorEventRequest(BaseModel): + transformation: str + """The requested pre-transformation string.""" + + x_request_id: str + """Unique identifier for the originating request.""" + + +class UploadPreTransformErrorEvent(BaseWebhookEvent): + """Triggered when a pre-transformation fails. + + The file upload may have been accepted, but the requested transformation could not be applied. + """ + + created_at: datetime + """Timestamp of when the event occurred in ISO8601 format.""" + + data: UploadPreTransformErrorEventData + + request: UploadPreTransformErrorEventRequest + + type: Literal["upload.pre-transform.error"] # type: ignore diff --git a/src/imagekitio/types/upload_pre_transform_success_event.py b/src/imagekitio/types/upload_pre_transform_success_event.py new file mode 100644 index 00000000..8584f867 --- /dev/null +++ b/src/imagekitio/types/upload_pre_transform_success_event.py @@ -0,0 +1,294 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Union, Optional +from datetime import datetime +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .metadata import Metadata +from .base_webhook_event import BaseWebhookEvent + +__all__ = [ + "UploadPreTransformSuccessEvent", + "UploadPreTransformSuccessEventData", + "UploadPreTransformSuccessEventDataAITag", + "UploadPreTransformSuccessEventDataExtensionStatus", + "UploadPreTransformSuccessEventDataSelectedFieldsSchema", + "UploadPreTransformSuccessEventDataVersionInfo", + "UploadPreTransformSuccessEventRequest", +] + + +class UploadPreTransformSuccessEventDataAITag(BaseModel): + confidence: Optional[float] = None + """Confidence score of the tag.""" + + name: Optional[str] = None + """Name of the tag.""" + + source: Optional[str] = None + """Array of `AITags` associated with the image. + + If no `AITags` are set, it will be null. These tags can be added using the + `google-auto-tagging` or `aws-auto-tagging` extensions. + """ + + +class UploadPreTransformSuccessEventDataExtensionStatus(BaseModel): + """ + Extension names with their processing status at the time of completion of the request. It could have one of the following status values: + + `success`: The extension has been successfully applied. + `failed`: The extension has failed and will not be retried. + `pending`: The extension will finish processing in some time. On completion, the final status (success / failed) will be sent to the `webhookUrl` provided. + + If no extension was requested, then this parameter is not returned. + """ + + ai_auto_description: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="ai-auto-description", default=None + ) + + aws_auto_tagging: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="aws-auto-tagging", default=None + ) + + google_auto_tagging: Optional[Literal["success", "pending", "failed"]] = FieldInfo( + alias="google-auto-tagging", default=None + ) + + remove_bg: Optional[Literal["success", "pending", "failed"]] = FieldInfo(alias="remove-bg", default=None) + + +class UploadPreTransformSuccessEventDataSelectedFieldsSchema(BaseModel): + type: Literal["Text", "Textarea", "Number", "Date", "Boolean", "SingleSelect", "MultiSelect"] + """Type of the custom metadata field.""" + + default_value: Union[str, float, bool, List[Union[str, float, bool]], None] = FieldInfo( + alias="defaultValue", default=None + ) + """The default value for this custom metadata field. + + The value should match the `type` of custom metadata field. + """ + + is_value_required: Optional[bool] = FieldInfo(alias="isValueRequired", default=None) + """Specifies if the custom metadata field is required or not.""" + + max_length: Optional[float] = FieldInfo(alias="maxLength", default=None) + """Maximum length of string. Only set if `type` is set to `Text` or `Textarea`.""" + + max_value: Union[str, float, None] = FieldInfo(alias="maxValue", default=None) + """Maximum value of the field. + + Only set if field type is `Date` or `Number`. For `Date` type field, the value + will be in ISO8601 string format. For `Number` type field, it will be a numeric + value. + """ + + min_length: Optional[float] = FieldInfo(alias="minLength", default=None) + """Minimum length of string. Only set if `type` is set to `Text` or `Textarea`.""" + + min_value: Union[str, float, None] = FieldInfo(alias="minValue", default=None) + """Minimum value of the field. + + Only set if field type is `Date` or `Number`. For `Date` type field, the value + will be in ISO8601 string format. For `Number` type field, it will be a numeric + value. + """ + + read_only: Optional[bool] = FieldInfo(alias="readOnly", default=None) + """Indicates whether the custom metadata field is read only. + + A read only field cannot be modified after being set. This field is configurable + only via the **Path policy** feature. + """ + + select_options: Optional[List[Union[str, float, bool]]] = FieldInfo(alias="selectOptions", default=None) + """An array of allowed values when field type is `SingleSelect` or `MultiSelect`.""" + + select_options_truncated: Optional[bool] = FieldInfo(alias="selectOptionsTruncated", default=None) + """Specifies if the selectOptions array is truncated. + + It is truncated when number of options are > 100. + """ + + +class UploadPreTransformSuccessEventDataVersionInfo(BaseModel): + """An object containing the file or file version's `id` (versionId) and `name`.""" + + id: Optional[str] = None + """Unique identifier of the file version.""" + + name: Optional[str] = None + """Name of the file version.""" + + +class UploadPreTransformSuccessEventData(BaseModel): + """Object containing details of a successful upload.""" + + ai_tags: Optional[List[UploadPreTransformSuccessEventDataAITag]] = FieldInfo(alias="AITags", default=None) + """An array of tags assigned to the uploaded file by auto tagging.""" + + audio_codec: Optional[str] = FieldInfo(alias="audioCodec", default=None) + """The audio codec used in the video (only for video).""" + + bit_rate: Optional[int] = FieldInfo(alias="bitRate", default=None) + """The bit rate of the video in kbps (only for video).""" + + custom_coordinates: Optional[str] = FieldInfo(alias="customCoordinates", default=None) + """ + Value of custom coordinates associated with the image in the format + `x,y,width,height`. If `customCoordinates` are not defined, then it is `null`. + Send `customCoordinates` in `responseFields` in API request to get the value of + this field. + """ + + custom_metadata: Optional[Dict[str, object]] = FieldInfo(alias="customMetadata", default=None) + """A key-value data associated with the asset. + + Use `responseField` in API request to get `customMetadata` in the upload API + response. Before setting any custom metadata on an asset, you have to create the + field using custom metadata fields API. Send `customMetadata` in + `responseFields` in API request to get the value of this field. + """ + + description: Optional[str] = None + """Optional text to describe the contents of the file. + + Can be set by the user or the ai-auto-description extension. + """ + + duration: Optional[int] = None + """The duration of the video in seconds (only for video).""" + + embedded_metadata: Optional[Dict[str, object]] = FieldInfo(alias="embeddedMetadata", default=None) + """Consolidated embedded metadata associated with the file. + + It includes exif, iptc, and xmp data. Send `embeddedMetadata` in + `responseFields` in API request to get embeddedMetadata in the upload API + response. + """ + + extension_status: Optional[UploadPreTransformSuccessEventDataExtensionStatus] = FieldInfo( + alias="extensionStatus", default=None + ) + """ + Extension names with their processing status at the time of completion of the + request. It could have one of the following status values: + + `success`: The extension has been successfully applied. `failed`: The extension + has failed and will not be retried. `pending`: The extension will finish + processing in some time. On completion, the final status (success / failed) will + be sent to the `webhookUrl` provided. + + If no extension was requested, then this parameter is not returned. + """ + + file_id: Optional[str] = FieldInfo(alias="fileId", default=None) + """Unique fileId. + + Store this fileld in your database, as this will be used to perform update + action on this file. + """ + + file_path: Optional[str] = FieldInfo(alias="filePath", default=None) + """The relative path of the file in the media library e.g. + + `/marketing-assets/new-banner.jpg`. + """ + + file_type: Optional[str] = FieldInfo(alias="fileType", default=None) + """Type of the uploaded file. Possible values are `image`, `non-image`.""" + + height: Optional[float] = None + """Height of the image in pixels (Only for images)""" + + is_private_file: Optional[bool] = FieldInfo(alias="isPrivateFile", default=None) + """Is the file marked as private. + + It can be either `true` or `false`. Send `isPrivateFile` in `responseFields` in + API request to get the value of this field. + """ + + is_published: Optional[bool] = FieldInfo(alias="isPublished", default=None) + """Is the file published or in draft state. + + It can be either `true` or `false`. Send `isPublished` in `responseFields` in + API request to get the value of this field. + """ + + metadata: Optional[Metadata] = None + """Legacy metadata. + + Send `metadata` in `responseFields` in API request to get metadata in the upload + API response. + """ + + name: Optional[str] = None + """Name of the asset.""" + + selected_fields_schema: Optional[Dict[str, UploadPreTransformSuccessEventDataSelectedFieldsSchema]] = FieldInfo( + alias="selectedFieldsSchema", default=None + ) + """ + This field is included in the response only if the Path policy feature is + available in the plan. It contains schema definitions for the custom metadata + fields selected for the specified file path. Field selection can only be done + when the Path policy feature is enabled. + + Keys are the names of the custom metadata fields; the value object has details + about the custom metadata schema. + """ + + size: Optional[float] = None + """Size of the image file in Bytes.""" + + tags: Optional[List[str]] = None + """The array of tags associated with the asset. + + If no tags are set, it will be `null`. Send `tags` in `responseFields` in API + request to get the value of this field. + """ + + thumbnail_url: Optional[str] = FieldInfo(alias="thumbnailUrl", default=None) + """In the case of an image, a small thumbnail URL.""" + + url: Optional[str] = None + """A publicly accessible URL of the file.""" + + version_info: Optional[UploadPreTransformSuccessEventDataVersionInfo] = FieldInfo(alias="versionInfo", default=None) + """An object containing the file or file version's `id` (versionId) and `name`.""" + + video_codec: Optional[str] = FieldInfo(alias="videoCodec", default=None) + """The video codec used in the video (only for video).""" + + width: Optional[float] = None + """Width of the image in pixels (Only for Images)""" + + +class UploadPreTransformSuccessEventRequest(BaseModel): + transformation: str + """The requested pre-transformation string.""" + + x_request_id: str + """Unique identifier for the originating request.""" + + +class UploadPreTransformSuccessEvent(BaseWebhookEvent): + """Triggered when a pre-transformation completes successfully. + + The file has been processed with the requested transformation and is now available in the Media Library. + """ + + created_at: datetime + """Timestamp of when the event occurred in ISO8601 format.""" + + data: UploadPreTransformSuccessEventData + """Object containing details of a successful upload.""" + + request: UploadPreTransformSuccessEventRequest + + type: Literal["upload.pre-transform.success"] # type: ignore diff --git a/src/imagekitio/types/video_transformation_accepted_event.py b/src/imagekitio/types/video_transformation_accepted_event.py new file mode 100644 index 00000000..4ddb83ca --- /dev/null +++ b/src/imagekitio/types/video_transformation_accepted_event.py @@ -0,0 +1,103 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel +from .base_webhook_event import BaseWebhookEvent + +__all__ = [ + "VideoTransformationAcceptedEvent", + "VideoTransformationAcceptedEventData", + "VideoTransformationAcceptedEventDataAsset", + "VideoTransformationAcceptedEventDataTransformation", + "VideoTransformationAcceptedEventDataTransformationOptions", + "VideoTransformationAcceptedEventRequest", +] + + +class VideoTransformationAcceptedEventDataAsset(BaseModel): + """Information about the source video asset being transformed.""" + + url: str + """URL to download or access the source video file.""" + + +class VideoTransformationAcceptedEventDataTransformationOptions(BaseModel): + """Configuration options for video transformations.""" + + audio_codec: Optional[Literal["aac", "opus"]] = None + """Audio codec used for encoding (aac or opus).""" + + auto_rotate: Optional[bool] = None + """Whether to automatically rotate the video based on metadata.""" + + format: Optional[Literal["mp4", "webm", "jpg", "png", "webp"]] = None + """Output format for the transformed video or thumbnail.""" + + quality: Optional[int] = None + """Quality setting for the output video.""" + + stream_protocol: Optional[Literal["HLS", "DASH"]] = None + """Streaming protocol for adaptive bitrate streaming.""" + + variants: Optional[List[str]] = None + """Array of quality representations for adaptive bitrate streaming.""" + + video_codec: Optional[Literal["h264", "vp9", "av1"]] = None + """Video codec used for encoding (h264, vp9, or av1).""" + + +class VideoTransformationAcceptedEventDataTransformation(BaseModel): + """Base information about a video transformation request.""" + + type: Literal["video-transformation", "gif-to-video", "video-thumbnail"] + """Type of video transformation: + + - `video-transformation`: Standard video processing (resize, format conversion, + etc.) + - `gif-to-video`: Convert animated GIF to video format + - `video-thumbnail`: Generate thumbnail image from video + """ + + options: Optional[VideoTransformationAcceptedEventDataTransformationOptions] = None + """Configuration options for video transformations.""" + + +class VideoTransformationAcceptedEventData(BaseModel): + asset: VideoTransformationAcceptedEventDataAsset + """Information about the source video asset being transformed.""" + + transformation: VideoTransformationAcceptedEventDataTransformation + """Base information about a video transformation request.""" + + +class VideoTransformationAcceptedEventRequest(BaseModel): + """Information about the original request that triggered the video transformation.""" + + url: str + """Full URL of the transformation request that was submitted.""" + + x_request_id: str + """Unique identifier for the originating transformation request.""" + + user_agent: Optional[str] = None + """User-Agent header from the original request that triggered the transformation.""" + + +class VideoTransformationAcceptedEvent(BaseWebhookEvent): + """Triggered when a new video transformation request is accepted for processing. + + This event confirms that ImageKit has received and queued your transformation request. Use this for debugging and tracking transformation lifecycle. + """ + + created_at: datetime + """Timestamp when the event was created in ISO8601 format.""" + + data: VideoTransformationAcceptedEventData + + request: VideoTransformationAcceptedEventRequest + """Information about the original request that triggered the video transformation.""" + + type: Literal["video.transformation.accepted"] # type: ignore diff --git a/src/imagekitio/types/video_transformation_error_event.py b/src/imagekitio/types/video_transformation_error_event.py new file mode 100644 index 00000000..788142fe --- /dev/null +++ b/src/imagekitio/types/video_transformation_error_event.py @@ -0,0 +1,116 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel +from .base_webhook_event import BaseWebhookEvent + +__all__ = [ + "VideoTransformationErrorEvent", + "VideoTransformationErrorEventData", + "VideoTransformationErrorEventDataAsset", + "VideoTransformationErrorEventDataTransformation", + "VideoTransformationErrorEventDataTransformationError", + "VideoTransformationErrorEventDataTransformationOptions", + "VideoTransformationErrorEventRequest", +] + + +class VideoTransformationErrorEventDataAsset(BaseModel): + """Information about the source video asset being transformed.""" + + url: str + """URL to download or access the source video file.""" + + +class VideoTransformationErrorEventDataTransformationError(BaseModel): + """Details about the transformation error.""" + + reason: Literal["encoding_failed", "download_failed", "internal_server_error"] + """Specific reason for the transformation failure: + + - `encoding_failed`: Error during video encoding process + - `download_failed`: Could not download source video + - `internal_server_error`: Unexpected server error + """ + + +class VideoTransformationErrorEventDataTransformationOptions(BaseModel): + """Configuration options for video transformations.""" + + audio_codec: Optional[Literal["aac", "opus"]] = None + """Audio codec used for encoding (aac or opus).""" + + auto_rotate: Optional[bool] = None + """Whether to automatically rotate the video based on metadata.""" + + format: Optional[Literal["mp4", "webm", "jpg", "png", "webp"]] = None + """Output format for the transformed video or thumbnail.""" + + quality: Optional[int] = None + """Quality setting for the output video.""" + + stream_protocol: Optional[Literal["HLS", "DASH"]] = None + """Streaming protocol for adaptive bitrate streaming.""" + + variants: Optional[List[str]] = None + """Array of quality representations for adaptive bitrate streaming.""" + + video_codec: Optional[Literal["h264", "vp9", "av1"]] = None + """Video codec used for encoding (h264, vp9, or av1).""" + + +class VideoTransformationErrorEventDataTransformation(BaseModel): + type: Literal["video-transformation", "gif-to-video", "video-thumbnail"] + """Type of video transformation: + + - `video-transformation`: Standard video processing (resize, format conversion, + etc.) + - `gif-to-video`: Convert animated GIF to video format + - `video-thumbnail`: Generate thumbnail image from video + """ + + error: Optional[VideoTransformationErrorEventDataTransformationError] = None + """Details about the transformation error.""" + + options: Optional[VideoTransformationErrorEventDataTransformationOptions] = None + """Configuration options for video transformations.""" + + +class VideoTransformationErrorEventData(BaseModel): + asset: VideoTransformationErrorEventDataAsset + """Information about the source video asset being transformed.""" + + transformation: VideoTransformationErrorEventDataTransformation + + +class VideoTransformationErrorEventRequest(BaseModel): + """Information about the original request that triggered the video transformation.""" + + url: str + """Full URL of the transformation request that was submitted.""" + + x_request_id: str + """Unique identifier for the originating transformation request.""" + + user_agent: Optional[str] = None + """User-Agent header from the original request that triggered the transformation.""" + + +class VideoTransformationErrorEvent(BaseWebhookEvent): + """Triggered when an error occurs during video encoding. + + Listen to this webhook to log error reasons and debug issues. Check your origin and URL endpoint settings if the reason is related to download failure. For other errors, contact ImageKit support. + """ + + created_at: datetime + """Timestamp when the event was created in ISO8601 format.""" + + data: VideoTransformationErrorEventData + + request: VideoTransformationErrorEventRequest + """Information about the original request that triggered the video transformation.""" + + type: Literal["video.transformation.error"] # type: ignore diff --git a/src/imagekitio/types/video_transformation_ready_event.py b/src/imagekitio/types/video_transformation_ready_event.py new file mode 100644 index 00000000..a711a9e9 --- /dev/null +++ b/src/imagekitio/types/video_transformation_ready_event.py @@ -0,0 +1,147 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel +from .base_webhook_event import BaseWebhookEvent + +__all__ = [ + "VideoTransformationReadyEvent", + "VideoTransformationReadyEventData", + "VideoTransformationReadyEventDataAsset", + "VideoTransformationReadyEventDataTransformation", + "VideoTransformationReadyEventDataTransformationOptions", + "VideoTransformationReadyEventDataTransformationOutput", + "VideoTransformationReadyEventDataTransformationOutputVideoMetadata", + "VideoTransformationReadyEventRequest", + "VideoTransformationReadyEventTimings", +] + + +class VideoTransformationReadyEventDataAsset(BaseModel): + """Information about the source video asset being transformed.""" + + url: str + """URL to download or access the source video file.""" + + +class VideoTransformationReadyEventDataTransformationOptions(BaseModel): + """Configuration options for video transformations.""" + + audio_codec: Optional[Literal["aac", "opus"]] = None + """Audio codec used for encoding (aac or opus).""" + + auto_rotate: Optional[bool] = None + """Whether to automatically rotate the video based on metadata.""" + + format: Optional[Literal["mp4", "webm", "jpg", "png", "webp"]] = None + """Output format for the transformed video or thumbnail.""" + + quality: Optional[int] = None + """Quality setting for the output video.""" + + stream_protocol: Optional[Literal["HLS", "DASH"]] = None + """Streaming protocol for adaptive bitrate streaming.""" + + variants: Optional[List[str]] = None + """Array of quality representations for adaptive bitrate streaming.""" + + video_codec: Optional[Literal["h264", "vp9", "av1"]] = None + """Video codec used for encoding (h264, vp9, or av1).""" + + +class VideoTransformationReadyEventDataTransformationOutputVideoMetadata(BaseModel): + """Metadata of the output video file.""" + + bitrate: int + """Bitrate of the output video in bits per second.""" + + duration: float + """Duration of the output video in seconds.""" + + height: int + """Height of the output video in pixels.""" + + width: int + """Width of the output video in pixels.""" + + +class VideoTransformationReadyEventDataTransformationOutput(BaseModel): + """Information about the transformed output video.""" + + url: str + """URL to access the transformed video.""" + + video_metadata: Optional[VideoTransformationReadyEventDataTransformationOutputVideoMetadata] = None + """Metadata of the output video file.""" + + +class VideoTransformationReadyEventDataTransformation(BaseModel): + type: Literal["video-transformation", "gif-to-video", "video-thumbnail"] + """Type of video transformation: + + - `video-transformation`: Standard video processing (resize, format conversion, + etc.) + - `gif-to-video`: Convert animated GIF to video format + - `video-thumbnail`: Generate thumbnail image from video + """ + + options: Optional[VideoTransformationReadyEventDataTransformationOptions] = None + """Configuration options for video transformations.""" + + output: Optional[VideoTransformationReadyEventDataTransformationOutput] = None + """Information about the transformed output video.""" + + +class VideoTransformationReadyEventData(BaseModel): + asset: VideoTransformationReadyEventDataAsset + """Information about the source video asset being transformed.""" + + transformation: VideoTransformationReadyEventDataTransformation + + +class VideoTransformationReadyEventRequest(BaseModel): + """Information about the original request that triggered the video transformation.""" + + url: str + """Full URL of the transformation request that was submitted.""" + + x_request_id: str + """Unique identifier for the originating transformation request.""" + + user_agent: Optional[str] = None + """User-Agent header from the original request that triggered the transformation.""" + + +class VideoTransformationReadyEventTimings(BaseModel): + """Performance metrics for the transformation process.""" + + download_duration: Optional[int] = None + """ + Time spent downloading the source video from your origin or media library, in + milliseconds. + """ + + encoding_duration: Optional[int] = None + """Time spent encoding the video, in milliseconds.""" + + +class VideoTransformationReadyEvent(BaseWebhookEvent): + """ + Triggered when video encoding is finished and the transformed resource is ready to be served. This is the key event to listen for - update your database or CMS flags when you receive this so your application can start showing the transformed video to users. + """ + + created_at: datetime + """Timestamp when the event was created in ISO8601 format.""" + + data: VideoTransformationReadyEventData + + request: VideoTransformationReadyEventRequest + """Information about the original request that triggered the video transformation.""" + + type: Literal["video.transformation.ready"] # type: ignore + + timings: Optional[VideoTransformationReadyEventTimings] = None + """Performance metrics for the transformation process.""" diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..fd8019a9 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/__init__.py b/tests/api_resources/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/accounts/__init__.py b/tests/api_resources/accounts/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/accounts/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/accounts/test_origins.py b/tests/api_resources/accounts/test_origins.py new file mode 100644 index 00000000..95b52147 --- /dev/null +++ b/tests/api_resources/accounts/test_origins.py @@ -0,0 +1,2432 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types.accounts import OriginResponse, OriginListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestOrigins: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_overload_1(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params_overload_1(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create_overload_1(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create_overload_1(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_overload_2(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params_overload_2(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + s3_force_path_style=True, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create_overload_2(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create_overload_2(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_overload_3(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params_overload_3(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create_overload_3(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create_overload_3(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_overload_4(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params_overload_4(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + base_url_for_canonical_header="https://cdn.example.com", + forward_host_header_to_origin=False, + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create_overload_4(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.create( + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create_overload_4(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.create( + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_overload_5(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + name="US S3 Storage", + type="WEB_PROXY", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params_overload_5(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + name="US S3 Storage", + type="WEB_PROXY", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create_overload_5(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.create( + name="US S3 Storage", + type="WEB_PROXY", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create_overload_5(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.create( + name="US S3 Storage", + type="WEB_PROXY", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_overload_6(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params_overload_6(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="products", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create_overload_6(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.create( + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create_overload_6(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.create( + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_overload_7(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params_overload_7(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="uploads", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create_overload_7(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.create( + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create_overload_7(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.create( + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_overload_8(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params_overload_8(self, client: ImageKit) -> None: + origin = client.accounts.origins.create( + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create_overload_8(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.create( + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create_overload_8(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.create( + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_overload_1(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params_overload_1(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update_overload_1(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update_overload_1(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update_overload_1(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.origins.with_raw_response.update( + id="", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_overload_2(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params_overload_2(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + s3_force_path_style=True, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update_overload_2(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update_overload_2(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update_overload_2(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.origins.with_raw_response.update( + id="", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_overload_3(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params_overload_3(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update_overload_3(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update_overload_3(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update_overload_3(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.origins.with_raw_response.update( + id="", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_overload_4(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params_overload_4(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + base_url_for_canonical_header="https://cdn.example.com", + forward_host_header_to_origin=False, + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update_overload_4(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.update( + id="id", + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update_overload_4(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.update( + id="id", + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update_overload_4(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.origins.with_raw_response.update( + id="", + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_overload_5(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + name="US S3 Storage", + type="WEB_PROXY", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params_overload_5(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + name="US S3 Storage", + type="WEB_PROXY", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update_overload_5(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.update( + id="id", + name="US S3 Storage", + type="WEB_PROXY", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update_overload_5(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.update( + id="id", + name="US S3 Storage", + type="WEB_PROXY", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update_overload_5(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.origins.with_raw_response.update( + id="", + name="US S3 Storage", + type="WEB_PROXY", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_overload_6(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params_overload_6(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="products", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update_overload_6(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.update( + id="id", + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update_overload_6(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.update( + id="id", + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update_overload_6(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.origins.with_raw_response.update( + id="", + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_overload_7(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params_overload_7(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="uploads", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update_overload_7(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.update( + id="id", + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update_overload_7(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.update( + id="id", + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update_overload_7(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.origins.with_raw_response.update( + id="", + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_overload_8(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params_overload_8(self, client: ImageKit) -> None: + origin = client.accounts.origins.update( + id="id", + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update_overload_8(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.update( + id="id", + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update_overload_8(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.update( + id="id", + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update_overload_8(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.origins.with_raw_response.update( + id="", + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list(self, client: ImageKit) -> None: + origin = client.accounts.origins.list() + assert_matches_type(OriginListResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_list(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginListResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_list(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginListResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_delete(self, client: ImageKit) -> None: + origin = client.accounts.origins.delete( + "id", + ) + assert origin is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_delete(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert origin is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert origin is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_delete(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.origins.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: ImageKit) -> None: + origin = client.accounts.origins.get( + "id", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: ImageKit) -> None: + response = client.accounts.origins.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: ImageKit) -> None: + with client.accounts.origins.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_get(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.origins.with_raw_response.get( + "", + ) + + +class TestAsyncOrigins: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_overload_1(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params_overload_1(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create_overload_1(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create_overload_1(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_overload_2(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params_overload_2(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + s3_force_path_style=True, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create_overload_2(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create_overload_2(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_overload_3(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params_overload_3(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create_overload_3(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create_overload_3(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.create( + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_overload_4(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params_overload_4(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + base_url_for_canonical_header="https://cdn.example.com", + forward_host_header_to_origin=False, + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create_overload_4(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.create( + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create_overload_4(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.create( + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_overload_5(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + name="US S3 Storage", + type="WEB_PROXY", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params_overload_5(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + name="US S3 Storage", + type="WEB_PROXY", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create_overload_5(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.create( + name="US S3 Storage", + type="WEB_PROXY", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create_overload_5(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.create( + name="US S3 Storage", + type="WEB_PROXY", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_overload_6(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params_overload_6(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="products", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create_overload_6(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.create( + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create_overload_6(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.create( + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_overload_7(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params_overload_7(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="uploads", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create_overload_7(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.create( + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create_overload_7(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.create( + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_overload_8(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params_overload_8(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.create( + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create_overload_8(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.create( + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create_overload_8(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.create( + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_overload_1(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params_overload_1(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update_overload_1(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update_overload_1(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update_overload_1(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.origins.with_raw_response.update( + id="", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_overload_2(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params_overload_2(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + s3_force_path_style=True, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update_overload_2(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update_overload_2(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update_overload_2(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.origins.with_raw_response.update( + id="", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + endpoint="https://s3.eu-central-1.wasabisys.com", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="S3_COMPATIBLE", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_overload_3(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params_overload_3(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="raw-assets", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update_overload_3(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update_overload_3(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.update( + id="id", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update_overload_3(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.origins.with_raw_response.update( + id="", + access_key="AKIAIOSFODNN7EXAMPLE", + bucket="product-images", + name="US S3 Storage", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + type="CLOUDINARY_BACKUP", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_overload_4(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params_overload_4(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + base_url_for_canonical_header="https://cdn.example.com", + forward_host_header_to_origin=False, + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update_overload_4(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.update( + id="id", + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update_overload_4(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.update( + id="id", + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update_overload_4(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.origins.with_raw_response.update( + id="", + base_url="https://images.example.com/assets", + name="US S3 Storage", + type="WEB_FOLDER", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_overload_5(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + name="US S3 Storage", + type="WEB_PROXY", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params_overload_5(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + name="US S3 Storage", + type="WEB_PROXY", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update_overload_5(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.update( + id="id", + name="US S3 Storage", + type="WEB_PROXY", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update_overload_5(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.update( + id="id", + name="US S3 Storage", + type="WEB_PROXY", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update_overload_5(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.origins.with_raw_response.update( + id="", + name="US S3 Storage", + type="WEB_PROXY", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_overload_6(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params_overload_6(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="products", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update_overload_6(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.update( + id="id", + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update_overload_6(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.update( + id="id", + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update_overload_6(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.origins.with_raw_response.update( + id="", + bucket="gcs-media", + client_email="service-account@project.iam.gserviceaccount.com", + name="US S3 Storage", + private_key="-----BEGIN PRIVATE KEY-----\\nMIIEv...", + type="GCS", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_overload_7(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params_overload_7(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + prefix="uploads", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update_overload_7(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.update( + id="id", + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update_overload_7(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.update( + id="id", + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update_overload_7(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.origins.with_raw_response.update( + id="", + account_name="account123", + container="images", + name="US S3 Storage", + sas_token="?sv=2023-01-03&sr=c&sig=abc123", + type="AZURE_BLOB", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_overload_8(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params_overload_8(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.update( + id="id", + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + base_url_for_canonical_header="https://cdn.example.com", + include_canonical_header=False, + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update_overload_8(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.update( + id="id", + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update_overload_8(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.update( + id="id", + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update_overload_8(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.origins.with_raw_response.update( + id="", + base_url="https://akeneo.company.com", + client_id="akeneo-client-id", + client_secret="akeneo-client-secret", + name="US S3 Storage", + password="strongpassword123", + type="AKENEO_PIM", + username="integration-user", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.list() + assert_matches_type(OriginListResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginListResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginListResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.delete( + "id", + ) + assert origin is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert origin is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert origin is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.origins.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncImageKit) -> None: + origin = await async_client.accounts.origins.get( + "id", + ) + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.origins.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.origins.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + origin = await response.parse() + assert_matches_type(OriginResponse, origin, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_get(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.origins.with_raw_response.get( + "", + ) diff --git a/tests/api_resources/accounts/test_url_endpoints.py b/tests/api_resources/accounts/test_url_endpoints.py new file mode 100644 index 00000000..954f6309 --- /dev/null +++ b/tests/api_resources/accounts/test_url_endpoints.py @@ -0,0 +1,469 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types.accounts import ( + URLEndpointResponse, + URLEndpointListResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestURLEndpoints: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create(self, client: ImageKit) -> None: + url_endpoint = client.accounts.url_endpoints.create( + description="My custom URL endpoint", + ) + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: ImageKit) -> None: + url_endpoint = client.accounts.url_endpoints.create( + description="My custom URL endpoint", + origins=["origin-id-1"], + url_prefix="product-images", + url_rewriter={ + "type": "CLOUDINARY", + "preserve_asset_delivery_types": True, + }, + ) + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create(self, client: ImageKit) -> None: + response = client.accounts.url_endpoints.with_raw_response.create( + description="My custom URL endpoint", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + url_endpoint = response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create(self, client: ImageKit) -> None: + with client.accounts.url_endpoints.with_streaming_response.create( + description="My custom URL endpoint", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + url_endpoint = response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update(self, client: ImageKit) -> None: + url_endpoint = client.accounts.url_endpoints.update( + id="id", + description="My custom URL endpoint", + ) + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: ImageKit) -> None: + url_endpoint = client.accounts.url_endpoints.update( + id="id", + description="My custom URL endpoint", + origins=["origin-id-1"], + url_prefix="product-images", + url_rewriter={ + "type": "CLOUDINARY", + "preserve_asset_delivery_types": True, + }, + ) + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update(self, client: ImageKit) -> None: + response = client.accounts.url_endpoints.with_raw_response.update( + id="id", + description="My custom URL endpoint", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + url_endpoint = response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update(self, client: ImageKit) -> None: + with client.accounts.url_endpoints.with_streaming_response.update( + id="id", + description="My custom URL endpoint", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + url_endpoint = response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.url_endpoints.with_raw_response.update( + id="", + description="My custom URL endpoint", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list(self, client: ImageKit) -> None: + url_endpoint = client.accounts.url_endpoints.list() + assert_matches_type(URLEndpointListResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_list(self, client: ImageKit) -> None: + response = client.accounts.url_endpoints.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + url_endpoint = response.parse() + assert_matches_type(URLEndpointListResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_list(self, client: ImageKit) -> None: + with client.accounts.url_endpoints.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + url_endpoint = response.parse() + assert_matches_type(URLEndpointListResponse, url_endpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_delete(self, client: ImageKit) -> None: + url_endpoint = client.accounts.url_endpoints.delete( + "id", + ) + assert url_endpoint is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_delete(self, client: ImageKit) -> None: + response = client.accounts.url_endpoints.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + url_endpoint = response.parse() + assert url_endpoint is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: ImageKit) -> None: + with client.accounts.url_endpoints.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + url_endpoint = response.parse() + assert url_endpoint is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_delete(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.url_endpoints.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: ImageKit) -> None: + url_endpoint = client.accounts.url_endpoints.get( + "id", + ) + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: ImageKit) -> None: + response = client.accounts.url_endpoints.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + url_endpoint = response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: ImageKit) -> None: + with client.accounts.url_endpoints.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + url_endpoint = response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_get(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.accounts.url_endpoints.with_raw_response.get( + "", + ) + + +class TestAsyncURLEndpoints: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncImageKit) -> None: + url_endpoint = await async_client.accounts.url_endpoints.create( + description="My custom URL endpoint", + ) + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncImageKit) -> None: + url_endpoint = await async_client.accounts.url_endpoints.create( + description="My custom URL endpoint", + origins=["origin-id-1"], + url_prefix="product-images", + url_rewriter={ + "type": "CLOUDINARY", + "preserve_asset_delivery_types": True, + }, + ) + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.url_endpoints.with_raw_response.create( + description="My custom URL endpoint", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + url_endpoint = await response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.url_endpoints.with_streaming_response.create( + description="My custom URL endpoint", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + url_endpoint = await response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncImageKit) -> None: + url_endpoint = await async_client.accounts.url_endpoints.update( + id="id", + description="My custom URL endpoint", + ) + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncImageKit) -> None: + url_endpoint = await async_client.accounts.url_endpoints.update( + id="id", + description="My custom URL endpoint", + origins=["origin-id-1"], + url_prefix="product-images", + url_rewriter={ + "type": "CLOUDINARY", + "preserve_asset_delivery_types": True, + }, + ) + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.url_endpoints.with_raw_response.update( + id="id", + description="My custom URL endpoint", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + url_endpoint = await response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.url_endpoints.with_streaming_response.update( + id="id", + description="My custom URL endpoint", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + url_endpoint = await response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.url_endpoints.with_raw_response.update( + id="", + description="My custom URL endpoint", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncImageKit) -> None: + url_endpoint = await async_client.accounts.url_endpoints.list() + assert_matches_type(URLEndpointListResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.url_endpoints.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + url_endpoint = await response.parse() + assert_matches_type(URLEndpointListResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.url_endpoints.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + url_endpoint = await response.parse() + assert_matches_type(URLEndpointListResponse, url_endpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncImageKit) -> None: + url_endpoint = await async_client.accounts.url_endpoints.delete( + "id", + ) + assert url_endpoint is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.url_endpoints.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + url_endpoint = await response.parse() + assert url_endpoint is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.url_endpoints.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + url_endpoint = await response.parse() + assert url_endpoint is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.url_endpoints.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncImageKit) -> None: + url_endpoint = await async_client.accounts.url_endpoints.get( + "id", + ) + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.url_endpoints.with_raw_response.get( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + url_endpoint = await response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.url_endpoints.with_streaming_response.get( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + url_endpoint = await response.parse() + assert_matches_type(URLEndpointResponse, url_endpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_get(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.accounts.url_endpoints.with_raw_response.get( + "", + ) diff --git a/tests/api_resources/accounts/test_usage.py b/tests/api_resources/accounts/test_usage.py new file mode 100644 index 00000000..f4776931 --- /dev/null +++ b/tests/api_resources/accounts/test_usage.py @@ -0,0 +1,99 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio._utils import parse_date +from imagekitio.types.accounts import UsageGetResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestUsage: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: ImageKit) -> None: + usage = client.accounts.usage.get( + end_date=parse_date("2019-12-27"), + start_date=parse_date("2019-12-27"), + ) + assert_matches_type(UsageGetResponse, usage, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: ImageKit) -> None: + response = client.accounts.usage.with_raw_response.get( + end_date=parse_date("2019-12-27"), + start_date=parse_date("2019-12-27"), + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + usage = response.parse() + assert_matches_type(UsageGetResponse, usage, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: ImageKit) -> None: + with client.accounts.usage.with_streaming_response.get( + end_date=parse_date("2019-12-27"), + start_date=parse_date("2019-12-27"), + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + usage = response.parse() + assert_matches_type(UsageGetResponse, usage, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncUsage: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncImageKit) -> None: + usage = await async_client.accounts.usage.get( + end_date=parse_date("2019-12-27"), + start_date=parse_date("2019-12-27"), + ) + assert_matches_type(UsageGetResponse, usage, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncImageKit) -> None: + response = await async_client.accounts.usage.with_raw_response.get( + end_date=parse_date("2019-12-27"), + start_date=parse_date("2019-12-27"), + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + usage = await response.parse() + assert_matches_type(UsageGetResponse, usage, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncImageKit) -> None: + async with async_client.accounts.usage.with_streaming_response.get( + end_date=parse_date("2019-12-27"), + start_date=parse_date("2019-12-27"), + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + usage = await response.parse() + assert_matches_type(UsageGetResponse, usage, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/beta/__init__.py b/tests/api_resources/beta/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/beta/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/beta/v2/__init__.py b/tests/api_resources/beta/v2/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/beta/v2/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/beta/v2/test_files.py b/tests/api_resources/beta/v2/test_files.py new file mode 100644 index 00000000..d5f6bbdb --- /dev/null +++ b/tests/api_resources/beta/v2/test_files.py @@ -0,0 +1,216 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types.beta.v2 import FileUploadResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestFiles: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_upload(self, client: ImageKit) -> None: + file = client.beta.v2.files.upload( + file=b"raw file contents", + file_name="fileName", + ) + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_upload_with_all_params(self, client: ImageKit) -> None: + file = client.beta.v2.files.upload( + file=b"raw file contents", + file_name="fileName", + token="token", + checks='"request.folder" : "marketing/"\n', + custom_coordinates="customCoordinates", + custom_metadata={ + "brand": "bar", + "color": "bar", + }, + description="Running shoes", + extensions=[ + { + "name": "remove-bg", + "options": { + "add_shadow": True, + "bg_color": "bg_color", + "bg_image_url": "bg_image_url", + "semitransparency": True, + }, + }, + { + "max_tags": 5, + "min_confidence": 95, + "name": "google-auto-tagging", + }, + {"name": "ai-auto-description"}, + ], + folder="folder", + is_private_file=True, + is_published=True, + overwrite_ai_tags=True, + overwrite_custom_metadata=True, + overwrite_file=True, + overwrite_tags=True, + response_fields=["tags", "customCoordinates", "isPrivateFile"], + tags=["t-shirt", "round-neck", "men"], + transformation={ + "post": [ + { + "type": "thumbnail", + "value": "w-150,h-150", + }, + { + "protocol": "dash", + "type": "abs", + "value": "sr-240_360_480_720_1080", + }, + ], + "pre": "w-300,h-300,q-80", + }, + use_unique_file_name=True, + webhook_url="https://example.com", + ) + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_upload(self, client: ImageKit) -> None: + response = client.beta.v2.files.with_raw_response.upload( + file=b"raw file contents", + file_name="fileName", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_upload(self, client: ImageKit) -> None: + with client.beta.v2.files.with_streaming_response.upload( + file=b"raw file contents", + file_name="fileName", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(FileUploadResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncFiles: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_upload(self, async_client: AsyncImageKit) -> None: + file = await async_client.beta.v2.files.upload( + file=b"raw file contents", + file_name="fileName", + ) + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_upload_with_all_params(self, async_client: AsyncImageKit) -> None: + file = await async_client.beta.v2.files.upload( + file=b"raw file contents", + file_name="fileName", + token="token", + checks='"request.folder" : "marketing/"\n', + custom_coordinates="customCoordinates", + custom_metadata={ + "brand": "bar", + "color": "bar", + }, + description="Running shoes", + extensions=[ + { + "name": "remove-bg", + "options": { + "add_shadow": True, + "bg_color": "bg_color", + "bg_image_url": "bg_image_url", + "semitransparency": True, + }, + }, + { + "max_tags": 5, + "min_confidence": 95, + "name": "google-auto-tagging", + }, + {"name": "ai-auto-description"}, + ], + folder="folder", + is_private_file=True, + is_published=True, + overwrite_ai_tags=True, + overwrite_custom_metadata=True, + overwrite_file=True, + overwrite_tags=True, + response_fields=["tags", "customCoordinates", "isPrivateFile"], + tags=["t-shirt", "round-neck", "men"], + transformation={ + "post": [ + { + "type": "thumbnail", + "value": "w-150,h-150", + }, + { + "protocol": "dash", + "type": "abs", + "value": "sr-240_360_480_720_1080", + }, + ], + "pre": "w-300,h-300,q-80", + }, + use_unique_file_name=True, + webhook_url="https://example.com", + ) + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_upload(self, async_client: AsyncImageKit) -> None: + response = await async_client.beta.v2.files.with_raw_response.upload( + file=b"raw file contents", + file_name="fileName", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_upload(self, async_client: AsyncImageKit) -> None: + async with async_client.beta.v2.files.with_streaming_response.upload( + file=b"raw file contents", + file_name="fileName", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(FileUploadResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/cache/__init__.py b/tests/api_resources/cache/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/cache/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/cache/test_invalidation.py b/tests/api_resources/cache/test_invalidation.py new file mode 100644 index 00000000..e68c2a63 --- /dev/null +++ b/tests/api_resources/cache/test_invalidation.py @@ -0,0 +1,176 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types.cache import InvalidationGetResponse, InvalidationCreateResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestInvalidation: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create(self, client: ImageKit) -> None: + invalidation = client.cache.invalidation.create( + url="https://ik.imagekit.io/your_imagekit_id/default-image.jpg", + ) + assert_matches_type(InvalidationCreateResponse, invalidation, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create(self, client: ImageKit) -> None: + response = client.cache.invalidation.with_raw_response.create( + url="https://ik.imagekit.io/your_imagekit_id/default-image.jpg", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invalidation = response.parse() + assert_matches_type(InvalidationCreateResponse, invalidation, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create(self, client: ImageKit) -> None: + with client.cache.invalidation.with_streaming_response.create( + url="https://ik.imagekit.io/your_imagekit_id/default-image.jpg", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invalidation = response.parse() + assert_matches_type(InvalidationCreateResponse, invalidation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: ImageKit) -> None: + invalidation = client.cache.invalidation.get( + "requestId", + ) + assert_matches_type(InvalidationGetResponse, invalidation, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: ImageKit) -> None: + response = client.cache.invalidation.with_raw_response.get( + "requestId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invalidation = response.parse() + assert_matches_type(InvalidationGetResponse, invalidation, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: ImageKit) -> None: + with client.cache.invalidation.with_streaming_response.get( + "requestId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invalidation = response.parse() + assert_matches_type(InvalidationGetResponse, invalidation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_get(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `request_id` but received ''"): + client.cache.invalidation.with_raw_response.get( + "", + ) + + +class TestAsyncInvalidation: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncImageKit) -> None: + invalidation = await async_client.cache.invalidation.create( + url="https://ik.imagekit.io/your_imagekit_id/default-image.jpg", + ) + assert_matches_type(InvalidationCreateResponse, invalidation, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncImageKit) -> None: + response = await async_client.cache.invalidation.with_raw_response.create( + url="https://ik.imagekit.io/your_imagekit_id/default-image.jpg", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invalidation = await response.parse() + assert_matches_type(InvalidationCreateResponse, invalidation, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncImageKit) -> None: + async with async_client.cache.invalidation.with_streaming_response.create( + url="https://ik.imagekit.io/your_imagekit_id/default-image.jpg", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invalidation = await response.parse() + assert_matches_type(InvalidationCreateResponse, invalidation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncImageKit) -> None: + invalidation = await async_client.cache.invalidation.get( + "requestId", + ) + assert_matches_type(InvalidationGetResponse, invalidation, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncImageKit) -> None: + response = await async_client.cache.invalidation.with_raw_response.get( + "requestId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + invalidation = await response.parse() + assert_matches_type(InvalidationGetResponse, invalidation, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncImageKit) -> None: + async with async_client.cache.invalidation.with_streaming_response.get( + "requestId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + invalidation = await response.parse() + assert_matches_type(InvalidationGetResponse, invalidation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_get(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `request_id` but received ''"): + await async_client.cache.invalidation.with_raw_response.get( + "", + ) diff --git a/tests/api_resources/files/__init__.py b/tests/api_resources/files/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/files/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/files/test_bulk.py b/tests/api_resources/files/test_bulk.py new file mode 100644 index 00000000..150b5b24 --- /dev/null +++ b/tests/api_resources/files/test_bulk.py @@ -0,0 +1,319 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types.files import ( + BulkDeleteResponse, + BulkAddTagsResponse, + BulkRemoveTagsResponse, + BulkRemoveAITagsResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestBulk: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_delete(self, client: ImageKit) -> None: + bulk = client.files.bulk.delete( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) + assert_matches_type(BulkDeleteResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_delete(self, client: ImageKit) -> None: + response = client.files.bulk.with_raw_response.delete( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + bulk = response.parse() + assert_matches_type(BulkDeleteResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: ImageKit) -> None: + with client.files.bulk.with_streaming_response.delete( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + bulk = response.parse() + assert_matches_type(BulkDeleteResponse, bulk, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_add_tags(self, client: ImageKit) -> None: + bulk = client.files.bulk.add_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) + assert_matches_type(BulkAddTagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_add_tags(self, client: ImageKit) -> None: + response = client.files.bulk.with_raw_response.add_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + bulk = response.parse() + assert_matches_type(BulkAddTagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_add_tags(self, client: ImageKit) -> None: + with client.files.bulk.with_streaming_response.add_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + bulk = response.parse() + assert_matches_type(BulkAddTagsResponse, bulk, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_remove_ai_tags(self, client: ImageKit) -> None: + bulk = client.files.bulk.remove_ai_tags( + ai_tags=["t-shirt", "round-neck", "sale2019"], + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) + assert_matches_type(BulkRemoveAITagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_remove_ai_tags(self, client: ImageKit) -> None: + response = client.files.bulk.with_raw_response.remove_ai_tags( + ai_tags=["t-shirt", "round-neck", "sale2019"], + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + bulk = response.parse() + assert_matches_type(BulkRemoveAITagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_remove_ai_tags(self, client: ImageKit) -> None: + with client.files.bulk.with_streaming_response.remove_ai_tags( + ai_tags=["t-shirt", "round-neck", "sale2019"], + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + bulk = response.parse() + assert_matches_type(BulkRemoveAITagsResponse, bulk, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_remove_tags(self, client: ImageKit) -> None: + bulk = client.files.bulk.remove_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) + assert_matches_type(BulkRemoveTagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_remove_tags(self, client: ImageKit) -> None: + response = client.files.bulk.with_raw_response.remove_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + bulk = response.parse() + assert_matches_type(BulkRemoveTagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_remove_tags(self, client: ImageKit) -> None: + with client.files.bulk.with_streaming_response.remove_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + bulk = response.parse() + assert_matches_type(BulkRemoveTagsResponse, bulk, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncBulk: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncImageKit) -> None: + bulk = await async_client.files.bulk.delete( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) + assert_matches_type(BulkDeleteResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.bulk.with_raw_response.delete( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + bulk = await response.parse() + assert_matches_type(BulkDeleteResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncImageKit) -> None: + async with async_client.files.bulk.with_streaming_response.delete( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + bulk = await response.parse() + assert_matches_type(BulkDeleteResponse, bulk, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_add_tags(self, async_client: AsyncImageKit) -> None: + bulk = await async_client.files.bulk.add_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) + assert_matches_type(BulkAddTagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_add_tags(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.bulk.with_raw_response.add_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + bulk = await response.parse() + assert_matches_type(BulkAddTagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_add_tags(self, async_client: AsyncImageKit) -> None: + async with async_client.files.bulk.with_streaming_response.add_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + bulk = await response.parse() + assert_matches_type(BulkAddTagsResponse, bulk, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_remove_ai_tags(self, async_client: AsyncImageKit) -> None: + bulk = await async_client.files.bulk.remove_ai_tags( + ai_tags=["t-shirt", "round-neck", "sale2019"], + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) + assert_matches_type(BulkRemoveAITagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_remove_ai_tags(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.bulk.with_raw_response.remove_ai_tags( + ai_tags=["t-shirt", "round-neck", "sale2019"], + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + bulk = await response.parse() + assert_matches_type(BulkRemoveAITagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_remove_ai_tags(self, async_client: AsyncImageKit) -> None: + async with async_client.files.bulk.with_streaming_response.remove_ai_tags( + ai_tags=["t-shirt", "round-neck", "sale2019"], + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + bulk = await response.parse() + assert_matches_type(BulkRemoveAITagsResponse, bulk, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_remove_tags(self, async_client: AsyncImageKit) -> None: + bulk = await async_client.files.bulk.remove_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) + assert_matches_type(BulkRemoveTagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_remove_tags(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.bulk.with_raw_response.remove_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + bulk = await response.parse() + assert_matches_type(BulkRemoveTagsResponse, bulk, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_remove_tags(self, async_client: AsyncImageKit) -> None: + async with async_client.files.bulk.with_streaming_response.remove_tags( + file_ids=["598821f949c0a938d57563bd", "598821f949c0a938d57563be"], + tags=["t-shirt", "round-neck", "sale2019"], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + bulk = await response.parse() + assert_matches_type(BulkRemoveTagsResponse, bulk, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/files/test_metadata.py b/tests/api_resources/files/test_metadata.py new file mode 100644 index 00000000..eb4007c1 --- /dev/null +++ b/tests/api_resources/files/test_metadata.py @@ -0,0 +1,176 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types import Metadata + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestMetadata: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: ImageKit) -> None: + metadata = client.files.metadata.get( + "fileId", + ) + assert_matches_type(Metadata, metadata, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: ImageKit) -> None: + response = client.files.metadata.with_raw_response.get( + "fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + metadata = response.parse() + assert_matches_type(Metadata, metadata, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: ImageKit) -> None: + with client.files.metadata.with_streaming_response.get( + "fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + metadata = response.parse() + assert_matches_type(Metadata, metadata, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_get(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + client.files.metadata.with_raw_response.get( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get_from_url(self, client: ImageKit) -> None: + metadata = client.files.metadata.get_from_url( + url="https://example.com", + ) + assert_matches_type(Metadata, metadata, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get_from_url(self, client: ImageKit) -> None: + response = client.files.metadata.with_raw_response.get_from_url( + url="https://example.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + metadata = response.parse() + assert_matches_type(Metadata, metadata, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get_from_url(self, client: ImageKit) -> None: + with client.files.metadata.with_streaming_response.get_from_url( + url="https://example.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + metadata = response.parse() + assert_matches_type(Metadata, metadata, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncMetadata: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncImageKit) -> None: + metadata = await async_client.files.metadata.get( + "fileId", + ) + assert_matches_type(Metadata, metadata, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.metadata.with_raw_response.get( + "fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + metadata = await response.parse() + assert_matches_type(Metadata, metadata, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncImageKit) -> None: + async with async_client.files.metadata.with_streaming_response.get( + "fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + metadata = await response.parse() + assert_matches_type(Metadata, metadata, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_get(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + await async_client.files.metadata.with_raw_response.get( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get_from_url(self, async_client: AsyncImageKit) -> None: + metadata = await async_client.files.metadata.get_from_url( + url="https://example.com", + ) + assert_matches_type(Metadata, metadata, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get_from_url(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.metadata.with_raw_response.get_from_url( + url="https://example.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + metadata = await response.parse() + assert_matches_type(Metadata, metadata, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get_from_url(self, async_client: AsyncImageKit) -> None: + async with async_client.files.metadata.with_streaming_response.get_from_url( + url="https://example.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + metadata = await response.parse() + assert_matches_type(Metadata, metadata, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/files/test_versions.py b/tests/api_resources/files/test_versions.py new file mode 100644 index 00000000..13f3c51a --- /dev/null +++ b/tests/api_resources/files/test_versions.py @@ -0,0 +1,421 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types import File +from imagekitio.types.files import VersionListResponse, VersionDeleteResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestVersions: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list(self, client: ImageKit) -> None: + version = client.files.versions.list( + "fileId", + ) + assert_matches_type(VersionListResponse, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_list(self, client: ImageKit) -> None: + response = client.files.versions.with_raw_response.list( + "fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + version = response.parse() + assert_matches_type(VersionListResponse, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_list(self, client: ImageKit) -> None: + with client.files.versions.with_streaming_response.list( + "fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + version = response.parse() + assert_matches_type(VersionListResponse, version, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_list(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + client.files.versions.with_raw_response.list( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_delete(self, client: ImageKit) -> None: + version = client.files.versions.delete( + version_id="versionId", + file_id="fileId", + ) + assert_matches_type(VersionDeleteResponse, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_delete(self, client: ImageKit) -> None: + response = client.files.versions.with_raw_response.delete( + version_id="versionId", + file_id="fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + version = response.parse() + assert_matches_type(VersionDeleteResponse, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: ImageKit) -> None: + with client.files.versions.with_streaming_response.delete( + version_id="versionId", + file_id="fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + version = response.parse() + assert_matches_type(VersionDeleteResponse, version, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_delete(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + client.files.versions.with_raw_response.delete( + version_id="versionId", + file_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `version_id` but received ''"): + client.files.versions.with_raw_response.delete( + version_id="", + file_id="fileId", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: ImageKit) -> None: + version = client.files.versions.get( + version_id="versionId", + file_id="fileId", + ) + assert_matches_type(File, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: ImageKit) -> None: + response = client.files.versions.with_raw_response.get( + version_id="versionId", + file_id="fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + version = response.parse() + assert_matches_type(File, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: ImageKit) -> None: + with client.files.versions.with_streaming_response.get( + version_id="versionId", + file_id="fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + version = response.parse() + assert_matches_type(File, version, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_get(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + client.files.versions.with_raw_response.get( + version_id="versionId", + file_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `version_id` but received ''"): + client.files.versions.with_raw_response.get( + version_id="", + file_id="fileId", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_restore(self, client: ImageKit) -> None: + version = client.files.versions.restore( + version_id="versionId", + file_id="fileId", + ) + assert_matches_type(File, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_restore(self, client: ImageKit) -> None: + response = client.files.versions.with_raw_response.restore( + version_id="versionId", + file_id="fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + version = response.parse() + assert_matches_type(File, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_restore(self, client: ImageKit) -> None: + with client.files.versions.with_streaming_response.restore( + version_id="versionId", + file_id="fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + version = response.parse() + assert_matches_type(File, version, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_restore(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + client.files.versions.with_raw_response.restore( + version_id="versionId", + file_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `version_id` but received ''"): + client.files.versions.with_raw_response.restore( + version_id="", + file_id="fileId", + ) + + +class TestAsyncVersions: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncImageKit) -> None: + version = await async_client.files.versions.list( + "fileId", + ) + assert_matches_type(VersionListResponse, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.versions.with_raw_response.list( + "fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + version = await response.parse() + assert_matches_type(VersionListResponse, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncImageKit) -> None: + async with async_client.files.versions.with_streaming_response.list( + "fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + version = await response.parse() + assert_matches_type(VersionListResponse, version, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_list(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + await async_client.files.versions.with_raw_response.list( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncImageKit) -> None: + version = await async_client.files.versions.delete( + version_id="versionId", + file_id="fileId", + ) + assert_matches_type(VersionDeleteResponse, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.versions.with_raw_response.delete( + version_id="versionId", + file_id="fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + version = await response.parse() + assert_matches_type(VersionDeleteResponse, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncImageKit) -> None: + async with async_client.files.versions.with_streaming_response.delete( + version_id="versionId", + file_id="fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + version = await response.parse() + assert_matches_type(VersionDeleteResponse, version, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + await async_client.files.versions.with_raw_response.delete( + version_id="versionId", + file_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `version_id` but received ''"): + await async_client.files.versions.with_raw_response.delete( + version_id="", + file_id="fileId", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncImageKit) -> None: + version = await async_client.files.versions.get( + version_id="versionId", + file_id="fileId", + ) + assert_matches_type(File, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.versions.with_raw_response.get( + version_id="versionId", + file_id="fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + version = await response.parse() + assert_matches_type(File, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncImageKit) -> None: + async with async_client.files.versions.with_streaming_response.get( + version_id="versionId", + file_id="fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + version = await response.parse() + assert_matches_type(File, version, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_get(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + await async_client.files.versions.with_raw_response.get( + version_id="versionId", + file_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `version_id` but received ''"): + await async_client.files.versions.with_raw_response.get( + version_id="", + file_id="fileId", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_restore(self, async_client: AsyncImageKit) -> None: + version = await async_client.files.versions.restore( + version_id="versionId", + file_id="fileId", + ) + assert_matches_type(File, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_restore(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.versions.with_raw_response.restore( + version_id="versionId", + file_id="fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + version = await response.parse() + assert_matches_type(File, version, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_restore(self, async_client: AsyncImageKit) -> None: + async with async_client.files.versions.with_streaming_response.restore( + version_id="versionId", + file_id="fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + version = await response.parse() + assert_matches_type(File, version, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_restore(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + await async_client.files.versions.with_raw_response.restore( + version_id="versionId", + file_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `version_id` but received ''"): + await async_client.files.versions.with_raw_response.restore( + version_id="", + file_id="fileId", + ) diff --git a/tests/api_resources/folders/__init__.py b/tests/api_resources/folders/__init__.py new file mode 100644 index 00000000..fd8019a9 --- /dev/null +++ b/tests/api_resources/folders/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/folders/test_job.py b/tests/api_resources/folders/test_job.py new file mode 100644 index 00000000..2bbc1cf5 --- /dev/null +++ b/tests/api_resources/folders/test_job.py @@ -0,0 +1,108 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types.folders import JobGetResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestJob: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: ImageKit) -> None: + job = client.folders.job.get( + "jobId", + ) + assert_matches_type(JobGetResponse, job, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: ImageKit) -> None: + response = client.folders.job.with_raw_response.get( + "jobId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + job = response.parse() + assert_matches_type(JobGetResponse, job, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: ImageKit) -> None: + with client.folders.job.with_streaming_response.get( + "jobId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + job = response.parse() + assert_matches_type(JobGetResponse, job, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_get(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `job_id` but received ''"): + client.folders.job.with_raw_response.get( + "", + ) + + +class TestAsyncJob: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncImageKit) -> None: + job = await async_client.folders.job.get( + "jobId", + ) + assert_matches_type(JobGetResponse, job, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncImageKit) -> None: + response = await async_client.folders.job.with_raw_response.get( + "jobId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + job = await response.parse() + assert_matches_type(JobGetResponse, job, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncImageKit) -> None: + async with async_client.folders.job.with_streaming_response.get( + "jobId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + job = await response.parse() + assert_matches_type(JobGetResponse, job, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_get(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `job_id` but received ''"): + await async_client.folders.job.with_raw_response.get( + "", + ) diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py new file mode 100644 index 00000000..6958ecd3 --- /dev/null +++ b/tests/api_resources/test_assets.py @@ -0,0 +1,108 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types import AssetListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAssets: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list(self, client: ImageKit) -> None: + asset = client.assets.list() + assert_matches_type(AssetListResponse, asset, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: ImageKit) -> None: + asset = client.assets.list( + file_type="all", + limit=1, + path="path", + search_query="searchQuery", + skip=0, + sort="ASC_NAME", + type="file", + ) + assert_matches_type(AssetListResponse, asset, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_list(self, client: ImageKit) -> None: + response = client.assets.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = response.parse() + assert_matches_type(AssetListResponse, asset, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_list(self, client: ImageKit) -> None: + with client.assets.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + asset = response.parse() + assert_matches_type(AssetListResponse, asset, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncAssets: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncImageKit) -> None: + asset = await async_client.assets.list() + assert_matches_type(AssetListResponse, asset, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncImageKit) -> None: + asset = await async_client.assets.list( + file_type="all", + limit=1, + path="path", + search_query="searchQuery", + skip=0, + sort="ASC_NAME", + type="file", + ) + assert_matches_type(AssetListResponse, asset, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncImageKit) -> None: + response = await async_client.assets.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = await response.parse() + assert_matches_type(AssetListResponse, asset, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncImageKit) -> None: + async with async_client.assets.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + asset = await response.parse() + assert_matches_type(AssetListResponse, asset, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_custom_metadata_fields.py b/tests/api_resources/test_custom_metadata_fields.py new file mode 100644 index 00000000..4c9fed68 --- /dev/null +++ b/tests/api_resources/test_custom_metadata_fields.py @@ -0,0 +1,424 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types import ( + CustomMetadataField, + CustomMetadataFieldListResponse, + CustomMetadataFieldDeleteResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestCustomMetadataFields: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create(self, client: ImageKit) -> None: + custom_metadata_field = client.custom_metadata_fields.create( + label="price", + name="price", + schema={"type": "Number"}, + ) + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: ImageKit) -> None: + custom_metadata_field = client.custom_metadata_fields.create( + label="price", + name="price", + schema={ + "type": "Number", + "default_value": "string", + "is_value_required": True, + "max_length": 0, + "max_value": 3000, + "min_length": 0, + "min_value": 1000, + "select_options": ["small", "medium", "large", 30, 40, True], + }, + ) + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create(self, client: ImageKit) -> None: + response = client.custom_metadata_fields.with_raw_response.create( + label="price", + name="price", + schema={"type": "Number"}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + custom_metadata_field = response.parse() + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create(self, client: ImageKit) -> None: + with client.custom_metadata_fields.with_streaming_response.create( + label="price", + name="price", + schema={"type": "Number"}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + custom_metadata_field = response.parse() + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update(self, client: ImageKit) -> None: + custom_metadata_field = client.custom_metadata_fields.update( + id="id", + ) + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: ImageKit) -> None: + custom_metadata_field = client.custom_metadata_fields.update( + id="id", + label="price", + schema={ + "default_value": "string", + "is_value_required": True, + "max_length": 0, + "max_value": 3000, + "min_length": 0, + "min_value": 1000, + "select_options": ["small", "medium", "large", 30, 40, True], + }, + ) + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update(self, client: ImageKit) -> None: + response = client.custom_metadata_fields.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + custom_metadata_field = response.parse() + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update(self, client: ImageKit) -> None: + with client.custom_metadata_fields.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + custom_metadata_field = response.parse() + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.custom_metadata_fields.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list(self, client: ImageKit) -> None: + custom_metadata_field = client.custom_metadata_fields.list() + assert_matches_type(CustomMetadataFieldListResponse, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: ImageKit) -> None: + custom_metadata_field = client.custom_metadata_fields.list( + folder_path="folderPath", + include_deleted=True, + ) + assert_matches_type(CustomMetadataFieldListResponse, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_list(self, client: ImageKit) -> None: + response = client.custom_metadata_fields.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + custom_metadata_field = response.parse() + assert_matches_type(CustomMetadataFieldListResponse, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_list(self, client: ImageKit) -> None: + with client.custom_metadata_fields.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + custom_metadata_field = response.parse() + assert_matches_type(CustomMetadataFieldListResponse, custom_metadata_field, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_delete(self, client: ImageKit) -> None: + custom_metadata_field = client.custom_metadata_fields.delete( + "id", + ) + assert_matches_type(CustomMetadataFieldDeleteResponse, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_delete(self, client: ImageKit) -> None: + response = client.custom_metadata_fields.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + custom_metadata_field = response.parse() + assert_matches_type(CustomMetadataFieldDeleteResponse, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: ImageKit) -> None: + with client.custom_metadata_fields.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + custom_metadata_field = response.parse() + assert_matches_type(CustomMetadataFieldDeleteResponse, custom_metadata_field, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_delete(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.custom_metadata_fields.with_raw_response.delete( + "", + ) + + +class TestAsyncCustomMetadataFields: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncImageKit) -> None: + custom_metadata_field = await async_client.custom_metadata_fields.create( + label="price", + name="price", + schema={"type": "Number"}, + ) + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncImageKit) -> None: + custom_metadata_field = await async_client.custom_metadata_fields.create( + label="price", + name="price", + schema={ + "type": "Number", + "default_value": "string", + "is_value_required": True, + "max_length": 0, + "max_value": 3000, + "min_length": 0, + "min_value": 1000, + "select_options": ["small", "medium", "large", 30, 40, True], + }, + ) + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncImageKit) -> None: + response = await async_client.custom_metadata_fields.with_raw_response.create( + label="price", + name="price", + schema={"type": "Number"}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + custom_metadata_field = await response.parse() + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncImageKit) -> None: + async with async_client.custom_metadata_fields.with_streaming_response.create( + label="price", + name="price", + schema={"type": "Number"}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + custom_metadata_field = await response.parse() + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncImageKit) -> None: + custom_metadata_field = await async_client.custom_metadata_fields.update( + id="id", + ) + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncImageKit) -> None: + custom_metadata_field = await async_client.custom_metadata_fields.update( + id="id", + label="price", + schema={ + "default_value": "string", + "is_value_required": True, + "max_length": 0, + "max_value": 3000, + "min_length": 0, + "min_value": 1000, + "select_options": ["small", "medium", "large", 30, 40, True], + }, + ) + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncImageKit) -> None: + response = await async_client.custom_metadata_fields.with_raw_response.update( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + custom_metadata_field = await response.parse() + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncImageKit) -> None: + async with async_client.custom_metadata_fields.with_streaming_response.update( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + custom_metadata_field = await response.parse() + assert_matches_type(CustomMetadataField, custom_metadata_field, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.custom_metadata_fields.with_raw_response.update( + id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncImageKit) -> None: + custom_metadata_field = await async_client.custom_metadata_fields.list() + assert_matches_type(CustomMetadataFieldListResponse, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncImageKit) -> None: + custom_metadata_field = await async_client.custom_metadata_fields.list( + folder_path="folderPath", + include_deleted=True, + ) + assert_matches_type(CustomMetadataFieldListResponse, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncImageKit) -> None: + response = await async_client.custom_metadata_fields.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + custom_metadata_field = await response.parse() + assert_matches_type(CustomMetadataFieldListResponse, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncImageKit) -> None: + async with async_client.custom_metadata_fields.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + custom_metadata_field = await response.parse() + assert_matches_type(CustomMetadataFieldListResponse, custom_metadata_field, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncImageKit) -> None: + custom_metadata_field = await async_client.custom_metadata_fields.delete( + "id", + ) + assert_matches_type(CustomMetadataFieldDeleteResponse, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncImageKit) -> None: + response = await async_client.custom_metadata_fields.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + custom_metadata_field = await response.parse() + assert_matches_type(CustomMetadataFieldDeleteResponse, custom_metadata_field, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncImageKit) -> None: + async with async_client.custom_metadata_fields.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + custom_metadata_field = await response.parse() + assert_matches_type(CustomMetadataFieldDeleteResponse, custom_metadata_field, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.custom_metadata_fields.with_raw_response.delete( + "", + ) diff --git a/tests/api_resources/test_dummy.py b/tests/api_resources/test_dummy.py new file mode 100644 index 00000000..bf19fc3a --- /dev/null +++ b/tests/api_resources/test_dummy.py @@ -0,0 +1,1444 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestDummy: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create(self, client: ImageKit) -> None: + dummy = client.dummy.create() + assert dummy is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: ImageKit) -> None: + dummy = client.dummy.create( + base_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + }, + extensions=[ + { + "name": "remove-bg", + "options": { + "add_shadow": True, + "bg_color": "bg_color", + "bg_image_url": "bg_image_url", + "semitransparency": True, + }, + }, + { + "max_tags": 5, + "min_confidence": 95, + "name": "google-auto-tagging", + }, + {"name": "ai-auto-description"}, + ], + get_image_attributes_options={ + "src": "/my-image.jpg", + "url_endpoint": "https://ik.imagekit.io/demo", + "expires_in": 0, + "query_parameters": {"foo": "string"}, + "signed": True, + "transformation": [ + { + "ai_change_background": "aiChangeBackground", + "ai_drop_shadow": True, + "ai_edit": "aiEdit", + "ai_remove_background": True, + "ai_remove_background_external": True, + "ai_retouch": True, + "ai_upscale": True, + "ai_variation": True, + "aspect_ratio": "4:3", + "audio_codec": "aac", + "background": "red", + "blur": 10, + "border": "5_FF0000", + "color_profile": True, + "contrast_stretch": True, + "crop": "force", + "crop_mode": "pad_resize", + "default_image": "defaultImage", + "dpr": 2, + "duration": 0, + "end_offset": 0, + "flip": "h", + "focus": "center", + "format": "auto", + "gradient": True, + "grayscale": True, + "height": 200, + "lossless": True, + "metadata": True, + "named": "named", + "opacity": 0, + "original": True, + "overlay": { + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + "page": 0, + "progressive": True, + "quality": 80, + "radius": 20, + "raw": "raw", + "rotation": 90, + "shadow": True, + "sharpen": True, + "start_offset": 0, + "streaming_resolutions": ["240"], + "trim": True, + "unsharp_mask": True, + "video_codec": "h264", + "width": 300, + "x": 0, + "x_center": 0, + "y": 0, + "y_center": 0, + "zoom": 0, + } + ], + "transformation_position": "path", + "device_breakpoints": [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + "image_breakpoints": [16, 32, 48, 64, 96, 128, 256, 384], + "sizes": "(min-width: 768px) 50vw, 100vw", + "width": 400, + }, + image_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "input": "input", + "type": "image", + "encoding": "auto", + "transformation": [ + { + "ai_change_background": "aiChangeBackground", + "ai_drop_shadow": True, + "ai_edit": "aiEdit", + "ai_remove_background": True, + "ai_remove_background_external": True, + "ai_retouch": True, + "ai_upscale": True, + "ai_variation": True, + "aspect_ratio": "4:3", + "audio_codec": "aac", + "background": "red", + "blur": 10, + "border": "5_FF0000", + "color_profile": True, + "contrast_stretch": True, + "crop": "force", + "crop_mode": "pad_resize", + "default_image": "defaultImage", + "dpr": 2, + "duration": 0, + "end_offset": 0, + "flip": "h", + "focus": "center", + "format": "auto", + "gradient": True, + "grayscale": True, + "height": 200, + "lossless": True, + "metadata": True, + "named": "named", + "opacity": 0, + "original": True, + "overlay": { + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + "page": 0, + "progressive": True, + "quality": 80, + "radius": 20, + "raw": "raw", + "rotation": 90, + "shadow": True, + "sharpen": True, + "start_offset": 0, + "streaming_resolutions": ["240"], + "trim": True, + "unsharp_mask": True, + "video_codec": "h264", + "width": 300, + "x": 0, + "x_center": 0, + "y": 0, + "y_center": 0, + "zoom": 0, + } + ], + }, + overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + overlay_position={ + "focus": "center", + "x": 0, + "y": 0, + }, + overlay_timing={ + "duration": 0, + "end": 0, + "start": 0, + }, + responsive_image_attributes={ + "src": "https://ik.imagekit.io/demo/image.jpg?tr=w-3840", + "sizes": "100vw", + "src_set": "https://ik.imagekit.io/demo/image.jpg?tr=w-640 640w, https://ik.imagekit.io/demo/image.jpg?tr=w-1080 1080w, https://ik.imagekit.io/demo/image.jpg?tr=w-1920 1920w", + "width": 400, + }, + solid_color_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "color": "color", + "type": "solidColor", + "transformation": [ + { + "alpha": 1, + "background": "background", + "gradient": True, + "height": 0, + "radius": 0, + "width": 0, + } + ], + }, + solid_color_overlay_transformation={ + "alpha": 1, + "background": "background", + "gradient": True, + "height": 0, + "radius": 0, + "width": 0, + }, + src_options={ + "src": "/my-image.jpg", + "url_endpoint": "https://ik.imagekit.io/demo", + "expires_in": 0, + "query_parameters": {"foo": "string"}, + "signed": True, + "transformation": [ + { + "ai_change_background": "aiChangeBackground", + "ai_drop_shadow": True, + "ai_edit": "aiEdit", + "ai_remove_background": True, + "ai_remove_background_external": True, + "ai_retouch": True, + "ai_upscale": True, + "ai_variation": True, + "aspect_ratio": "4:3", + "audio_codec": "aac", + "background": "red", + "blur": 10, + "border": "5_FF0000", + "color_profile": True, + "contrast_stretch": True, + "crop": "force", + "crop_mode": "pad_resize", + "default_image": "defaultImage", + "dpr": 2, + "duration": 0, + "end_offset": 0, + "flip": "h", + "focus": "center", + "format": "auto", + "gradient": True, + "grayscale": True, + "height": 200, + "lossless": True, + "metadata": True, + "named": "named", + "opacity": 0, + "original": True, + "overlay": { + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + "page": 0, + "progressive": True, + "quality": 80, + "radius": 20, + "raw": "raw", + "rotation": 90, + "shadow": True, + "sharpen": True, + "start_offset": 0, + "streaming_resolutions": ["240"], + "trim": True, + "unsharp_mask": True, + "video_codec": "h264", + "width": 300, + "x": 0, + "x_center": 0, + "y": 0, + "y_center": 0, + "zoom": 0, + } + ], + "transformation_position": "path", + }, + streaming_resolution="240", + subtitle_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "input": "input", + "type": "subtitle", + "encoding": "auto", + "transformation": [ + { + "background": "background", + "color": "color", + "font_family": "fontFamily", + "font_outline": "fontOutline", + "font_shadow": "fontShadow", + "font_size": 0, + "typography": "b", + } + ], + }, + subtitle_overlay_transformation={ + "background": "background", + "color": "color", + "font_family": "fontFamily", + "font_outline": "fontOutline", + "font_shadow": "fontShadow", + "font_size": 0, + "typography": "b", + }, + text_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + text_overlay_transformation={ + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + }, + transformation={ + "ai_change_background": "aiChangeBackground", + "ai_drop_shadow": True, + "ai_edit": "aiEdit", + "ai_remove_background": True, + "ai_remove_background_external": True, + "ai_retouch": True, + "ai_upscale": True, + "ai_variation": True, + "aspect_ratio": "4:3", + "audio_codec": "aac", + "background": "red", + "blur": 10, + "border": "5_FF0000", + "color_profile": True, + "contrast_stretch": True, + "crop": "force", + "crop_mode": "pad_resize", + "default_image": "defaultImage", + "dpr": 2, + "duration": 0, + "end_offset": 0, + "flip": "h", + "focus": "center", + "format": "auto", + "gradient": True, + "grayscale": True, + "height": 200, + "lossless": True, + "metadata": True, + "named": "named", + "opacity": 0, + "original": True, + "overlay": { + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + "page": 0, + "progressive": True, + "quality": 80, + "radius": 20, + "raw": "raw", + "rotation": 90, + "shadow": True, + "sharpen": True, + "start_offset": 0, + "streaming_resolutions": ["240"], + "trim": True, + "unsharp_mask": True, + "video_codec": "h264", + "width": 300, + "x": 0, + "x_center": 0, + "y": 0, + "y_center": 0, + "zoom": 0, + }, + transformation_position="path", + video_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "input": "input", + "type": "video", + "encoding": "auto", + "transformation": [ + { + "ai_change_background": "aiChangeBackground", + "ai_drop_shadow": True, + "ai_edit": "aiEdit", + "ai_remove_background": True, + "ai_remove_background_external": True, + "ai_retouch": True, + "ai_upscale": True, + "ai_variation": True, + "aspect_ratio": "4:3", + "audio_codec": "aac", + "background": "red", + "blur": 10, + "border": "5_FF0000", + "color_profile": True, + "contrast_stretch": True, + "crop": "force", + "crop_mode": "pad_resize", + "default_image": "defaultImage", + "dpr": 2, + "duration": 0, + "end_offset": 0, + "flip": "h", + "focus": "center", + "format": "auto", + "gradient": True, + "grayscale": True, + "height": 200, + "lossless": True, + "metadata": True, + "named": "named", + "opacity": 0, + "original": True, + "overlay": { + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + "page": 0, + "progressive": True, + "quality": 80, + "radius": 20, + "raw": "raw", + "rotation": 90, + "shadow": True, + "sharpen": True, + "start_offset": 0, + "streaming_resolutions": ["240"], + "trim": True, + "unsharp_mask": True, + "video_codec": "h264", + "width": 300, + "x": 0, + "x_center": 0, + "y": 0, + "y_center": 0, + "zoom": 0, + } + ], + }, + ) + assert dummy is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create(self, client: ImageKit) -> None: + response = client.dummy.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + dummy = response.parse() + assert dummy is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create(self, client: ImageKit) -> None: + with client.dummy.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + dummy = response.parse() + assert dummy is None + + assert cast(Any, response.is_closed) is True + + +class TestAsyncDummy: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncImageKit) -> None: + dummy = await async_client.dummy.create() + assert dummy is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncImageKit) -> None: + dummy = await async_client.dummy.create( + base_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + }, + extensions=[ + { + "name": "remove-bg", + "options": { + "add_shadow": True, + "bg_color": "bg_color", + "bg_image_url": "bg_image_url", + "semitransparency": True, + }, + }, + { + "max_tags": 5, + "min_confidence": 95, + "name": "google-auto-tagging", + }, + {"name": "ai-auto-description"}, + ], + get_image_attributes_options={ + "src": "/my-image.jpg", + "url_endpoint": "https://ik.imagekit.io/demo", + "expires_in": 0, + "query_parameters": {"foo": "string"}, + "signed": True, + "transformation": [ + { + "ai_change_background": "aiChangeBackground", + "ai_drop_shadow": True, + "ai_edit": "aiEdit", + "ai_remove_background": True, + "ai_remove_background_external": True, + "ai_retouch": True, + "ai_upscale": True, + "ai_variation": True, + "aspect_ratio": "4:3", + "audio_codec": "aac", + "background": "red", + "blur": 10, + "border": "5_FF0000", + "color_profile": True, + "contrast_stretch": True, + "crop": "force", + "crop_mode": "pad_resize", + "default_image": "defaultImage", + "dpr": 2, + "duration": 0, + "end_offset": 0, + "flip": "h", + "focus": "center", + "format": "auto", + "gradient": True, + "grayscale": True, + "height": 200, + "lossless": True, + "metadata": True, + "named": "named", + "opacity": 0, + "original": True, + "overlay": { + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + "page": 0, + "progressive": True, + "quality": 80, + "radius": 20, + "raw": "raw", + "rotation": 90, + "shadow": True, + "sharpen": True, + "start_offset": 0, + "streaming_resolutions": ["240"], + "trim": True, + "unsharp_mask": True, + "video_codec": "h264", + "width": 300, + "x": 0, + "x_center": 0, + "y": 0, + "y_center": 0, + "zoom": 0, + } + ], + "transformation_position": "path", + "device_breakpoints": [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + "image_breakpoints": [16, 32, 48, 64, 96, 128, 256, 384], + "sizes": "(min-width: 768px) 50vw, 100vw", + "width": 400, + }, + image_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "input": "input", + "type": "image", + "encoding": "auto", + "transformation": [ + { + "ai_change_background": "aiChangeBackground", + "ai_drop_shadow": True, + "ai_edit": "aiEdit", + "ai_remove_background": True, + "ai_remove_background_external": True, + "ai_retouch": True, + "ai_upscale": True, + "ai_variation": True, + "aspect_ratio": "4:3", + "audio_codec": "aac", + "background": "red", + "blur": 10, + "border": "5_FF0000", + "color_profile": True, + "contrast_stretch": True, + "crop": "force", + "crop_mode": "pad_resize", + "default_image": "defaultImage", + "dpr": 2, + "duration": 0, + "end_offset": 0, + "flip": "h", + "focus": "center", + "format": "auto", + "gradient": True, + "grayscale": True, + "height": 200, + "lossless": True, + "metadata": True, + "named": "named", + "opacity": 0, + "original": True, + "overlay": { + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + "page": 0, + "progressive": True, + "quality": 80, + "radius": 20, + "raw": "raw", + "rotation": 90, + "shadow": True, + "sharpen": True, + "start_offset": 0, + "streaming_resolutions": ["240"], + "trim": True, + "unsharp_mask": True, + "video_codec": "h264", + "width": 300, + "x": 0, + "x_center": 0, + "y": 0, + "y_center": 0, + "zoom": 0, + } + ], + }, + overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + overlay_position={ + "focus": "center", + "x": 0, + "y": 0, + }, + overlay_timing={ + "duration": 0, + "end": 0, + "start": 0, + }, + responsive_image_attributes={ + "src": "https://ik.imagekit.io/demo/image.jpg?tr=w-3840", + "sizes": "100vw", + "src_set": "https://ik.imagekit.io/demo/image.jpg?tr=w-640 640w, https://ik.imagekit.io/demo/image.jpg?tr=w-1080 1080w, https://ik.imagekit.io/demo/image.jpg?tr=w-1920 1920w", + "width": 400, + }, + solid_color_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "color": "color", + "type": "solidColor", + "transformation": [ + { + "alpha": 1, + "background": "background", + "gradient": True, + "height": 0, + "radius": 0, + "width": 0, + } + ], + }, + solid_color_overlay_transformation={ + "alpha": 1, + "background": "background", + "gradient": True, + "height": 0, + "radius": 0, + "width": 0, + }, + src_options={ + "src": "/my-image.jpg", + "url_endpoint": "https://ik.imagekit.io/demo", + "expires_in": 0, + "query_parameters": {"foo": "string"}, + "signed": True, + "transformation": [ + { + "ai_change_background": "aiChangeBackground", + "ai_drop_shadow": True, + "ai_edit": "aiEdit", + "ai_remove_background": True, + "ai_remove_background_external": True, + "ai_retouch": True, + "ai_upscale": True, + "ai_variation": True, + "aspect_ratio": "4:3", + "audio_codec": "aac", + "background": "red", + "blur": 10, + "border": "5_FF0000", + "color_profile": True, + "contrast_stretch": True, + "crop": "force", + "crop_mode": "pad_resize", + "default_image": "defaultImage", + "dpr": 2, + "duration": 0, + "end_offset": 0, + "flip": "h", + "focus": "center", + "format": "auto", + "gradient": True, + "grayscale": True, + "height": 200, + "lossless": True, + "metadata": True, + "named": "named", + "opacity": 0, + "original": True, + "overlay": { + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + "page": 0, + "progressive": True, + "quality": 80, + "radius": 20, + "raw": "raw", + "rotation": 90, + "shadow": True, + "sharpen": True, + "start_offset": 0, + "streaming_resolutions": ["240"], + "trim": True, + "unsharp_mask": True, + "video_codec": "h264", + "width": 300, + "x": 0, + "x_center": 0, + "y": 0, + "y_center": 0, + "zoom": 0, + } + ], + "transformation_position": "path", + }, + streaming_resolution="240", + subtitle_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "input": "input", + "type": "subtitle", + "encoding": "auto", + "transformation": [ + { + "background": "background", + "color": "color", + "font_family": "fontFamily", + "font_outline": "fontOutline", + "font_shadow": "fontShadow", + "font_size": 0, + "typography": "b", + } + ], + }, + subtitle_overlay_transformation={ + "background": "background", + "color": "color", + "font_family": "fontFamily", + "font_outline": "fontOutline", + "font_shadow": "fontShadow", + "font_size": 0, + "typography": "b", + }, + text_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + text_overlay_transformation={ + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + }, + transformation={ + "ai_change_background": "aiChangeBackground", + "ai_drop_shadow": True, + "ai_edit": "aiEdit", + "ai_remove_background": True, + "ai_remove_background_external": True, + "ai_retouch": True, + "ai_upscale": True, + "ai_variation": True, + "aspect_ratio": "4:3", + "audio_codec": "aac", + "background": "red", + "blur": 10, + "border": "5_FF0000", + "color_profile": True, + "contrast_stretch": True, + "crop": "force", + "crop_mode": "pad_resize", + "default_image": "defaultImage", + "dpr": 2, + "duration": 0, + "end_offset": 0, + "flip": "h", + "focus": "center", + "format": "auto", + "gradient": True, + "grayscale": True, + "height": 200, + "lossless": True, + "metadata": True, + "named": "named", + "opacity": 0, + "original": True, + "overlay": { + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + "page": 0, + "progressive": True, + "quality": 80, + "radius": 20, + "raw": "raw", + "rotation": 90, + "shadow": True, + "sharpen": True, + "start_offset": 0, + "streaming_resolutions": ["240"], + "trim": True, + "unsharp_mask": True, + "video_codec": "h264", + "width": 300, + "x": 0, + "x_center": 0, + "y": 0, + "y_center": 0, + "zoom": 0, + }, + transformation_position="path", + video_overlay={ + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "input": "input", + "type": "video", + "encoding": "auto", + "transformation": [ + { + "ai_change_background": "aiChangeBackground", + "ai_drop_shadow": True, + "ai_edit": "aiEdit", + "ai_remove_background": True, + "ai_remove_background_external": True, + "ai_retouch": True, + "ai_upscale": True, + "ai_variation": True, + "aspect_ratio": "4:3", + "audio_codec": "aac", + "background": "red", + "blur": 10, + "border": "5_FF0000", + "color_profile": True, + "contrast_stretch": True, + "crop": "force", + "crop_mode": "pad_resize", + "default_image": "defaultImage", + "dpr": 2, + "duration": 0, + "end_offset": 0, + "flip": "h", + "focus": "center", + "format": "auto", + "gradient": True, + "grayscale": True, + "height": 200, + "lossless": True, + "metadata": True, + "named": "named", + "opacity": 0, + "original": True, + "overlay": { + "position": { + "focus": "center", + "x": 0, + "y": 0, + }, + "timing": { + "duration": 0, + "end": 0, + "start": 0, + }, + "text": "text", + "type": "text", + "encoding": "auto", + "transformation": [ + { + "alpha": 1, + "background": "background", + "flip": "h", + "font_color": "fontColor", + "font_family": "fontFamily", + "font_size": 0, + "inner_alignment": "left", + "line_height": 0, + "padding": 0, + "radius": 0, + "rotation": 0, + "typography": "typography", + "width": 0, + } + ], + }, + "page": 0, + "progressive": True, + "quality": 80, + "radius": 20, + "raw": "raw", + "rotation": 90, + "shadow": True, + "sharpen": True, + "start_offset": 0, + "streaming_resolutions": ["240"], + "trim": True, + "unsharp_mask": True, + "video_codec": "h264", + "width": 300, + "x": 0, + "x_center": 0, + "y": 0, + "y_center": 0, + "zoom": 0, + } + ], + }, + ) + assert dummy is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncImageKit) -> None: + response = await async_client.dummy.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + dummy = await response.parse() + assert dummy is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncImageKit) -> None: + async with async_client.dummy.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + dummy = await response.parse() + assert dummy is None + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_files.py b/tests/api_resources/test_files.py new file mode 100644 index 00000000..4d3d4aad --- /dev/null +++ b/tests/api_resources/test_files.py @@ -0,0 +1,913 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types import ( + File, + FileCopyResponse, + FileMoveResponse, + FileRenameResponse, + FileUpdateResponse, + FileUploadResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestFiles: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_overload_1(self, client: ImageKit) -> None: + file = client.files.update( + file_id="fileId", + ) + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params_overload_1(self, client: ImageKit) -> None: + file = client.files.update( + file_id="fileId", + custom_coordinates="customCoordinates", + custom_metadata={"foo": "bar"}, + description="description", + extensions=[ + { + "name": "remove-bg", + "options": { + "add_shadow": True, + "bg_color": "bg_color", + "bg_image_url": "bg_image_url", + "semitransparency": True, + }, + }, + { + "max_tags": 5, + "min_confidence": 95, + "name": "google-auto-tagging", + }, + {"name": "ai-auto-description"}, + ], + remove_ai_tags=["string"], + tags=["tag1", "tag2"], + webhook_url="https://example.com", + ) + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update_overload_1(self, client: ImageKit) -> None: + response = client.files.with_raw_response.update( + file_id="fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update_overload_1(self, client: ImageKit) -> None: + with client.files.with_streaming_response.update( + file_id="fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update_overload_1(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + client.files.with_raw_response.update( + file_id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_overload_2(self, client: ImageKit) -> None: + file = client.files.update( + file_id="fileId", + ) + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_update_with_all_params_overload_2(self, client: ImageKit) -> None: + file = client.files.update( + file_id="fileId", + publish={ + "is_published": True, + "include_file_versions": True, + }, + ) + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_update_overload_2(self, client: ImageKit) -> None: + response = client.files.with_raw_response.update( + file_id="fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_update_overload_2(self, client: ImageKit) -> None: + with client.files.with_streaming_response.update( + file_id="fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_update_overload_2(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + client.files.with_raw_response.update( + file_id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_delete(self, client: ImageKit) -> None: + file = client.files.delete( + "fileId", + ) + assert file is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_delete(self, client: ImageKit) -> None: + response = client.files.with_raw_response.delete( + "fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert file is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: ImageKit) -> None: + with client.files.with_streaming_response.delete( + "fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert file is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_delete(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + client.files.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_copy(self, client: ImageKit) -> None: + file = client.files.copy( + destination_path="/folder/to/copy/into/", + source_file_path="/path/to/file.jpg", + ) + assert_matches_type(FileCopyResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_copy_with_all_params(self, client: ImageKit) -> None: + file = client.files.copy( + destination_path="/folder/to/copy/into/", + source_file_path="/path/to/file.jpg", + include_file_versions=False, + ) + assert_matches_type(FileCopyResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_copy(self, client: ImageKit) -> None: + response = client.files.with_raw_response.copy( + destination_path="/folder/to/copy/into/", + source_file_path="/path/to/file.jpg", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(FileCopyResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_copy(self, client: ImageKit) -> None: + with client.files.with_streaming_response.copy( + destination_path="/folder/to/copy/into/", + source_file_path="/path/to/file.jpg", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(FileCopyResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_get(self, client: ImageKit) -> None: + file = client.files.get( + "fileId", + ) + assert_matches_type(File, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_get(self, client: ImageKit) -> None: + response = client.files.with_raw_response.get( + "fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(File, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_get(self, client: ImageKit) -> None: + with client.files.with_streaming_response.get( + "fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(File, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_path_params_get(self, client: ImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + client.files.with_raw_response.get( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_move(self, client: ImageKit) -> None: + file = client.files.move( + destination_path="/folder/to/move/into/", + source_file_path="/path/to/file.jpg", + ) + assert_matches_type(FileMoveResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_move(self, client: ImageKit) -> None: + response = client.files.with_raw_response.move( + destination_path="/folder/to/move/into/", + source_file_path="/path/to/file.jpg", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(FileMoveResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_move(self, client: ImageKit) -> None: + with client.files.with_streaming_response.move( + destination_path="/folder/to/move/into/", + source_file_path="/path/to/file.jpg", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(FileMoveResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_rename(self, client: ImageKit) -> None: + file = client.files.rename( + file_path="/path/to/file.jpg", + new_file_name="newFileName.jpg", + ) + assert_matches_type(FileRenameResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_rename_with_all_params(self, client: ImageKit) -> None: + file = client.files.rename( + file_path="/path/to/file.jpg", + new_file_name="newFileName.jpg", + purge_cache=True, + ) + assert_matches_type(FileRenameResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_rename(self, client: ImageKit) -> None: + response = client.files.with_raw_response.rename( + file_path="/path/to/file.jpg", + new_file_name="newFileName.jpg", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(FileRenameResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_rename(self, client: ImageKit) -> None: + with client.files.with_streaming_response.rename( + file_path="/path/to/file.jpg", + new_file_name="newFileName.jpg", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(FileRenameResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_upload(self, client: ImageKit) -> None: + file = client.files.upload( + file=b"raw file contents", + file_name="fileName", + ) + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_upload_with_all_params(self, client: ImageKit) -> None: + file = client.files.upload( + file=b"raw file contents", + file_name="fileName", + token="token", + checks='"request.folder" : "marketing/"\n', + custom_coordinates="customCoordinates", + custom_metadata={ + "brand": "bar", + "color": "bar", + }, + description="Running shoes", + expire=0, + extensions=[ + { + "name": "remove-bg", + "options": { + "add_shadow": True, + "bg_color": "bg_color", + "bg_image_url": "bg_image_url", + "semitransparency": True, + }, + }, + { + "max_tags": 5, + "min_confidence": 95, + "name": "google-auto-tagging", + }, + {"name": "ai-auto-description"}, + ], + folder="folder", + is_private_file=True, + is_published=True, + overwrite_ai_tags=True, + overwrite_custom_metadata=True, + overwrite_file=True, + overwrite_tags=True, + public_key="publicKey", + response_fields=["tags", "customCoordinates", "isPrivateFile"], + signature="signature", + tags=["t-shirt", "round-neck", "men"], + transformation={ + "post": [ + { + "type": "thumbnail", + "value": "w-150,h-150", + }, + { + "protocol": "dash", + "type": "abs", + "value": "sr-240_360_480_720_1080", + }, + ], + "pre": "w-300,h-300,q-80", + }, + use_unique_file_name=True, + webhook_url="https://example.com", + ) + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_upload(self, client: ImageKit) -> None: + response = client.files.with_raw_response.upload( + file=b"raw file contents", + file_name="fileName", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = response.parse() + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_upload(self, client: ImageKit) -> None: + with client.files.with_streaming_response.upload( + file=b"raw file contents", + file_name="fileName", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = response.parse() + assert_matches_type(FileUploadResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncFiles: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_overload_1(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.update( + file_id="fileId", + ) + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params_overload_1(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.update( + file_id="fileId", + custom_coordinates="customCoordinates", + custom_metadata={"foo": "bar"}, + description="description", + extensions=[ + { + "name": "remove-bg", + "options": { + "add_shadow": True, + "bg_color": "bg_color", + "bg_image_url": "bg_image_url", + "semitransparency": True, + }, + }, + { + "max_tags": 5, + "min_confidence": 95, + "name": "google-auto-tagging", + }, + {"name": "ai-auto-description"}, + ], + remove_ai_tags=["string"], + tags=["tag1", "tag2"], + webhook_url="https://example.com", + ) + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update_overload_1(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.with_raw_response.update( + file_id="fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update_overload_1(self, async_client: AsyncImageKit) -> None: + async with async_client.files.with_streaming_response.update( + file_id="fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update_overload_1(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + await async_client.files.with_raw_response.update( + file_id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_overload_2(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.update( + file_id="fileId", + ) + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_update_with_all_params_overload_2(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.update( + file_id="fileId", + publish={ + "is_published": True, + "include_file_versions": True, + }, + ) + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_update_overload_2(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.with_raw_response.update( + file_id="fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_update_overload_2(self, async_client: AsyncImageKit) -> None: + async with async_client.files.with_streaming_response.update( + file_id="fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(FileUpdateResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_update_overload_2(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + await async_client.files.with_raw_response.update( + file_id="", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.delete( + "fileId", + ) + assert file is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.with_raw_response.delete( + "fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert file is None + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncImageKit) -> None: + async with async_client.files.with_streaming_response.delete( + "fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert file is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + await async_client.files.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_copy(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.copy( + destination_path="/folder/to/copy/into/", + source_file_path="/path/to/file.jpg", + ) + assert_matches_type(FileCopyResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_copy_with_all_params(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.copy( + destination_path="/folder/to/copy/into/", + source_file_path="/path/to/file.jpg", + include_file_versions=False, + ) + assert_matches_type(FileCopyResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_copy(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.with_raw_response.copy( + destination_path="/folder/to/copy/into/", + source_file_path="/path/to/file.jpg", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(FileCopyResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_copy(self, async_client: AsyncImageKit) -> None: + async with async_client.files.with_streaming_response.copy( + destination_path="/folder/to/copy/into/", + source_file_path="/path/to/file.jpg", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(FileCopyResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_get(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.get( + "fileId", + ) + assert_matches_type(File, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_get(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.with_raw_response.get( + "fileId", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(File, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_get(self, async_client: AsyncImageKit) -> None: + async with async_client.files.with_streaming_response.get( + "fileId", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(File, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_path_params_get(self, async_client: AsyncImageKit) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `file_id` but received ''"): + await async_client.files.with_raw_response.get( + "", + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_move(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.move( + destination_path="/folder/to/move/into/", + source_file_path="/path/to/file.jpg", + ) + assert_matches_type(FileMoveResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_move(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.with_raw_response.move( + destination_path="/folder/to/move/into/", + source_file_path="/path/to/file.jpg", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(FileMoveResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_move(self, async_client: AsyncImageKit) -> None: + async with async_client.files.with_streaming_response.move( + destination_path="/folder/to/move/into/", + source_file_path="/path/to/file.jpg", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(FileMoveResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_rename(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.rename( + file_path="/path/to/file.jpg", + new_file_name="newFileName.jpg", + ) + assert_matches_type(FileRenameResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_rename_with_all_params(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.rename( + file_path="/path/to/file.jpg", + new_file_name="newFileName.jpg", + purge_cache=True, + ) + assert_matches_type(FileRenameResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_rename(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.with_raw_response.rename( + file_path="/path/to/file.jpg", + new_file_name="newFileName.jpg", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(FileRenameResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_rename(self, async_client: AsyncImageKit) -> None: + async with async_client.files.with_streaming_response.rename( + file_path="/path/to/file.jpg", + new_file_name="newFileName.jpg", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(FileRenameResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_upload(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.upload( + file=b"raw file contents", + file_name="fileName", + ) + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_upload_with_all_params(self, async_client: AsyncImageKit) -> None: + file = await async_client.files.upload( + file=b"raw file contents", + file_name="fileName", + token="token", + checks='"request.folder" : "marketing/"\n', + custom_coordinates="customCoordinates", + custom_metadata={ + "brand": "bar", + "color": "bar", + }, + description="Running shoes", + expire=0, + extensions=[ + { + "name": "remove-bg", + "options": { + "add_shadow": True, + "bg_color": "bg_color", + "bg_image_url": "bg_image_url", + "semitransparency": True, + }, + }, + { + "max_tags": 5, + "min_confidence": 95, + "name": "google-auto-tagging", + }, + {"name": "ai-auto-description"}, + ], + folder="folder", + is_private_file=True, + is_published=True, + overwrite_ai_tags=True, + overwrite_custom_metadata=True, + overwrite_file=True, + overwrite_tags=True, + public_key="publicKey", + response_fields=["tags", "customCoordinates", "isPrivateFile"], + signature="signature", + tags=["t-shirt", "round-neck", "men"], + transformation={ + "post": [ + { + "type": "thumbnail", + "value": "w-150,h-150", + }, + { + "protocol": "dash", + "type": "abs", + "value": "sr-240_360_480_720_1080", + }, + ], + "pre": "w-300,h-300,q-80", + }, + use_unique_file_name=True, + webhook_url="https://example.com", + ) + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_upload(self, async_client: AsyncImageKit) -> None: + response = await async_client.files.with_raw_response.upload( + file=b"raw file contents", + file_name="fileName", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + file = await response.parse() + assert_matches_type(FileUploadResponse, file, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_upload(self, async_client: AsyncImageKit) -> None: + async with async_client.files.with_streaming_response.upload( + file=b"raw file contents", + file_name="fileName", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + file = await response.parse() + assert_matches_type(FileUploadResponse, file, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_folders.py b/tests/api_resources/test_folders.py new file mode 100644 index 00000000..ad5d83f0 --- /dev/null +++ b/tests/api_resources/test_folders.py @@ -0,0 +1,434 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from tests.utils import assert_matches_type +from imagekitio.types import ( + FolderCopyResponse, + FolderMoveResponse, + FolderCreateResponse, + FolderDeleteResponse, + FolderRenameResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestFolders: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_create(self, client: ImageKit) -> None: + folder = client.folders.create( + folder_name="summer", + parent_folder_path="/product/images/", + ) + assert_matches_type(FolderCreateResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_create(self, client: ImageKit) -> None: + response = client.folders.with_raw_response.create( + folder_name="summer", + parent_folder_path="/product/images/", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + folder = response.parse() + assert_matches_type(FolderCreateResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_create(self, client: ImageKit) -> None: + with client.folders.with_streaming_response.create( + folder_name="summer", + parent_folder_path="/product/images/", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + folder = response.parse() + assert_matches_type(FolderCreateResponse, folder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_delete(self, client: ImageKit) -> None: + folder = client.folders.delete( + folder_path="/folder/to/delete/", + ) + assert_matches_type(FolderDeleteResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_delete(self, client: ImageKit) -> None: + response = client.folders.with_raw_response.delete( + folder_path="/folder/to/delete/", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + folder = response.parse() + assert_matches_type(FolderDeleteResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: ImageKit) -> None: + with client.folders.with_streaming_response.delete( + folder_path="/folder/to/delete/", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + folder = response.parse() + assert_matches_type(FolderDeleteResponse, folder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_copy(self, client: ImageKit) -> None: + folder = client.folders.copy( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) + assert_matches_type(FolderCopyResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_copy_with_all_params(self, client: ImageKit) -> None: + folder = client.folders.copy( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + include_versions=True, + ) + assert_matches_type(FolderCopyResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_copy(self, client: ImageKit) -> None: + response = client.folders.with_raw_response.copy( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + folder = response.parse() + assert_matches_type(FolderCopyResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_copy(self, client: ImageKit) -> None: + with client.folders.with_streaming_response.copy( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + folder = response.parse() + assert_matches_type(FolderCopyResponse, folder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_move(self, client: ImageKit) -> None: + folder = client.folders.move( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) + assert_matches_type(FolderMoveResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_move(self, client: ImageKit) -> None: + response = client.folders.with_raw_response.move( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + folder = response.parse() + assert_matches_type(FolderMoveResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_move(self, client: ImageKit) -> None: + with client.folders.with_streaming_response.move( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + folder = response.parse() + assert_matches_type(FolderMoveResponse, folder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_rename(self, client: ImageKit) -> None: + folder = client.folders.rename( + folder_path="/path/of/folder", + new_folder_name="new-folder-name", + ) + assert_matches_type(FolderRenameResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_rename_with_all_params(self, client: ImageKit) -> None: + folder = client.folders.rename( + folder_path="/path/of/folder", + new_folder_name="new-folder-name", + purge_cache=True, + ) + assert_matches_type(FolderRenameResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_rename(self, client: ImageKit) -> None: + response = client.folders.with_raw_response.rename( + folder_path="/path/of/folder", + new_folder_name="new-folder-name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + folder = response.parse() + assert_matches_type(FolderRenameResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_rename(self, client: ImageKit) -> None: + with client.folders.with_streaming_response.rename( + folder_path="/path/of/folder", + new_folder_name="new-folder-name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + folder = response.parse() + assert_matches_type(FolderRenameResponse, folder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncFolders: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncImageKit) -> None: + folder = await async_client.folders.create( + folder_name="summer", + parent_folder_path="/product/images/", + ) + assert_matches_type(FolderCreateResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncImageKit) -> None: + response = await async_client.folders.with_raw_response.create( + folder_name="summer", + parent_folder_path="/product/images/", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + folder = await response.parse() + assert_matches_type(FolderCreateResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncImageKit) -> None: + async with async_client.folders.with_streaming_response.create( + folder_name="summer", + parent_folder_path="/product/images/", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + folder = await response.parse() + assert_matches_type(FolderCreateResponse, folder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncImageKit) -> None: + folder = await async_client.folders.delete( + folder_path="/folder/to/delete/", + ) + assert_matches_type(FolderDeleteResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncImageKit) -> None: + response = await async_client.folders.with_raw_response.delete( + folder_path="/folder/to/delete/", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + folder = await response.parse() + assert_matches_type(FolderDeleteResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncImageKit) -> None: + async with async_client.folders.with_streaming_response.delete( + folder_path="/folder/to/delete/", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + folder = await response.parse() + assert_matches_type(FolderDeleteResponse, folder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_copy(self, async_client: AsyncImageKit) -> None: + folder = await async_client.folders.copy( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) + assert_matches_type(FolderCopyResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_copy_with_all_params(self, async_client: AsyncImageKit) -> None: + folder = await async_client.folders.copy( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + include_versions=True, + ) + assert_matches_type(FolderCopyResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_copy(self, async_client: AsyncImageKit) -> None: + response = await async_client.folders.with_raw_response.copy( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + folder = await response.parse() + assert_matches_type(FolderCopyResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_copy(self, async_client: AsyncImageKit) -> None: + async with async_client.folders.with_streaming_response.copy( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + folder = await response.parse() + assert_matches_type(FolderCopyResponse, folder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_move(self, async_client: AsyncImageKit) -> None: + folder = await async_client.folders.move( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) + assert_matches_type(FolderMoveResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_move(self, async_client: AsyncImageKit) -> None: + response = await async_client.folders.with_raw_response.move( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + folder = await response.parse() + assert_matches_type(FolderMoveResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_move(self, async_client: AsyncImageKit) -> None: + async with async_client.folders.with_streaming_response.move( + destination_path="/path/of/destination/folder", + source_folder_path="/path/of/source/folder", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + folder = await response.parse() + assert_matches_type(FolderMoveResponse, folder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_rename(self, async_client: AsyncImageKit) -> None: + folder = await async_client.folders.rename( + folder_path="/path/of/folder", + new_folder_name="new-folder-name", + ) + assert_matches_type(FolderRenameResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_rename_with_all_params(self, async_client: AsyncImageKit) -> None: + folder = await async_client.folders.rename( + folder_path="/path/of/folder", + new_folder_name="new-folder-name", + purge_cache=True, + ) + assert_matches_type(FolderRenameResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_rename(self, async_client: AsyncImageKit) -> None: + response = await async_client.folders.with_raw_response.rename( + folder_path="/path/of/folder", + new_folder_name="new-folder-name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + folder = await response.parse() + assert_matches_type(FolderRenameResponse, folder, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_rename(self, async_client: AsyncImageKit) -> None: + async with async_client.folders.with_streaming_response.rename( + folder_path="/path/of/folder", + new_folder_name="new-folder-name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + folder = await response.parse() + assert_matches_type(FolderRenameResponse, folder, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_webhooks.py b/tests/api_resources/test_webhooks.py new file mode 100644 index 00000000..35fbbd13 --- /dev/null +++ b/tests/api_resources/test_webhooks.py @@ -0,0 +1,79 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from datetime import datetime, timezone + +import pytest +import standardwebhooks + +from imagekitio import ImageKit + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestWebhooks: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + def test_method_unwrap(self, client: ImageKit) -> None: + key = b"secret" + hook = standardwebhooks.Webhook(key) + + data = """{"id":"id","type":"video.transformation.accepted","created_at":"2019-12-27T18:11:19.117Z","data":{"asset":{"url":"https://example.com"},"transformation":{"type":"video-transformation","options":{"audio_codec":"aac","auto_rotate":true,"format":"mp4","quality":0,"stream_protocol":"HLS","variants":["string"],"video_codec":"h264"}}},"request":{"url":"https://example.com","x_request_id":"x_request_id","user_agent":"user_agent"}}""" + msg_id = "1" + timestamp = datetime.now(tz=timezone.utc) + sig = hook.sign(msg_id=msg_id, timestamp=timestamp, data=data) + headers = { + "webhook-id": msg_id, + "webhook-timestamp": str(int(timestamp.timestamp())), + "webhook-signature": sig, + } + + try: + _ = client.webhooks.unwrap(data, headers=headers, key=key) + except standardwebhooks.WebhookVerificationError as e: + raise AssertionError("Failed to unwrap valid webhook") from e + + bad_headers = [ + {**headers, "webhook-signature": hook.sign(msg_id=msg_id, timestamp=timestamp, data="xxx")}, + {**headers, "webhook-id": "bad"}, + {**headers, "webhook-timestamp": "0"}, + ] + for bad_header in bad_headers: + with pytest.raises(standardwebhooks.WebhookVerificationError): + _ = client.webhooks.unwrap(data, headers=bad_header, key=key) + + +class TestAsyncWebhooks: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + def test_method_unwrap(self, client: ImageKit) -> None: + key = b"secret" + hook = standardwebhooks.Webhook(key) + + data = """{"id":"id","type":"video.transformation.accepted","created_at":"2019-12-27T18:11:19.117Z","data":{"asset":{"url":"https://example.com"},"transformation":{"type":"video-transformation","options":{"audio_codec":"aac","auto_rotate":true,"format":"mp4","quality":0,"stream_protocol":"HLS","variants":["string"],"video_codec":"h264"}}},"request":{"url":"https://example.com","x_request_id":"x_request_id","user_agent":"user_agent"}}""" + msg_id = "1" + timestamp = datetime.now(tz=timezone.utc) + sig = hook.sign(msg_id=msg_id, timestamp=timestamp, data=data) + headers = { + "webhook-id": msg_id, + "webhook-timestamp": str(int(timestamp.timestamp())), + "webhook-signature": sig, + } + + try: + _ = client.webhooks.unwrap(data, headers=headers, key=key) + except standardwebhooks.WebhookVerificationError as e: + raise AssertionError("Failed to unwrap valid webhook") from e + + bad_headers = [ + {**headers, "webhook-signature": hook.sign(msg_id=msg_id, timestamp=timestamp, data="xxx")}, + {**headers, "webhook-id": "bad"}, + {**headers, "webhook-timestamp": "0"}, + ] + for bad_header in bad_headers: + with pytest.raises(standardwebhooks.WebhookVerificationError): + _ = client.webhooks.unwrap(data, headers=bad_header, key=key) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..d64313cd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,91 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +import logging +from typing import TYPE_CHECKING, Iterator, AsyncIterator + +import httpx +import pytest +from pytest_asyncio import is_async_test + +from imagekitio import ImageKit, AsyncImageKit, DefaultAioHttpClient +from imagekitio._utils import is_dict + +if TYPE_CHECKING: + from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] + +pytest.register_assert_rewrite("tests.utils") + +logging.getLogger("imagekitio").setLevel(logging.DEBUG) + + +# automatically add `pytest.mark.asyncio()` to all of our async tests +# so we don't have to add that boilerplate everywhere +def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: + pytest_asyncio_tests = (item for item in items if is_async_test(item)) + session_scope_marker = pytest.mark.asyncio(loop_scope="session") + for async_test in pytest_asyncio_tests: + async_test.add_marker(session_scope_marker, append=False) + + # We skip tests that use both the aiohttp client and respx_mock as respx_mock + # doesn't support custom transports. + for item in items: + if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: + continue + + if not hasattr(item, "callspec"): + continue + + async_client_param = item.callspec.params.get("async_client") + if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": + item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) + + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + +private_key = "My Private Key" +password = "My Password" + + +@pytest.fixture(scope="session") +def client(request: FixtureRequest) -> Iterator[ImageKit]: + strict = getattr(request, "param", True) + if not isinstance(strict, bool): + raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") + + with ImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=strict + ) as client: + yield client + + +@pytest.fixture(scope="session") +async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncImageKit]: + param = getattr(request, "param", True) + + # defaults + strict = True + http_client: None | httpx.AsyncClient = None + + if isinstance(param, bool): + strict = param + elif is_dict(param): + strict = param.get("strict", True) + assert isinstance(strict, bool) + + http_client_type = param.get("http_client", "httpx") + if http_client_type == "aiohttp": + http_client = DefaultAioHttpClient() + else: + raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") + + async with AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=strict, + http_client=http_client, + ) as client: + yield client diff --git a/tests/custom/__init__.py b/tests/custom/__init__.py new file mode 100644 index 00000000..dad8a0a3 --- /dev/null +++ b/tests/custom/__init__.py @@ -0,0 +1,2 @@ +# Custom tests for manually created helper functions +# These tests are separate from auto-generated API tests diff --git a/tests/custom/test_helper_authentication.py b/tests/custom/test_helper_authentication.py new file mode 100644 index 00000000..a0a08efa --- /dev/null +++ b/tests/custom/test_helper_authentication.py @@ -0,0 +1,114 @@ +"""Helper authentication tests - converted from Ruby SDK.""" + +import re + +import pytest + +from imagekitio import ImageKit, ImageKitError + + +class TestHelperAuthentication: + """Test helper authentication parameter generation.""" + + def test_should_return_correct_authentication_parameters_with_provided_token_and_expire(self) -> None: + """Should return correct authentication parameters with provided token and expire.""" + private_key = "private_key_test" + client = ImageKit(private_key=private_key) + + token = "your_token" + expire = 1582269249 + + params = client.helper.get_authentication_parameters(token=token, expire=expire) + + # Expected exact match with Node.js output + expected_signature = "e71bcd6031016b060d349d212e23e85c791decdd" + + assert params["token"] == token + assert params["expire"] == expire + assert params["signature"] == expected_signature + + def test_should_return_authentication_parameters_with_required_properties_when_no_params_provided(self) -> None: + """Should return authentication parameters with required properties when no params provided.""" + private_key = "private_key_test" + client = ImageKit(private_key=private_key) + + params = client.helper.get_authentication_parameters() + + # Check that all required properties exist + assert "token" in params, "Expected token parameter" + assert "expire" in params, "Expected expire parameter" + assert "signature" in params, "Expected signature parameter" + + # Token should be a UUID v4 format (36 characters with dashes) + token = params["token"] + assert isinstance(token, str) + assert re.match( + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", token, re.IGNORECASE + ), "Expected token to be UUID v4 format" + + # Expire should be a number greater than current time + expire = params["expire"] + assert isinstance(expire, int) + import time + + current_time = int(time.time()) + assert expire > current_time, f"Expected expire {expire} to be greater than current time {current_time}" + + # Signature should be a hex string (40 characters for HMAC-SHA1) + signature = params["signature"] + assert isinstance(signature, str) + assert re.match(r"^[a-f0-9]{40}$", signature), "Expected signature to be 40 character hex string" + + def test_should_handle_edge_case_with_expire_time_0(self) -> None: + """Should handle edge case with expire time 0.""" + private_key = "private_key_test" + client = ImageKit(private_key=private_key) + + token = "test_token" + expire = 0 + + params = client.helper.get_authentication_parameters(token=token, expire=expire) + + assert params["token"] == token + assert params["expire"] == expire + assert "signature" in params + # Signature should still be generated even with expire = 0 + assert isinstance(params["signature"], str) + assert len(params["signature"]) == 40 + + def test_should_handle_empty_string_token(self) -> None: + """Should handle empty string token.""" + private_key = "private_key_test" + client = ImageKit(private_key=private_key) + + token = "" # Empty string is falsy + expire = 1582269249 + + params = client.helper.get_authentication_parameters(token=token, expire=expire) + + # Since empty string is falsy, it should generate a token + token_result = params["token"] + assert isinstance(token_result, str) + assert len(token_result) > 0, "Expected token to be generated when empty string is provided" + assert re.match( + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", token_result, re.IGNORECASE + ), "Expected generated token to be UUID v4 format" + + assert params["expire"] == expire + + # Signature should be a hex string (40 characters for HMAC-SHA1) + signature = params["signature"] + assert isinstance(signature, str) + assert re.match(r"^[a-f0-9]{40}$", signature), "Expected signature to be 40 character hex string" + + def test_should_raise_error_when_private_key_is_not_provided(self) -> None: + """Should raise error when private key is empty.""" + with pytest.raises(ValueError, match="Private key is required"): + client = ImageKit(private_key="") + client.helper.get_authentication_parameters(token="test", expire=123) + + def test_should_raise_error_when_private_key_is_nil(self) -> None: + """Should raise error when private key is None.""" + with pytest.raises(ImageKitError, match="private_key client option must be set"): + client = ImageKit(private_key=None) # type: ignore + client.helper.get_authentication_parameters(token="test", expire=123) diff --git a/tests/custom/test_serialization_utils.py b/tests/custom/test_serialization_utils.py new file mode 100644 index 00000000..b523aeb4 --- /dev/null +++ b/tests/custom/test_serialization_utils.py @@ -0,0 +1,228 @@ +"""Unit tests for serialization_utils module.""" + +import json +from typing import Any, Dict, List + +from imagekitio.lib.serialization_utils import serialize_upload_options + + +class TestSerializeUploadOptions: + """Test cases for serialize_upload_options function.""" + + def test_should_convert_tags_array_to_comma_separated_string(self): + """Test that tags array is converted to comma-separated string.""" + body = {"tags": ["tag1", "tag2", "tag3"]} + result = serialize_upload_options(body) + assert result["tags"] == "tag1,tag2,tag3" + + def test_should_convert_tags_tuple_to_comma_separated_string(self): + """Test that tags tuple is converted to comma-separated string.""" + body = {"tags": ("tag1", "tag2", "tag3")} + result = serialize_upload_options(body) + assert result["tags"] == "tag1,tag2,tag3" + + def test_should_convert_response_fields_array_to_comma_separated_string(self): + """Test that response_fields array is converted to comma-separated string.""" + body = {"response_fields": ["tags", "customCoordinates", "metadata"]} + result = serialize_upload_options(body) + assert result["response_fields"] == "tags,customCoordinates,metadata" + + def test_should_convert_response_fields_tuple_to_comma_separated_string(self): + """Test that response_fields tuple is converted to comma-separated string.""" + body = {"response_fields": ("tags", "customCoordinates")} + result = serialize_upload_options(body) + assert result["response_fields"] == "tags,customCoordinates" + + def test_should_json_stringify_extensions_array(self): + """Test that extensions array is JSON stringified.""" + body = {"extensions": [{"name": "remove-bg"}, {"name": "google-auto-tagging", "minConfidence": 80}]} + result = serialize_upload_options(body) + expected = json.dumps(body["extensions"]) + assert result["extensions"] == expected + # Verify it's valid JSON + assert json.loads(result["extensions"]) == body["extensions"] + + def test_should_json_stringify_custom_metadata_object(self): + """Test that custom_metadata object is JSON stringified.""" + body = {"custom_metadata": {"key1": "value1", "key2": 123, "key3": True}} + result = serialize_upload_options(body) + expected = json.dumps(body["custom_metadata"]) + assert result["custom_metadata"] == expected + # Verify it's valid JSON + assert json.loads(result["custom_metadata"]) == body["custom_metadata"] + + def test_should_json_stringify_transformation_object(self): + """Test that transformation object is JSON stringified.""" + body = { + "transformation": { + "pre": "l-image,i-logo.png,w-100,h-100", + "post": [{"type": "thumbnail", "value": "h-300"}], + } + } + result = serialize_upload_options(body) + expected = json.dumps(body["transformation"]) + assert result["transformation"] == expected + # Verify it's valid JSON + assert json.loads(result["transformation"]) == body["transformation"] + + def test_should_handle_all_serializable_fields_together(self): + """Test that all serializable fields are processed correctly together.""" + body = { + "file": "test.jpg", + "file_name": "test.jpg", + "tags": ["tag1", "tag2"], + "response_fields": ["tags", "metadata"], + "extensions": [{"name": "remove-bg"}], + "custom_metadata": {"key": "value"}, + "transformation": {"pre": "w-100"}, + "folder": "/images", + } + result = serialize_upload_options(body) + + assert result["tags"] == "tag1,tag2" + assert result["response_fields"] == "tags,metadata" + assert result["extensions"] == json.dumps([{"name": "remove-bg"}]) + assert result["custom_metadata"] == json.dumps({"key": "value"}) + assert result["transformation"] == json.dumps({"pre": "w-100"}) + # Non-serializable fields should remain unchanged + assert result["file"] == "test.jpg" + assert result["file_name"] == "test.jpg" + assert result["folder"] == "/images" + + def test_should_not_modify_original_body(self): + """Test that the original body is not modified.""" + body = { + "tags": ["tag1", "tag2"], + "response_fields": ["tags"], + "extensions": [{"name": "ext1"}], + } + original_tags = body["tags"].copy() + original_response_fields = body["response_fields"].copy() + original_extensions = body["extensions"].copy() + + serialize_upload_options(body) + + # Original should remain unchanged + assert body["tags"] == original_tags + assert body["response_fields"] == original_response_fields + assert body["extensions"] == original_extensions + + def test_should_handle_empty_arrays(self): + """Test that empty arrays are converted to empty strings.""" + body: Dict[str, List[str]] = {"tags": [], "response_fields": []} + result = serialize_upload_options(body) + assert result["tags"] == "" + assert result["response_fields"] == "" + + def test_should_handle_empty_extensions_array(self): + """Test that empty extensions array is JSON stringified.""" + body: Dict[str, List[Any]] = {"extensions": []} + result = serialize_upload_options(body) + assert result["extensions"] == "[]" + + def test_should_handle_none_values(self): + """Test that None values are not processed.""" + body = { + "tags": None, + "response_fields": None, + "extensions": None, + "custom_metadata": None, + "transformation": None, + } + result = serialize_upload_options(body) + # None values should remain None + assert result["tags"] is None + assert result["response_fields"] is None + assert result["extensions"] is None + assert result["custom_metadata"] is None + assert result["transformation"] is None + + def test_should_handle_empty_object(self): + """Test that an empty object is returned as is.""" + body: Dict[str, Any] = {} + result = serialize_upload_options(body) + assert result == {} + + def test_should_skip_non_matching_fields(self): + """Test that fields not in the serialization list are left unchanged.""" + body = { + "file_name": "test.jpg", + "folder": "/images", + "is_private_file": True, + "use_unique_file_name": False, + } + result = serialize_upload_options(body) + assert result == body + + def test_should_handle_single_tag(self): + """Test that a single tag array is handled correctly.""" + body = {"tags": ["single-tag"]} + result = serialize_upload_options(body) + assert result["tags"] == "single-tag" + + def test_should_handle_tags_with_empty_strings(self): + """Test that tags with empty strings are still joined.""" + body = {"tags": ["tag1", "", "tag2"]} + result = serialize_upload_options(body) + assert result["tags"] == "tag1,,tag2" + + def test_should_handle_complex_nested_extensions(self): + """Test that complex nested extensions are properly JSON stringified.""" + body = { + "extensions": [ + { + "name": "aws-auto-tagging", + "options": {"maxTags": 10, "minConfidence": 75}, + }, + { + "name": "remove-bg", + "options": {"add_shadow": True, "bg_color": "white"}, + }, + ] + } + result = serialize_upload_options(body) + expected = json.dumps(body["extensions"]) + assert result["extensions"] == expected + assert json.loads(result["extensions"]) == body["extensions"] + + def test_should_handle_nested_custom_metadata(self): + """Test that nested custom metadata is properly JSON stringified.""" + body = { + "custom_metadata": { + "product": {"name": "Test Product", "price": 99.99, "inStock": True}, + "category": "electronics", + } + } + result = serialize_upload_options(body) + expected = json.dumps(body["custom_metadata"]) + assert result["custom_metadata"] == expected + assert json.loads(result["custom_metadata"]) == body["custom_metadata"] + + def test_should_handle_transformation_with_both_pre_and_post(self): + """Test that transformation with both pre and post is properly handled.""" + body = { + "transformation": { + "pre": "w-200,h-200", + "post": [{"type": "transformation", "value": "w-100,h-100"}], + } + } + result = serialize_upload_options(body) + expected = json.dumps(body["transformation"]) + assert result["transformation"] == expected + assert json.loads(result["transformation"]) == body["transformation"] + + def test_should_not_modify_non_dict_custom_metadata(self): + """Test that custom_metadata is only serialized when it's a dict.""" + # This shouldn't happen in practice but testing edge case + body = {"custom_metadata": "string_value"} + result = serialize_upload_options(body) + # String value should remain unchanged + assert result["custom_metadata"] == "string_value" + + def test_should_not_modify_non_list_extensions(self): + """Test that extensions is only serialized when it's a list.""" + # This shouldn't happen in practice but testing edge case + body = {"extensions": "string_value"} + result = serialize_upload_options(body) + # String value should remain unchanged + assert result["extensions"] == "string_value" diff --git a/tests/custom/url_generation/__init__.py b/tests/custom/url_generation/__init__.py new file mode 100644 index 00000000..e0c071a8 --- /dev/null +++ b/tests/custom/url_generation/__init__.py @@ -0,0 +1 @@ +# URL generation test module diff --git a/tests/custom/url_generation/test_advanced_url_generation.py b/tests/custom/url_generation/test_advanced_url_generation.py new file mode 100644 index 00000000..6111f07d --- /dev/null +++ b/tests/custom/url_generation/test_advanced_url_generation.py @@ -0,0 +1,281 @@ +"""Advanced URL generation tests imported from Ruby SDK.""" + +import pytest + +from imagekitio import ImageKit + + +class TestAdvancedURLGeneration: + """Test advanced URL generation matching Ruby SDK advanced_url_generation_test.rb.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Setup client for each test.""" + self.client = ImageKit(private_key="My Private API Key") + + # AI Transformation Tests + def test_should_generate_the_correct_url_for_ai_background_removal_when_set_to_true(self): + """Test AI background removal transformation.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"ai_remove_background": True}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=e-bgremove" + assert url == expected + + def test_should_generate_the_correct_url_for_external_ai_background_removal_when_set_to_true(self): + """Test external AI background removal transformation.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"ai_remove_background_external": True}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=e-removedotbg" + assert url == expected + + def test_should_generate_the_correct_url_when_ai_drop_shadow_transformation_is_set_to_true(self): + """Test AI drop shadow transformation.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"ai_drop_shadow": True}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=e-dropshadow" + assert url == expected + + def test_should_generate_the_correct_url_when_gradient_transformation_is_set_to_true(self): + """Test gradient transformation.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"gradient": True}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=e-gradient" + assert url == expected + + def test_should_not_apply_ai_background_removal_when_value_is_not_true(self): + """Test that AI background removal is not applied when not true.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg" + assert url == expected + + def test_should_not_apply_external_ai_background_removal_when_value_is_not_true(self): + """Test that external AI background removal is not applied when not true.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg" + assert url == expected + + def test_should_handle_ai_transformations_with_parameters(self): + """Test AI transformations with custom parameters.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"ai_drop_shadow": "custom-shadow-params"}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=e-dropshadow-custom-shadow-params" + assert url == expected + + def test_should_handle_gradient_with_parameters(self): + """Test gradient with custom parameters.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"gradient": "ld-top_from-green_to-00FF0010_sp-1"}], + ) + expected = ( + "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=e-gradient-ld-top_from-green_to-00FF0010_sp-1" + ) + assert url == expected + + def test_should_combine_ai_transformations_with_regular_transformations(self): + """Test combining AI and regular transformations.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"width": 300, "height": 200, "ai_remove_background": True}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=w-300,h-200,e-bgremove" + assert url == expected + + def test_should_handle_multiple_ai_transformations(self): + """Test multiple AI transformations.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"ai_remove_background": True, "ai_drop_shadow": True}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=e-bgremove,e-dropshadow" + assert url == expected + + # Parameter-specific tests + def test_should_generate_the_correct_url_for_width_transformation_when_provided_with_a_number_value(self): + """Test width transformation with number value.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"width": 400}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=w-400" + assert url == expected + + def test_should_generate_the_correct_url_for_height_transformation_when_provided_with_a_string_value(self): + """Test height transformation with string value.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"height": "300"}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=h-300" + assert url == expected + + def test_should_generate_the_correct_url_for_aspect_ratio_transformation_when_provided_with_colon_format(self): + """Test aspect ratio transformation with colon format.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"aspect_ratio": "4:3"}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=ar-4:3" + assert url == expected + + def test_should_generate_the_correct_url_for_quality_transformation_when_provided_with_a_number_value(self): + """Test quality transformation with number value.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"quality": 80}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=q-80" + assert url == expected + + # Additional parameter validation tests + def test_should_skip_transformation_parameters_that_are_undefined_or_empty(self): + """Test that undefined/empty parameters are skipped.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"width": 300}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=w-300" + assert url == expected + + def test_should_handle_boolean_transformation_values(self): + """Test boolean transformation values.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"trim": True}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=t-true" + assert url == expected + + def test_should_handle_transformation_parameter_with_empty_string_value(self): + """Test transformation with empty string value.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"default_image": ""}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg" + assert url == expected + + def test_should_handle_complex_transformation_combinations(self): + """Test complex transformation combinations.""" + url = self.client.helper.build_url( + src="/test_path1.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"width": 300, "height": 200, "quality": 85, "border": "5_FF0000"}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path1.jpg?tr=w-300,h-200,q-85,b-5_FF0000" + assert url == expected + + def test_should_generate_the_correct_url_with_many_transformations_including_video_and_ai_transforms(self): + """Test many transformations including video and AI.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[ + { + "height": 300, + "width": 400, + "aspect_ratio": "4-3", + "quality": 40, + "crop": "force", + "crop_mode": "extract", + "focus": "left", + "format": "jpeg", + "radius": 50, + "background": "A94D34", + "border": "5-A94D34", + "rotation": 90, + "blur": 10, + "named": "some_name", + "progressive": True, + "lossless": True, + "trim": 5, + "metadata": True, + "color_profile": True, + "default_image": "/folder/file.jpg/", + "dpr": 3, + "x": 10, + "y": 20, + "x_center": 30, + "y_center": 40, + "flip": "h", + "opacity": 0.8, + "zoom": 2, + "video_codec": "h264", + "audio_codec": "aac", + "start_offset": 5, + "end_offset": 15, + "duration": 10, + "streaming_resolutions": ["1440", "1080"], + "grayscale": True, + "ai_upscale": True, + "ai_retouch": True, + "ai_variation": True, + "ai_drop_shadow": True, + "ai_change_background": "prompt-car", + "ai_edit": "prompt-make it vintage", + "ai_remove_background": True, + "contrast_stretch": True, + "shadow": "bl-15_st-40_x-10_y-N5", + "sharpen": 10, + "unsharp_mask": "2-2-0.8-0.024", + "gradient": "from-red_to-white", + "original": True, + "page": "2_4", + "raw": "h-200,w-300,l-image,i-logo.png,l-end", + } + ], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/test_path.jpg?tr=h-300,w-400,ar-4-3,q-40,c-force,cm-extract,fo-left,f-jpeg,r-50,bg-A94D34,b-5-A94D34,rt-90,bl-10,n-some_name,pr-true,lo-true,t-5,md-true,cp-true,di-folder@@file.jpg,dpr-3,x-10,y-20,xc-30,yc-40,fl-h,o-0.8,z-2,vc-h264,ac-aac,so-5,eo-15,du-10,sr-1440_1080,e-grayscale,e-upscale,e-retouch,e-genvar,e-dropshadow,e-changebg-prompt-car,e-edit-prompt-make it vintage,e-bgremove,e-contrast,e-shadow-bl-15_st-40_x-10_y-N5,e-sharpen-10,e-usm-2-2-0.8-0.024,e-gradient-from-red_to-white,orig-true,pg-2_4,h-200,w-300,l-image,i-logo.png,l-end" + assert url == expected diff --git a/tests/custom/url_generation/test_basic_url_generation.py b/tests/custom/url_generation/test_basic_url_generation.py new file mode 100644 index 00000000..d91614b1 --- /dev/null +++ b/tests/custom/url_generation/test_basic_url_generation.py @@ -0,0 +1,261 @@ +"""Basic URL generation tests - converted from Ruby SDK.""" + +from typing import TYPE_CHECKING + +import pytest + +from imagekitio import ImageKit + +if TYPE_CHECKING: + from imagekitio._client import ImageKit as ImageKitType + + +class TestBasicURLGeneration: + """Test basic URL generation functionality.""" + + client: "ImageKitType" + + @pytest.fixture(autouse=True) + def setup(self) -> None: + """Set up test client.""" + self.client = ImageKit(private_key="My Private API Key") + + def test_should_return_an_empty_string_when_src_is_not_provided(self) -> None: + """Should return an empty string when src is not provided.""" + url = self.client.helper.build_url( + src="", url_endpoint="https://ik.imagekit.io/test_url_endpoint", transformation_position="query" + ) + + assert url == "" + + def test_should_generate_a_valid_url_when_src_is_slash(self) -> None: + """Should generate a valid URL when src is slash.""" + url = self.client.helper.build_url( + src="/", url_endpoint="https://ik.imagekit.io/test_url_endpoint", transformation_position="query" + ) + + expected = "https://ik.imagekit.io/test_url_endpoint" + assert url == expected + + def test_should_generate_a_valid_url_when_src_is_provided_with_transformation(self) -> None: + """Should generate a valid URL when src is provided with transformation.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path.jpg" + assert url == expected + + def test_should_generate_a_valid_url_when_a_src_is_provided_without_transformation(self) -> None: + """Should generate a valid URL when a src is provided without transformation.""" + url = self.client.helper.build_url( + src="https://ik.imagekit.io/test_url_endpoint/test_path_alt.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path_alt.jpg" + assert url == expected + + def test_should_generate_a_valid_url_when_undefined_transformation_parameters_are_provided_with_path(self) -> None: + """Should generate a valid URL when undefined transformation parameters are provided with path.""" + url = self.client.helper.build_url( + src="/test_path_alt.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path_alt.jpg" + assert url == expected + + def test_by_default_transformation_position_should_be_query(self) -> None: + """By default transformation position should be query.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation=[{"height": 300, "width": 400}, {"rotation": 90}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path.jpg?tr=h-300,w-400:rt-90" + assert url == expected + + def test_should_generate_the_url_without_sdk_version(self) -> None: + """Should generate the URL without SDK version.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation=[{"height": 300, "width": 400}], + transformation_position="path", + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/tr:h-300,w-400/test_path.jpg" + assert url == expected + + def test_should_generate_the_correct_url_with_a_valid_src_and_transformation(self) -> None: + """Should generate the correct URL with a valid src and transformation.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"height": 300, "width": 400}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path.jpg?tr=h-300,w-400" + assert url == expected + + def test_should_add_transformation_as_query_when_src_has_absolute_url_even_if_transformation_position_is_path( + self, + ) -> None: + """Should add transformation as query when src has absolute URL even if transformation position is path.""" + url = self.client.helper.build_url( + src="https://my.custom.domain.com/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"height": 300, "width": 400}], + ) + + expected = "https://my.custom.domain.com/test_path.jpg?tr=h-300,w-400" + assert url == expected + + def test_should_generate_correct_url_when_src_has_query_params(self) -> None: + """Should generate correct URL when src has query params.""" + url = self.client.helper.build_url( + src="https://ik.imagekit.io/imagekit_id/new-endpoint/test_path.jpg?t1=v1", + url_endpoint="https://ik.imagekit.io/imagekit_id/new-endpoint", + transformation_position="query", + transformation=[{"height": 300, "width": 400}], + ) + + expected = "https://ik.imagekit.io/imagekit_id/new-endpoint/test_path.jpg?t1=v1&tr=h-300,w-400" + assert url == expected + + def test_should_generate_the_correct_url_when_the_provided_path_contains_multiple_leading_slashes(self) -> None: + """Should generate the correct URL when the provided path contains multiple leading slashes.""" + url = self.client.helper.build_url( + src="///test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"height": 300, "width": 400}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path.jpg?tr=h-300,w-400" + assert url == expected + + def test_should_generate_the_correct_url_when_the_url_endpoint_is_overridden(self) -> None: + """Should generate the correct URL when the URL endpoint is overridden.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint_alt", + transformation_position="query", + transformation=[{"height": 300, "width": 400}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint_alt/test_path.jpg?tr=h-300,w-400" + assert url == expected + + def test_should_generate_the_correct_url_with_transformation_position_as_query_parameter_when_src_is_provided( + self, + ) -> None: + """Should generate the correct URL with transformation position as query parameter when src is provided.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"height": 300, "width": 400}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path.jpg?tr=h-300,w-400" + assert url == expected + + def test_should_generate_the_correct_url_with_a_valid_src_parameter_and_transformation(self) -> None: + """Should generate the correct URL with a valid src parameter and transformation.""" + url = self.client.helper.build_url( + src="https://ik.imagekit.io/test_url_endpoint/test_path_alt.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"height": 300, "width": 400}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path_alt.jpg?tr=h-300,w-400" + assert url == expected + + def test_should_merge_query_parameters_correctly_in_the_generated_url(self) -> None: + """Should merge query parameters correctly in the generated URL.""" + url = self.client.helper.build_url( + src="https://ik.imagekit.io/test_url_endpoint/test_path_alt.jpg?t1=v1", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + query_parameters={"t2": "v2", "t3": "v3"}, + transformation=[{"height": 300, "width": 400}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path_alt.jpg?t1=v1&t2=v2&t3=v3&tr=h-300,w-400" + assert url == expected + + def test_should_generate_the_correct_url_with_chained_transformations(self) -> None: + """Should generate the correct URL with chained transformations.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"height": 300, "width": 400}, {"rotation": 90}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path.jpg?tr=h-300,w-400:rt-90" + assert url == expected + + def test_should_generate_the_correct_url_with_chained_transformations_including_raw_transformation(self) -> None: + """Should generate the correct URL with chained transformations including raw transformation.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"height": 300, "width": 400}, {"raw": "rndm_trnsf-abcd"}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path.jpg?tr=h-300,w-400:rndm_trnsf-abcd" + assert url == expected + + def test_should_generate_the_correct_url_when_border_transformation_is_applied(self) -> None: + """Should generate the correct URL when border transformation is applied.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"height": 300, "width": 400, "border": "20_FF0000"}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path.jpg?tr=h-300,w-400,b-20_FF0000" + assert url == expected + + def test_should_generate_the_correct_url_when_transformation_has_empty_key_and_value(self) -> None: + """Should generate the correct URL when transformation has empty key and value.""" + url = self.client.helper.build_url( + src="/test_path.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="query", + transformation=[{"raw": ""}], + ) + + expected = "https://ik.imagekit.io/test_url_endpoint/test_path.jpg" + assert url == expected + + def test_should_generate_a_valid_url_when_cname_is_used(self) -> None: + """Should generate a valid URL when CNAME is used.""" + url = self.client.helper.build_url( + src="/test_path.jpg", url_endpoint="https://custom.domain.com", transformation_position="query" + ) + + expected = "https://custom.domain.com/test_path.jpg" + assert url == expected + + def test_should_generate_a_valid_url_when_cname_with_path_is_used(self) -> None: + """Should generate a valid URL when CNAME with path is used.""" + url = self.client.helper.build_url( + src="/test_path.jpg", url_endpoint="https://custom.domain.com/url-pattern", transformation_position="query" + ) + + expected = "https://custom.domain.com/url-pattern/test_path.jpg" + assert url == expected diff --git a/tests/custom/url_generation/test_build_transformation_string.py b/tests/custom/url_generation/test_build_transformation_string.py new file mode 100644 index 00000000..447bd94c --- /dev/null +++ b/tests/custom/url_generation/test_build_transformation_string.py @@ -0,0 +1,76 @@ +"""Build transformation string tests imported from Ruby SDK.""" + +import pytest + +from imagekitio import ImageKit + + +class TestBuildTransformationString: + """Test build_transformation_string matching Ruby SDK build_transformation_string_test.rb.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Setup client for each test.""" + self.client = ImageKit(private_key="test-key") + + def test_should_return_empty_string_for_empty_transformation_array(self): + """Test empty transformation array returns empty string.""" + result = self.client.helper.build_transformation_string(None) + assert result == "" + + result = self.client.helper.build_transformation_string([]) + assert result == "" + + def test_should_generate_transformation_string_for_width_only(self): + """Test transformation string for width only.""" + result = self.client.helper.build_transformation_string([{"width": 300}]) + expected = "w-300" + assert result == expected + + def test_should_generate_transformation_string_for_multiple_parameters(self): + """Test transformation string for multiple parameters.""" + result = self.client.helper.build_transformation_string([{"width": 300, "height": 200}]) + expected = "w-300,h-200" + assert result == expected + + def test_should_generate_transformation_string_for_chained_transformations(self): + """Test transformation string for chained transformations.""" + result = self.client.helper.build_transformation_string([{"width": 300}, {"height": 200}]) + expected = "w-300:h-200" + assert result == expected + + def test_should_handle_empty_transformation_object(self): + """Test empty transformation object.""" + result = self.client.helper.build_transformation_string([{}]) + expected = "" + assert result == expected + + def test_should_handle_transformation_with_overlay(self): + """Test transformation with overlay.""" + result = self.client.helper.build_transformation_string([{"overlay": {"type": "text", "text": "Hello"}}]) + expected = "l-text,i-Hello,l-end" + assert result == expected + + def test_should_handle_raw_transformation_parameter(self): + """Test raw transformation parameter.""" + result = self.client.helper.build_transformation_string([{"raw": "custom-transform-123"}]) + expected = "custom-transform-123" + assert result == expected + + def test_should_handle_mixed_parameters_with_raw(self): + """Test mixed parameters with raw.""" + result = self.client.helper.build_transformation_string([{"width": 300, "raw": "custom-param-123"}]) + expected = "w-300,custom-param-123" + assert result == expected + + def test_should_handle_quality_parameter(self): + """Test quality parameter.""" + result = self.client.helper.build_transformation_string([{"quality": 80}]) + expected = "q-80" + assert result == expected + + def test_should_handle_aspect_ratio_parameter(self): + """Test aspect ratio parameter.""" + result = self.client.helper.build_transformation_string([{"aspect_ratio": "4:3"}]) + expected = "ar-4:3" + assert result == expected diff --git a/tests/custom/url_generation/test_overlay.py b/tests/custom/url_generation/test_overlay.py new file mode 100644 index 00000000..68d6200c --- /dev/null +++ b/tests/custom/url_generation/test_overlay.py @@ -0,0 +1,405 @@ +"""Overlay transformation tests imported from Ruby SDK.""" + +import pytest + +from imagekitio import ImageKit + + +class TestOverlay: + """Test overlay functionality matching Ruby SDK overlay_test.rb.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Setup client for each test.""" + self.client = ImageKit(private_key="My Private API Key") + + # Basic overlay tests + def test_should_ignore_overlay_when_type_property_is_missing(self): + """Test that overlay is ignored when type is missing.""" + url = self.client.helper.build_url( + src="/base-image.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"width": 300}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/tr:w-300/base-image.jpg" + assert url == expected + + def test_should_ignore_text_overlay_when_text_property_is_missing(self): + """Test that text overlay is ignored when text is empty.""" + url = self.client.helper.build_url( + src="/base-image.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"overlay": {"type": "text", "text": ""}}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/base-image.jpg" + assert url == expected + + def test_should_ignore_image_overlay_when_input_property_is_missing(self): + """Test that image overlay is ignored when input is empty.""" + url = self.client.helper.build_url( + src="/base-image.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"overlay": {"type": "image", "input": ""}}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/base-image.jpg" + assert url == expected + + def test_should_ignore_video_overlay_when_input_property_is_missing(self): + """Test that video overlay is ignored when input is empty.""" + url = self.client.helper.build_url( + src="/base-image.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"overlay": {"type": "video", "input": ""}}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/base-image.jpg" + assert url == expected + + def test_should_ignore_subtitle_overlay_when_input_property_is_missing(self): + """Test that subtitle overlay is ignored when input is empty.""" + url = self.client.helper.build_url( + src="/base-image.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"overlay": {"type": "subtitle", "input": ""}}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/base-image.jpg" + assert url == expected + + def test_should_ignore_solid_color_overlay_when_color_property_is_missing(self): + """Test that solid color overlay is ignored when color is empty.""" + url = self.client.helper.build_url( + src="/base-image.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"overlay": {"type": "solidColor", "color": ""}}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/base-image.jpg" + assert url == expected + + # Basic overlay functionality tests + def test_should_generate_url_with_text_overlay_using_url_encoding(self): + """Test text overlay with URL encoding.""" + url = self.client.helper.build_url( + src="/base-image.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"overlay": {"type": "text", "text": "Minimal Text"}}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/tr:l-text,i-Minimal%20Text,l-end/base-image.jpg" + assert url == expected + + def test_should_generate_url_with_image_overlay_from_input_file(self): + """Test image overlay from input file.""" + url = self.client.helper.build_url( + src="/base-image.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"overlay": {"type": "image", "input": "logo.png"}}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/tr:l-image,i-logo.png,l-end/base-image.jpg" + assert url == expected + + def test_should_generate_url_with_video_overlay_from_input_file(self): + """Test video overlay from input file.""" + url = self.client.helper.build_url( + src="/base-video.mp4", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"overlay": {"type": "video", "input": "play-pause-loop.mp4"}}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/tr:l-video,i-play-pause-loop.mp4,l-end/base-video.mp4" + assert url == expected + + def test_should_generate_url_with_subtitle_overlay_from_input_file(self): + """Test subtitle overlay from input file.""" + url = self.client.helper.build_url( + src="/base-video.mp4", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"overlay": {"type": "subtitle", "input": "subtitle.srt"}}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/tr:l-subtitle,i-subtitle.srt,l-end/base-video.mp4" + assert url == expected + + def test_should_generate_url_with_solid_color_overlay_using_background_color(self): + """Test solid color overlay.""" + url = self.client.helper.build_url( + src="/base-image.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[{"overlay": {"type": "solidColor", "color": "FF0000"}}], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/tr:l-image,i-ik_canvas,bg-FF0000,l-end/base-image.jpg" + assert url == expected + + def test_should_generate_url_with_multiple_complex_overlays_including_nested_transformations(self): + """Test complex overlays with nested transformations.""" + url = self.client.helper.build_url( + src="/base-image.jpg", + url_endpoint="https://ik.imagekit.io/test_url_endpoint", + transformation_position="path", + transformation=[ + # Text overlay + { + "overlay": { + "type": "text", + "text": "Every thing", + "position": {"x": "10", "y": "20", "focus": "center"}, + "timing": {"start": 5.0, "duration": "10", "end": 15.0}, + "transformation": [ + { + "width": "bw_mul_0.5", + "font_size": 20.0, + "font_family": "Arial", + "font_color": "0000ff", + "inner_alignment": "left", + "padding": 5.0, + "alpha": 7.0, + "typography": "b", + "background": "red", + "radius": 10.0, + "rotation": "N45", + "flip": "h", + "line_height": 20.0, + } + ], + } + }, + # Image overlay + { + "overlay": { + "type": "image", + "input": "logo.png", + "position": {"x": "10", "y": "20", "focus": "center"}, + "timing": {"start": 5.0, "duration": "10", "end": 15.0}, + "transformation": [ + { + "width": "bw_mul_0.5", + "height": "bh_mul_0.5", + "rotation": "N45", + "flip": "h", + "overlay": {"type": "text", "text": "Nested text overlay"}, + } + ], + } + }, + # Video overlay + { + "overlay": { + "type": "video", + "input": "play-pause-loop.mp4", + "position": {"x": "10", "y": "20", "focus": "center"}, + "timing": {"start": 5.0, "duration": "10", "end": 15.0}, + "transformation": [ + {"width": "bw_mul_0.5", "height": "bh_mul_0.5", "rotation": "N45", "flip": "h"} + ], + } + }, + # Subtitle overlay + { + "overlay": { + "type": "subtitle", + "input": "subtitle.srt", + "position": {"x": "10", "y": "20", "focus": "center"}, + "timing": {"start": 5.0, "duration": "10", "end": 15.0}, + "transformation": [ + { + "background": "red", + "color": "0000ff", + "font_family": "Arial", + "font_outline": "2_A1CCDD50", + "font_shadow": "A1CCDD_3", + } + ], + } + }, + # Solid color overlay + { + "overlay": { + "type": "solidColor", + "color": "FF0000", + "position": {"x": "10", "y": "20", "focus": "center"}, + "timing": {"start": 5.0, "duration": "10", "end": 15.0}, + "transformation": [ + { + "width": "bw_mul_0.5", + "height": "bh_mul_0.5", + "alpha": 0.5, + "background": "red", + "gradient": True, + "radius": "max", + } + ], + } + }, + ], + ) + expected = "https://ik.imagekit.io/test_url_endpoint/tr:l-text,i-Every%20thing,lx-10,ly-20,lfo-center,lso-5,leo-15,ldu-10,w-bw_mul_0.5,fs-20,ff-Arial,co-0000ff,ia-left,pa-5,al-7,tg-b,bg-red,r-10,rt-N45,fl-h,lh-20,l-end:l-image,i-logo.png,lx-10,ly-20,lfo-center,lso-5,leo-15,ldu-10,w-bw_mul_0.5,h-bh_mul_0.5,rt-N45,fl-h,l-text,i-Nested%20text%20overlay,l-end,l-end:l-video,i-play-pause-loop.mp4,lx-10,ly-20,lfo-center,lso-5,leo-15,ldu-10,w-bw_mul_0.5,h-bh_mul_0.5,rt-N45,fl-h,l-end:l-subtitle,i-subtitle.srt,lx-10,ly-20,lfo-center,lso-5,leo-15,ldu-10,bg-red,co-0000ff,ff-Arial,fol-2_A1CCDD50,fsh-A1CCDD_3,l-end:l-image,i-ik_canvas,bg-FF0000,lx-10,ly-20,lfo-center,lso-5,leo-15,ldu-10,w-bw_mul_0.5,h-bh_mul_0.5,al-0.5,bg-red,e-gradient,r-max,l-end/base-image.jpg" + assert url == expected + + # Overlay encoding tests + def test_should_use_plain_encoding_for_simple_image_paths_with_slashes_converted_to_double_at(self): + """Test plain encoding for simple image paths.""" + url = self.client.helper.build_url( + src="/medium_cafe_B1iTdD0C.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "image", "input": "/customer_logo/nykaa.png"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-image,i-customer_logo@@nykaa.png,l-end/medium_cafe_B1iTdD0C.jpg" + assert url == expected + + def test_should_use_base64_encoding_for_image_paths_containing_special_characters(self): + """Test base64 encoding for image paths with special characters.""" + url = self.client.helper.build_url( + src="/medium_cafe_B1iTdD0C.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "image", "input": "/customer_logo/Ñykaa.png"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-image,ie-Y3VzdG9tZXJfbG9nby%2FDkXlrYWEucG5n,l-end/medium_cafe_B1iTdD0C.jpg" + assert url == expected + + def test_should_use_plain_encoding_for_simple_text_overlays(self): + """Test plain encoding for simple text.""" + url = self.client.helper.build_url( + src="/sample.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "text", "text": "HelloWorld"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-text,i-HelloWorld,l-end/sample.jpg" + assert url == expected + + def test_should_convert_slashes_to_double_at_in_font_family_paths_for_custom_fonts(self): + """Test font family path conversion.""" + url = self.client.helper.build_url( + src="/medium_cafe_B1iTdD0C.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[ + { + "overlay": { + "type": "text", + "text": "Manu", + "transformation": [{"font_family": "nested-path/Poppins-Regular_Q15GrYWmL.ttf"}], + } + } + ], + ) + expected = "https://ik.imagekit.io/demo/tr:l-text,i-Manu,ff-nested-path@@Poppins-Regular_Q15GrYWmL.ttf,l-end/medium_cafe_B1iTdD0C.jpg" + assert url == expected + + def test_should_use_url_encoding_for_text_overlays_with_spaces_and_safe_characters(self): + """Test URL encoding for text with spaces.""" + url = self.client.helper.build_url( + src="/sample.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "text", "text": "Hello World"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-text,i-Hello%20World,l-end/sample.jpg" + assert url == expected + + def test_should_use_base64_encoding_for_text_overlays_with_special_unicode_characters(self): + """Test base64 encoding for Unicode text.""" + url = self.client.helper.build_url( + src="/sample.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "text", "text": "हिन्दी"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-text,ie-4KS54KS%2F4KSo4KWN4KSm4KWA,l-end/sample.jpg" + assert url == expected + + def test_should_use_plain_encoding_when_explicitly_specified_for_text_overlay(self): + """Test explicit plain encoding for text.""" + url = self.client.helper.build_url( + src="/sample.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "text", "text": "HelloWorld", "encoding": "plain"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-text,i-HelloWorld,l-end/sample.jpg" + assert url == expected + + def test_should_use_base64_encoding_when_explicitly_specified_for_text_overlay(self): + """Test explicit base64 encoding for text.""" + url = self.client.helper.build_url( + src="/sample.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "text", "text": "HelloWorld", "encoding": "base64"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-text,ie-SGVsbG9Xb3JsZA%3D%3D,l-end/sample.jpg" + assert url == expected + + def test_should_use_plain_encoding_when_explicitly_specified_for_image_overlay(self): + """Test explicit plain encoding for image.""" + url = self.client.helper.build_url( + src="/sample.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "image", "input": "/customer/logo.png", "encoding": "plain"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-image,i-customer@@logo.png,l-end/sample.jpg" + assert url == expected + + def test_should_use_base64_encoding_when_explicitly_specified_for_image_overlay(self): + """Test explicit base64 encoding for image.""" + url = self.client.helper.build_url( + src="/sample.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "image", "input": "/customer/logo.png", "encoding": "base64"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-image,ie-Y3VzdG9tZXIvbG9nby5wbmc%3D,l-end/sample.jpg" + assert url == expected + + def test_should_use_base64_encoding_when_explicitly_specified_for_video_overlay(self): + """Test explicit base64 encoding for video.""" + url = self.client.helper.build_url( + src="/sample.mp4", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "video", "input": "/path/to/video.mp4", "encoding": "base64"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-video,ie-cGF0aC90by92aWRlby5tcDQ%3D,l-end/sample.mp4" + assert url == expected + + def test_should_use_base64_encoding_when_explicitly_specified_for_subtitle_overlay(self): + """Test explicit base64 encoding for subtitle.""" + url = self.client.helper.build_url( + src="/sample.mp4", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "subtitle", "input": "sub.srt", "encoding": "base64"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-subtitle,ie-c3ViLnNydA%3D%3D,l-end/sample.mp4" + assert url == expected + + def test_should_use_plain_encoding_when_explicitly_specified_for_subtitle_overlay(self): + """Test explicit plain encoding for subtitle overlay.""" + url = self.client.helper.build_url( + src="/sample.mp4", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="path", + transformation=[{"overlay": {"type": "subtitle", "input": "/sub.srt", "encoding": "plain"}}], + ) + expected = "https://ik.imagekit.io/demo/tr:l-subtitle,i-sub.srt,l-end/sample.mp4" + assert url == expected + + def test_should_properly_encode_overlay_text_when_transformations_are_in_query_parameters(self): + """Test text overlay encoding with query position.""" + url = self.client.helper.build_url( + src="/sample.jpg", + url_endpoint="https://ik.imagekit.io/demo", + transformation_position="query", + transformation=[{"overlay": {"type": "text", "text": "Minimal Text"}}], + ) + expected = "https://ik.imagekit.io/demo/sample.jpg?tr=l-text,i-Minimal%20Text,l-end" + assert url == expected diff --git a/tests/custom/url_generation/test_signing.py b/tests/custom/url_generation/test_signing.py new file mode 100644 index 00000000..8df8b6d3 --- /dev/null +++ b/tests/custom/url_generation/test_signing.py @@ -0,0 +1,168 @@ +"""Signing URL tests - converted from Ruby SDK.""" + +from typing import TYPE_CHECKING + +import pytest + +from imagekitio import ImageKit + +if TYPE_CHECKING: + from imagekitio._client import ImageKit as ImageKitType + + +class TestSigning: + """Test URL signing functionality.""" + + client: "ImageKitType" + + @pytest.fixture(autouse=True) + def setup(self) -> None: + """Set up test client.""" + self.client = ImageKit(private_key="dummy-key") + + def test_should_generate_a_signed_url_when_signed_is_true_without_expires_in(self) -> None: + """Should generate a signed URL when signed is true without expires_in.""" + url = self.client.helper.build_url( + src="sdk-testing-files/future-search.png", url_endpoint="https://ik.imagekit.io/demo/", signed=True + ) + + expected = "https://ik.imagekit.io/demo/sdk-testing-files/future-search.png?ik-s=32dbbbfc5f945c0403c71b54c38e76896ef2d6b0" + assert url == expected + + def test_should_generate_a_signed_url_when_signed_is_true_with_expires_in(self) -> None: + """Should generate a signed URL when signed is true with expires_in.""" + url = self.client.helper.build_url( + src="sdk-testing-files/future-search.png", + url_endpoint="https://ik.imagekit.io/demo/", + signed=True, + expires_in=3600, + ) + + # Expect ik-t exist in the URL. We don't assert signature because it will keep changing. + assert "ik-t" in url + + def test_should_generate_a_signed_url_when_expires_in_is_above_0_and_even_if_signed_is_false(self) -> None: + """Should generate a signed URL when expires_in is above 0 and even if signed is false.""" + url = self.client.helper.build_url( + src="sdk-testing-files/future-search.png", + url_endpoint="https://ik.imagekit.io/demo/", + signed=False, + expires_in=3600, + ) + + # Expect ik-t exist in the URL. We don't assert signature because it will keep changing. + assert "ik-t" in url + + def test_should_generate_signed_url_with_special_characters_in_filename(self) -> None: + """Should generate signed URL with special characters in filename.""" + url = self.client.helper.build_url( + src="sdk-testing-files/हिन्दी.png", url_endpoint="https://ik.imagekit.io/demo/", signed=True + ) + + expected = "https://ik.imagekit.io/demo/sdk-testing-files/%E0%A4%B9%E0%A4%BF%E0%A4%A8%E0%A5%8D%E0%A4%A6%E0%A5%80.png?ik-s=3fff2f31da1f45e007adcdbe95f88c8c330e743c" + assert url == expected + + def test_should_generate_signed_url_with_text_overlay_containing_special_characters(self) -> None: + """Should generate signed URL with text overlay containing special characters.""" + url = self.client.helper.build_url( + src="sdk-testing-files/हिन्दी.png", + url_endpoint="https://ik.imagekit.io/demo/", + transformation=[ + { + "overlay": { + "type": "text", + "text": "हिन्दी", + "transformation": [ + { + "font_color": "red", + "font_size": "32", + "font_family": "sdk-testing-files/Poppins-Regular_Q15GrYWmL.ttf", + } + ], + } + } + ], + signed=True, + ) + + expected = "https://ik.imagekit.io/demo/sdk-testing-files/%E0%A4%B9%E0%A4%BF%E0%A4%A8%E0%A5%8D%E0%A4%A6%E0%A5%80.png?tr=l-text,ie-4KS54KS%2F4KSo4KWN4KSm4KWA,co-red,fs-32,ff-sdk-testing-files@@Poppins-Regular_Q15GrYWmL.ttf,l-end&ik-s=ac9f24a03080102555e492185533c1ae6bd93fa7" + assert url == expected + + def test_should_generate_signed_url_with_text_overlay_and_special_characters_using_path_transformation_position( + self, + ) -> None: + """Should generate signed URL with text overlay and special characters using path transformation position.""" + url = self.client.helper.build_url( + src="sdk-testing-files/हिन्दी.png", + url_endpoint="https://ik.imagekit.io/demo/", + transformation_position="path", + transformation=[ + { + "overlay": { + "type": "text", + "text": "हिन्दी", + "transformation": [ + { + "font_color": "red", + "font_size": "32", + "font_family": "sdk-testing-files/Poppins-Regular_Q15GrYWmL.ttf", + } + ], + } + } + ], + signed=True, + ) + + expected = "https://ik.imagekit.io/demo/tr:l-text,ie-4KS54KS%2F4KSo4KWN4KSm4KWA,co-red,fs-32,ff-sdk-testing-files@@Poppins-Regular_Q15GrYWmL.ttf,l-end/sdk-testing-files/%E0%A4%B9%E0%A4%BF%E0%A4%A8%E0%A5%8D%E0%A4%A6%E0%A5%80.png?ik-s=69f2ecbb7364bbbad24616e1f7f1bac5a560fc71" + assert url == expected + + def test_should_generate_signed_url_with_query_parameters(self) -> None: + """Should generate signed URL with query parameters.""" + url = self.client.helper.build_url( + src="sdk-testing-files/future-search.png", + url_endpoint="https://ik.imagekit.io/demo/", + query_parameters={"version": "1.0", "cache": "false"}, + signed=True, + ) + + expected = "https://ik.imagekit.io/demo/sdk-testing-files/future-search.png?version=1.0&cache=false&ik-s=f2e5a1b8b6a0b03fd63789dfc6413a94acef9fd8" + assert url == expected + + def test_should_generate_signed_url_with_transformations_and_query_parameters(self) -> None: + """Should generate signed URL with transformations and query parameters.""" + url = self.client.helper.build_url( + src="sdk-testing-files/future-search.png", + url_endpoint="https://ik.imagekit.io/demo/", + transformation=[{"width": 300, "height": 200}], + query_parameters={"version": "2.0"}, + signed=True, + ) + + expected = "https://ik.imagekit.io/demo/sdk-testing-files/future-search.png?version=2.0&tr=w-300,h-200&ik-s=601d97a7834b7554f4dabf0d3fc3a219ceeb6b31" + assert url == expected + + def test_should_not_sign_url_when_signed_is_false(self) -> None: + """Should not sign URL when signed is false.""" + url = self.client.helper.build_url( + src="sdk-testing-files/future-search.png", url_endpoint="https://ik.imagekit.io/demo/", signed=False + ) + + expected = "https://ik.imagekit.io/demo/sdk-testing-files/future-search.png" + assert url == expected + assert "ik-s=" not in url + assert "ik-t=" not in url + + def test_should_generate_signed_url_with_transformations_in_path_position_and_query_parameters(self) -> None: + """Should generate signed URL with transformations in path position and query parameters.""" + url = self.client.helper.build_url( + src="sdk-testing-files/future-search.png", + url_endpoint="https://ik.imagekit.io/demo/", + transformation=[{"width": 300, "height": 200}], + transformation_position="path", + query_parameters={"version": "2.0"}, + signed=True, + ) + + expected = "https://ik.imagekit.io/demo/tr:w-300,h-200/sdk-testing-files/future-search.png?version=2.0&ik-s=dd1ee8f83d019bc59fd57a5fc4674a11eb8a3496" + assert url == expected diff --git a/tests/dummy_data/__init__.py b/tests/dummy_data/__init__.py deleted file mode 100644 index ab7fb752..00000000 --- a/tests/dummy_data/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import file - -__all__ = ["file"] diff --git a/tests/dummy_data/file.py b/tests/dummy_data/file.py deleted file mode 100644 index 67be48ed..00000000 --- a/tests/dummy_data/file.py +++ /dev/null @@ -1,60 +0,0 @@ -FAILED_GENERIC_RESP = {"message": "Hi There is an error"} -SUCCESS_GENERIC_RESP = {"response": "Success"} -AUTHENTICATION_ERR_MSG = { - "message": "Your account cannot be authenticated.", - "help": "For support kindly contact us at support@imagekit.io .", -} -FAILED_DELETE_RESP = {"message": "Item Not Found"} - -SUCCESS_PURGE_CACHE_MSG = {"request_id": "fake_abc_xyz"} - -SUCCESS_PURGE_CACHE_STATUS_MSG = {"status": "pending"} - -SERVER_ERR_MSG = { - "message": "We have experienced an internal error while processing your request.", - "help": "For support kindly contact us at support@imagekit.io .", -} - -SUCCESS_LIST_RESP_MESSAGE = { - "response": [ - { - "type": "file", - "name": "default-image.jpg", - "fileId": "53dgd6023f28ft7fse488992c", - "tags": None, - "customCoordinates": None, - "isPrivateFile": None, - "url": "https://ik.imagekit.io/fakeid/default-image.jpg", - "thumbnail": "https://ik.imagekit.io/fakeid/tr:n-media_library_thumbnail/default-image.jpg", - "fileType": "image", - "filePath": "/default-image.jpg", - }, - { - "type": "file", - "name": "default-image.jpg", - "fileId": "53dgd6023f28ft7fse488992c", - "tags": None, - "customCoordinates": None, - "isPrivateFile": None, - "url": "https://ik.imagekit.io/fakeid/default-image.jpg", - "thumbnail": "https://ik.imagekit.io/fakeid/tr:n-media_library_thumbnail/default-image.jpg", - "fileType": "image", - "filePath": "/default-image.jpg", - }, - ], -} - -SUCCESS_DETAIL_MSG = { - "response": { - "type": "file", - "name": "default-image.jpg", - "fileId": "53dgd6023f28ft7fse488992c", - "tags": None, - "customCoordinates": None, - "isPrivateFile": None, - "url": "https://ik.imagekit.io/fakeid/default-image.jpg", - "thumbnail": "https://ik.imagekit.io/fakeid/tr:n-media_library_thumbnail/default-image.jpg", - "fileType": "image", - "filePath": "/default-image.jpg", - } -} diff --git a/tests/dummy_data/image.png b/tests/dummy_data/image.png deleted file mode 100644 index 4db4e065e2cab8564b5157822d558a7adb1e9280..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1128 zcmeAS@N?(olHy`uVBq!ia0y~yV2WU1V4T3h3>0aQW?l`Xqyv0HT+f_217yyeIkUdL zJ}oWH*w}c}rcIKPl0H5@Gwv;)%fP_G;pyTSQZeW4E&u%6W)kcPmK$Z|51Z6i`B<&l ztru(W(8POVtK5dSv)Gu6)^?jcuWGM-*D3O{BkTdoZQsgy-Cv4j7;BsZRi-Xu1X@Ho z_%VOq@2Af{pMAOe-}Co!zklgfzc-(Li|g@*{XB|u%AW7vwmkk6|Edj_U4LAd>l3ax zC)TvTrSL}n?2xWfasR{eXARz%#6L|sdwHv0UR$EdwYT&Ce-G6PyS?X|$sE(>4YwUn z9L;+D`|EiDJKn<{zUBI11HS7UJ#V`1 z$WQ9GkZqZ}Oi?^rf6m<{dX<}Hk1fm&+Q#?%<>Kqj8!o5rc-OU+!~J8y#HwV`V~N+^ zTRv2gotECb!8`iAXZV_$8A}USaXl^&Txa&Y`k{sF`sDem4*8Wo4A%46cf;iCO0Ay5 zyRH{{?G7?tec>j5?89$PrhK~R^NKdEJAJ-?`(ceUznw2B-Jc}?*dVwyXYR2-Wy?OS zdinoa;LfMlVg+}1hb4-9OZvaF_I7Keh0pOTU$jh~a%aj!f6u#XvFCrpzAZd=-Ct&h zO0;HL{LPLOQ)REz@jI@S@>$^YmUvTpTfO3qVL#^qMS2b?$t-vEwf$ZsaduH3OzqAZ* zTOJv{ChkT=TYh&|#r&o1X`0tOe_jJB`IGQ^8}GcpHM>8&+9Y*v{q2W;mx^R>x~XST z#likJDRg_{oTSjhzw6AqZ)siA%4rYOP@Q?q&EEFF@p&PM2Qb`Lop3m-^1hUgEfFMoaJEi~e5bhs$#=DYfsp^Rkuw zZSIYDiR(wB%D-x!o*O68x{1lhw<=5V?vl6b|FPW&+vv7$!oBm8mt9$so^$a|>+jOq zcs2c$Zng89cdJePyRU>fbYVo>=Vtd^r@lSA|FBG=eNS`2bhcmW+owL}NshDUpM3K4 zjj(?=OfzTBE4;h=QuC!#aVPX1r%%7?qdjwqxY$bbbwx9)6J?eu{t=FkSlV{>kZ0BJ zuZaRypG|h0Z=2j`v_GQlzM1&y!xlQuKX#qE{OMEN*E9jESo7KZ_f_rI%(_&4^>?B~ zyKB|E=l1pAkNx{Mal#YEcZ+V7cX(~wW&UmFzEFwI_sMI|72o*TuXs7;a^{V?!d)|; v_5YQ3o^|`X{oS0VdP^qK^B@Dm1N)!iTrak8KiBP?1Jdv5>gTe~DWM4f+us80 diff --git a/tests/dummy_data/urls.py b/tests/dummy_data/urls.py deleted file mode 100644 index 2944b7ee..00000000 --- a/tests/dummy_data/urls.py +++ /dev/null @@ -1,4 +0,0 @@ -URL_ENDPOINT = "https://ik.imagekit.io/your_imagekit_id/endpoint/" -BASIC_GENERATED_URL = ( - "https://ik.imagekit.io/your_imagekit_id/endpoint/tr:h-300,w-400/default-image.jpg", -) diff --git a/tests/helpers.py b/tests/helpers.py deleted file mode 100644 index 62fb542f..00000000 --- a/tests/helpers.py +++ /dev/null @@ -1,63 +0,0 @@ -import base64 -import json -import re -import unittest -from unittest.mock import patch - -from imagekitio.client import ImageKit -from imagekitio.models.ListAndSearchFileRequestOptions import ( - ListAndSearchFileRequestOptions, -) - - -class ClientTestCase(unittest.TestCase): - """ - Base TestCase for Client - """ - - private_key = "fake122" - - @patch("imagekitio.file.File") - @patch("imagekitio.resource.ImageKitRequest") - def setUp(self, mock_file, mock_req): - """ - Tests if list_files work with skip and limit - """ - self.options = ListAndSearchFileRequestOptions( - type="file", - sort="ASC_CREATED", - path="/", - search_query="created_at >= '2d' OR size < '2mb' OR format='png'", - file_type="all", - limit=1, - skip=0, - tags="Tag-1, Tag-2, Tag-3", - ) - self.opt = ListAndSearchFileRequestOptions( - type="file", - sort="ASC_CREATED", - path="/", - search_query="created_at >= '2d' OR size < '2mb' OR format='png'", - file_type="all", - limit=1, - skip=0, - tags=["Tag-1", "Tag-2", "Tag-3"], - ) - self.client = ImageKit( - public_key="fake122", - private_key=ClientTestCase.private_key, - url_endpoint="fake122", - ) - - -def create_headers_for_test(): - headers = {"Accept-Encoding": "gzip, deflate"} - headers.update(get_auth_headers_for_test()) - return headers - - -def get_auth_headers_for_test(): - encoded_private_key = base64.b64encode( - (ClientTestCase.private_key + ":").encode() - ).decode("utf-8") - return {"Authorization": "Basic {}".format(encoded_private_key)} diff --git a/tests/sample.jpg b/tests/sample.jpg deleted file mode 100644 index 1db39e04b4ccf009cf0d4503a684ca75e8e2c3d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102117 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 YTje32U?tACE0QPnjkMh1(toS}12^vyF8}}l diff --git a/tests/sample_file.txt b/tests/sample_file.txt new file mode 100644 index 00000000..af5626b4 --- /dev/null +++ b/tests/sample_file.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/tests/test_client.py b/tests/test_client.py index 674bfaee..73532a86 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,51 +1,1886 @@ -import unittest +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from imagekitio.client import ImageKit -from tests.helpers import ClientTestCase +from __future__ import annotations -imagekit_obj = ImageKit( - private_key="private_fake:", - public_key="public_fake123:", - url_endpoint="fake.com", +import gc +import os +import sys +import json +import asyncio +import inspect +import tracemalloc +from typing import Any, Union, cast +from unittest import mock +from typing_extensions import Literal + +import httpx +import pytest +from respx import MockRouter +from pydantic import ValidationError + +from imagekitio import ImageKit, AsyncImageKit, APIResponseValidationError +from imagekitio._types import Omit +from imagekitio._utils import asyncify +from imagekitio._models import BaseModel, FinalRequestOptions +from imagekitio._exceptions import ImageKitError, APIStatusError, APITimeoutError, APIResponseValidationError +from imagekitio._base_client import ( + DEFAULT_TIMEOUT, + HTTPX_DEFAULT_TIMEOUT, + BaseClient, + OtherPlatform, + DefaultHttpxClient, + DefaultAsyncHttpxClient, + get_platform, + make_request_options, ) +from .utils import update_env + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +private_key = "My Private Key" +password = "My Password" + + +def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + return dict(url.params) + + +def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: + return 0.1 + + +def _get_open_connections(client: ImageKit | AsyncImageKit) -> int: + transport = client._client._transport + assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) + + pool = transport._pool + return len(pool._requests) + + +class TestImageKit: + @pytest.mark.respx(base_url=base_url) + def test_raw_response(self, respx_mock: MockRouter, client: ImageKit) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + @pytest.mark.respx(base_url=base_url) + def test_raw_response_for_binary(self, respx_mock: MockRouter, client: ImageKit) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + response = client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + def test_copy(self, client: ImageKit) -> None: + copied = client.copy() + assert id(copied) != id(client) + + copied = client.copy(private_key="another My Private Key") + assert copied.private_key == "another My Private Key" + assert client.private_key == "My Private Key" + + copied = client.copy(password="another My Password") + assert copied.password == "another My Password" + assert client.password == "My Password" + + def test_copy_default_options(self, client: ImageKit) -> None: + # options that have a default are overridden correctly + copied = client.copy(max_retries=7) + assert copied.max_retries == 7 + assert client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(client.timeout, httpx.Timeout) + copied = client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + default_headers={"X-Foo": "bar"}, + ) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + client.close() + + def test_copy_default_query(self) -> None: + client = ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + default_query={"foo": "bar"}, + ) + assert _get_params(client)["foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert _get_params(copied)["foo"] == "bar" + + # merges already given params + copied = client.copy(default_query={"bar": "stainless"}) + params = _get_params(copied) + assert params["foo"] == "bar" + assert params["bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_query={"foo": "stainless"}) + assert _get_params(copied)["foo"] == "stainless" + + # set_default_query + + # completely overrides already set values + copied = client.copy(set_default_query={}) + assert _get_params(copied) == {} + + copied = client.copy(set_default_query={"bar": "Robert"}) + assert _get_params(copied)["bar"] == "Robert" + + with pytest.raises( + ValueError, + # TODO: update + match="`default_query` and `set_default_query` arguments are mutually exclusive", + ): + client.copy(set_default_query={}, default_query={"foo": "Bar"}) + + client.close() + + def test_copy_signature(self, client: ImageKit) -> None: + # ensure the same parameters that can be passed to the client are defined in the `.copy()` method + init_signature = inspect.signature( + # mypy doesn't like that we access the `__init__` property. + client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(client.copy) + exclude_params = {"transport", "proxies", "_strict_response_validation"} + + for name in init_signature.parameters.keys(): + if name in exclude_params: + continue + + copy_param = copy_signature.parameters.get(name) + assert copy_param is not None, f"copy() signature is missing the {name} param" + + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") + def test_copy_build_request(self, client: ImageKit) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client_copy = client.copy() + client_copy._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + + gc.collect() + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "imagekitio/_legacy_response.py", + "imagekitio/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "imagekitio/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + + def test_request_timeout(self, client: ImageKit) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + def test_client_timeout_option(self) -> None: + client = ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + timeout=httpx.Timeout(0), + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + client.close() + + def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + with httpx.Client(timeout=None) as http_client: + client = ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=http_client, + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + client.close() + + # no timeout given to the httpx client should not use the httpx default + with httpx.Client() as http_client: + client = ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=http_client, + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + client.close() + + # explicitly passing the default timeout currently results in it being ignored + with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=http_client, + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + + client.close() + + async def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + async with httpx.AsyncClient() as http_client: + ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + + def test_default_headers_option(self) -> None: + test_client = ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + default_headers={"X-Foo": "bar"}, + ) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "bar" + assert request.headers.get("x-stainless-lang") == "python" + + test_client2 = ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "stainless" + assert request.headers.get("x-stainless-lang") == "my-overriding-header" + + test_client.close() + test_client2.close() + + def test_validate_headers(self) -> None: + client = ImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=True + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert "Basic" in request.headers.get("Authorization") + + with pytest.raises(ImageKitError): + with update_env( + **{ + "IMAGEKIT_PRIVATE_KEY": Omit(), + "OPTIONAL_IMAGEKIT_IGNORES_THIS": Omit(), + } + ): + client2 = ImageKit(base_url=base_url, private_key=None, password=None, _strict_response_validation=True) + _ = client2 + + def test_default_query_option(self) -> None: + client = ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + default_query={"query_param": "bar"}, + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + assert dict(url.params) == {"query_param": "bar"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo", + params={"foo": "baz", "query_param": "overridden"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} + + client.close() + + def test_request_extra_json(self, client: ImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": False} + + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"baz": False} + + # `extra_json` takes priority over `json_data` when keys clash + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar", "baz": True}, + extra_json={"baz": None}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": None} + + def test_request_extra_headers(self, client: ImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options(extra_headers={"X-Foo": "Foo"}), + ), + ) + assert request.headers.get("X-Foo") == "Foo" + + # `extra_headers` takes priority over `default_headers` when keys clash + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_headers={"X-Bar": "false"}, + ), + ), + ) + assert request.headers.get("X-Bar") == "false" + + def test_request_extra_query(self, client: ImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_query={"my_query_param": "Foo"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"my_query_param": "Foo"} + + # if both `query` and `extra_query` are given, they are merged + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"bar": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"bar": "1", "foo": "2"} + + # `extra_query` takes priority over `query` when keys clash + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"foo": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"foo": "2"} + + def test_multipart_repeating_array(self, client: ImageKit) -> None: + request = client._build_request( + FinalRequestOptions.construct( + method="post", + url="/foo", + headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, + json_data={"array": ["foo", "bar"]}, + files=[("foo.txt", b"hello world")], + ) + ) + + assert request.read().split(b"\r\n") == [ + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"foo", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"bar", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', + b"Content-Type: application/octet-stream", + b"", + b"hello world", + b"--6b7ba517decee4a450543ea6ae821c82--", + b"", + ] + + @pytest.mark.respx(base_url=base_url) + def test_basic_union_response(self, respx_mock: MockRouter, client: ImageKit) -> None: + class Model1(BaseModel): + name: str + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + @pytest.mark.respx(base_url=base_url) + def test_union_response_different_types(self, respx_mock: MockRouter, client: ImageKit) -> None: + """Union of objects with the same field name using a different type""" -class TestPHashDistance(unittest.TestCase): - def test_phash_distance(self): - """Tests if phash_distance working properly""" - a, b = ("33699c96619cc69e", "968e978414fe04ea") - c, d = ("33699c96619cc69e", "33699c96619cc69e") - e, f = ("a4a65595ac94518b", "7838873e791f8400") + class Model1(BaseModel): + foo: int - self.assertEqual(imagekit_obj.phash_distance(a, b), 30) - self.assertEqual(imagekit_obj.phash_distance(c, d), 0) - self.assertEqual(imagekit_obj.phash_distance(e, f), 37) - self.assertRaises(TypeError, imagekit_obj.phash_distance, "", "dkf90") - self.assertRaises(TypeError, imagekit_obj.phash_distance, 1234, 111) + class Model2(BaseModel): + foo: str + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) -class TestClientAndImageKitObjInit(ClientTestCase): - """ - Tests client and Imagekit classes object initialization - """ + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" - def test_all_variable_is_being_set_to_obj(self) -> None: + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) + + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model1) + assert response.foo == 1 + + @pytest.mark.respx(base_url=base_url) + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter, client: ImageKit) -> None: """ - Tests if variables are properly being set when creating - an object from ImageKit class + Response that sets Content-Type to something other than application/json but returns json data """ - self.assertIsNotNone(self.client.ik_request) - self.assertIsNotNone(self.client.url_obj) - self.assertIsNotNone(self.client.file) + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) -class TestGetAuthenticationParameters(ClientTestCase): - def test_get_authentication_parameters_without_token(self) -> None: - result = self.client.get_authentication_parameters("", expire=444) - self.assertIsNotNone(result) + response = client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 - def test_get_authentication_param_with_token(self) -> None: - result = self.client.get_authentication_parameters( - "dc45da6e3286066265a09e", expire=4555 + def test_base_url_setter(self) -> None: + client = ImageKit( + base_url="https://example.com/from_init", + private_key=private_key, + password=password, + _strict_response_validation=True, ) - self.assertIsNotNone(result) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + + client.close() + + def test_base_url_env(self) -> None: + with update_env(IMAGE_KIT_BASE_URL="http://localhost:5000/from/env"): + client = ImageKit(private_key=private_key, password=password, _strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + @pytest.mark.parametrize( + "client", + [ + ImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + ), + ImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: ImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + client.close() + + @pytest.mark.parametrize( + "client", + [ + ImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + ), + ImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: ImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + client.close() + + @pytest.mark.parametrize( + "client", + [ + ImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + ), + ImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: ImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + client.close() + + def test_copied_client_does_not_close_http(self) -> None: + test_client = ImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=True + ) + assert not test_client.is_closed() + + copied = test_client.copy() + assert copied is not test_client + + del copied + + assert not test_client.is_closed() + + def test_client_context_manager(self) -> None: + test_client = ImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=True + ) + with test_client as c2: + assert c2 is test_client + assert not c2.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() + + @pytest.mark.respx(base_url=base_url) + def test_client_response_validation_error(self, respx_mock: MockRouter, client: ImageKit) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + + def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + ImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + max_retries=cast(Any, None), + ) + + @pytest.mark.respx(base_url=base_url) + def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = ImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=True + ) + + with pytest.raises(APIResponseValidationError): + strict_client.get("/foo", cast_to=Model) + + non_strict_client = ImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=False + ) + + response = non_strict_client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + + strict_client.close() + non_strict_client.close() + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 0.5], + [3, "-10", 0.5], + [3, "60", 60], + [3, "61", 0.5], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "", 0.5], + [2, "", 0.5 * 2.0], + [1, "", 0.5 * 4.0], + [-1100, "", 8], # test large number potentially overflowing + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, client: ImageKit + ) -> None: + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("imagekitio._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: ImageKit) -> None: + respx_mock.post("/api/v1/files/upload").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + client.files.with_streaming_response.upload(file=b"raw file contents", file_name="fileName").__enter__() + + assert _get_open_connections(client) == 0 + + @mock.patch("imagekitio._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: ImageKit) -> None: + respx_mock.post("/api/v1/files/upload").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + client.files.with_streaming_response.upload(file=b"raw file contents", file_name="fileName").__enter__() + assert _get_open_connections(client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("imagekitio._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + def test_retries_taken( + self, + client: ImageKit, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.post("/api/v1/files/upload").mock(side_effect=retry_handler) + + response = client.files.with_raw_response.upload(file=b"raw file contents", file_name="fileName") + + assert response.retries_taken == failures_before_success + assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("imagekitio._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_omit_retry_count_header( + self, client: ImageKit, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.post("/api/v1/files/upload").mock(side_effect=retry_handler) + + response = client.files.with_raw_response.upload( + file=b"raw file contents", file_name="fileName", extra_headers={"x-stainless-retry-count": Omit()} + ) + + assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("imagekitio._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_overwrite_retry_count_header( + self, client: ImageKit, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.post("/api/v1/files/upload").mock(side_effect=retry_handler) + + response = client.files.with_raw_response.upload( + file=b"raw file contents", file_name="fileName", extra_headers={"x-stainless-retry-count": "42"} + ) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter, client: ImageKit) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: ImageKit) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + + +class TestAsyncImageKit: + @pytest.mark.respx(base_url=base_url) + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncImageKit) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await async_client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncImageKit) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + response = await async_client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + def test_copy(self, async_client: AsyncImageKit) -> None: + copied = async_client.copy() + assert id(copied) != id(async_client) + + copied = async_client.copy(private_key="another My Private Key") + assert copied.private_key == "another My Private Key" + assert async_client.private_key == "My Private Key" + + copied = async_client.copy(password="another My Password") + assert copied.password == "another My Password" + assert async_client.password == "My Password" + + def test_copy_default_options(self, async_client: AsyncImageKit) -> None: + # options that have a default are overridden correctly + copied = async_client.copy(max_retries=7) + assert copied.max_retries == 7 + assert async_client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(async_client.timeout, httpx.Timeout) + copied = async_client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(async_client.timeout, httpx.Timeout) + + async def test_copy_default_headers(self) -> None: + client = AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + default_headers={"X-Foo": "bar"}, + ) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + await client.close() + + async def test_copy_default_query(self) -> None: + client = AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + default_query={"foo": "bar"}, + ) + assert _get_params(client)["foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert _get_params(copied)["foo"] == "bar" + + # merges already given params + copied = client.copy(default_query={"bar": "stainless"}) + params = _get_params(copied) + assert params["foo"] == "bar" + assert params["bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_query={"foo": "stainless"}) + assert _get_params(copied)["foo"] == "stainless" + + # set_default_query + + # completely overrides already set values + copied = client.copy(set_default_query={}) + assert _get_params(copied) == {} + + copied = client.copy(set_default_query={"bar": "Robert"}) + assert _get_params(copied)["bar"] == "Robert" + + with pytest.raises( + ValueError, + # TODO: update + match="`default_query` and `set_default_query` arguments are mutually exclusive", + ): + client.copy(set_default_query={}, default_query={"foo": "Bar"}) + + await client.close() + + def test_copy_signature(self, async_client: AsyncImageKit) -> None: + # ensure the same parameters that can be passed to the client are defined in the `.copy()` method + init_signature = inspect.signature( + # mypy doesn't like that we access the `__init__` property. + async_client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(async_client.copy) + exclude_params = {"transport", "proxies", "_strict_response_validation"} + + for name in init_signature.parameters.keys(): + if name in exclude_params: + continue + + copy_param = copy_signature.parameters.get(name) + assert copy_param is not None, f"copy() signature is missing the {name} param" + + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") + def test_copy_build_request(self, async_client: AsyncImageKit) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client_copy = async_client.copy() + client_copy._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + + gc.collect() + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "imagekitio/_legacy_response.py", + "imagekitio/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "imagekitio/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + + async def test_request_timeout(self, async_client: AsyncImageKit) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = async_client._build_request( + FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) + ) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + async def test_client_timeout_option(self) -> None: + client = AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + timeout=httpx.Timeout(0), + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + await client.close() + + async def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + async with httpx.AsyncClient(timeout=None) as http_client: + client = AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=http_client, + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + await client.close() + + # no timeout given to the httpx client should not use the httpx default + async with httpx.AsyncClient() as http_client: + client = AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=http_client, + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + await client.close() + + # explicitly passing the default timeout currently results in it being ignored + async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=http_client, + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + + await client.close() + + def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + with httpx.Client() as http_client: + AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + + async def test_default_headers_option(self) -> None: + test_client = AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + default_headers={"X-Foo": "bar"}, + ) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "bar" + assert request.headers.get("x-stainless-lang") == "python" + + test_client2 = AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "stainless" + assert request.headers.get("x-stainless-lang") == "my-overriding-header" + + await test_client.close() + await test_client2.close() + + def test_validate_headers(self) -> None: + client = AsyncImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=True + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert "Basic" in request.headers.get("Authorization") + + with pytest.raises(ImageKitError): + with update_env( + **{ + "IMAGEKIT_PRIVATE_KEY": Omit(), + "OPTIONAL_IMAGEKIT_IGNORES_THIS": Omit(), + } + ): + client2 = AsyncImageKit( + base_url=base_url, private_key=None, password=None, _strict_response_validation=True + ) + _ = client2 + + async def test_default_query_option(self) -> None: + client = AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + default_query={"query_param": "bar"}, + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + assert dict(url.params) == {"query_param": "bar"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo", + params={"foo": "baz", "query_param": "overridden"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} + + await client.close() + + def test_request_extra_json(self, client: ImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": False} + + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"baz": False} + + # `extra_json` takes priority over `json_data` when keys clash + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar", "baz": True}, + extra_json={"baz": None}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": None} + + def test_request_extra_headers(self, client: ImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options(extra_headers={"X-Foo": "Foo"}), + ), + ) + assert request.headers.get("X-Foo") == "Foo" + + # `extra_headers` takes priority over `default_headers` when keys clash + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_headers={"X-Bar": "false"}, + ), + ), + ) + assert request.headers.get("X-Bar") == "false" + + def test_request_extra_query(self, client: ImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_query={"my_query_param": "Foo"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"my_query_param": "Foo"} + + # if both `query` and `extra_query` are given, they are merged + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"bar": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"bar": "1", "foo": "2"} + + # `extra_query` takes priority over `query` when keys clash + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"foo": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"foo": "2"} + + def test_multipart_repeating_array(self, async_client: AsyncImageKit) -> None: + request = async_client._build_request( + FinalRequestOptions.construct( + method="post", + url="/foo", + headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, + json_data={"array": ["foo", "bar"]}, + files=[("foo.txt", b"hello world")], + ) + ) + + assert request.read().split(b"\r\n") == [ + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"foo", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"bar", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', + b"Content-Type: application/octet-stream", + b"", + b"hello world", + b"--6b7ba517decee4a450543ea6ae821c82--", + b"", + ] + + @pytest.mark.respx(base_url=base_url) + async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncImageKit) -> None: + class Model1(BaseModel): + name: str + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + @pytest.mark.respx(base_url=base_url) + async def test_union_response_different_types(self, respx_mock: MockRouter, async_client: AsyncImageKit) -> None: + """Union of objects with the same field name using a different type""" + + class Model1(BaseModel): + foo: int + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) + + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model1) + assert response.foo == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_non_application_json_content_type_for_json_data( + self, respx_mock: MockRouter, async_client: AsyncImageKit + ) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = await async_client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + async def test_base_url_setter(self) -> None: + client = AsyncImageKit( + base_url="https://example.com/from_init", + private_key=private_key, + password=password, + _strict_response_validation=True, + ) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + + await client.close() + + async def test_base_url_env(self) -> None: + with update_env(IMAGE_KIT_BASE_URL="http://localhost:5000/from/env"): + client = AsyncImageKit(private_key=private_key, password=password, _strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + @pytest.mark.parametrize( + "client", + [ + AsyncImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + ), + AsyncImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + async def test_base_url_trailing_slash(self, client: AsyncImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() + + @pytest.mark.parametrize( + "client", + [ + AsyncImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + ), + AsyncImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + async def test_base_url_no_trailing_slash(self, client: AsyncImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() + + @pytest.mark.parametrize( + "client", + [ + AsyncImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + ), + AsyncImageKit( + base_url="http://localhost:5000/custom/path/", + private_key=private_key, + password=password, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + async def test_absolute_request_url(self, client: AsyncImageKit) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + await client.close() + + async def test_copied_client_does_not_close_http(self) -> None: + test_client = AsyncImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=True + ) + assert not test_client.is_closed() + + copied = test_client.copy() + assert copied is not test_client + + del copied + + await asyncio.sleep(0.2) + assert not test_client.is_closed() + + async def test_client_context_manager(self) -> None: + test_client = AsyncImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=True + ) + async with test_client as c2: + assert c2 is test_client + assert not c2.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() + + @pytest.mark.respx(base_url=base_url) + async def test_client_response_validation_error(self, respx_mock: MockRouter, async_client: AsyncImageKit) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + await async_client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + + async def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + AsyncImageKit( + base_url=base_url, + private_key=private_key, + password=password, + _strict_response_validation=True, + max_retries=cast(Any, None), + ) + + @pytest.mark.respx(base_url=base_url) + async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = AsyncImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=True + ) + + with pytest.raises(APIResponseValidationError): + await strict_client.get("/foo", cast_to=Model) + + non_strict_client = AsyncImageKit( + base_url=base_url, private_key=private_key, password=password, _strict_response_validation=False + ) + + response = await non_strict_client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + + await strict_client.close() + await non_strict_client.close() + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 0.5], + [3, "-10", 0.5], + [3, "60", 60], + [3, "61", 0.5], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "", 0.5], + [2, "", 0.5 * 2.0], + [1, "", 0.5 * 4.0], + [-1100, "", 8], # test large number potentially overflowing + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + async def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncImageKit + ) -> None: + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("imagekitio._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_timeout_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncImageKit + ) -> None: + respx_mock.post("/api/v1/files/upload").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + await async_client.files.with_streaming_response.upload( + file=b"raw file contents", file_name="fileName" + ).__aenter__() + + assert _get_open_connections(async_client) == 0 + + @mock.patch("imagekitio._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_status_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncImageKit + ) -> None: + respx_mock.post("/api/v1/files/upload").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + await async_client.files.with_streaming_response.upload( + file=b"raw file contents", file_name="fileName" + ).__aenter__() + assert _get_open_connections(async_client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("imagekitio._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + async def test_retries_taken( + self, + async_client: AsyncImageKit, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.post("/api/v1/files/upload").mock(side_effect=retry_handler) + + response = await client.files.with_raw_response.upload(file=b"raw file contents", file_name="fileName") + + assert response.retries_taken == failures_before_success + assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("imagekitio._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_omit_retry_count_header( + self, async_client: AsyncImageKit, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.post("/api/v1/files/upload").mock(side_effect=retry_handler) + + response = await client.files.with_raw_response.upload( + file=b"raw file contents", file_name="fileName", extra_headers={"x-stainless-retry-count": Omit()} + ) + + assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("imagekitio._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_overwrite_retry_count_header( + self, async_client: AsyncImageKit, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.post("/api/v1/files/upload").mock(side_effect=retry_handler) + + response = await client.files.with_raw_response.upload( + file=b"raw file contents", file_name="fileName", extra_headers={"x-stainless-retry-count": "42"} + ) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + async def test_get_platform(self) -> None: + platform = await asyncify(get_platform)() + assert isinstance(platform, (str, OtherPlatform)) + + async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultAsyncHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + async def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultAsyncHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncImageKit) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncImageKit) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + await async_client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" diff --git a/tests/test_custom_metadata_fields_ops.py b/tests/test_custom_metadata_fields_ops.py deleted file mode 100644 index 0e43a957..00000000 --- a/tests/test_custom_metadata_fields_ops.py +++ /dev/null @@ -1,990 +0,0 @@ -import json - -import responses -from responses import matchers - -from imagekitio.constants.url import URL -from imagekitio.exceptions.BadRequestException import BadRequestException -from imagekitio.exceptions.ForbiddenException import ForbiddenException -from imagekitio.exceptions.NotFoundException import NotFoundException -from imagekitio.exceptions.UnknownException import UnknownException -from imagekitio.models.CreateCustomMetadataFieldsRequestOptions import ( - CreateCustomMetadataFieldsRequestOptions, -) -from imagekitio.models.CustomMetaDataTypeEnum import CustomMetaDataTypeEnum -from imagekitio.models.CustomMetadataFieldsSchema import CustomMetadataFieldsSchema -from imagekitio.models.UpdateCustomMetadataFieldsRequestOptions import ( - UpdateCustomMetadataFieldsRequestOptions, -) -from imagekitio.utils.formatter import camel_dict_to_snake_dict -from tests.helpers import ( - ClientTestCase, - create_headers_for_test, -) - - -class TestCustomMetadataFields(ClientTestCase): - """ - TestCustomMetadataFields class used to test CRUD methods of custom metadata fields - """ - - field_id = "field_id" - - @responses.activate - def test_get_custom_metadata_fields_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - try: - responses.add( - responses.GET, - url, - status=403, - body="""{"message": "Your account cannot be authenticated." - , "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.get_custom_metadata_fields(True) - self.assertRaises(ForbiddenException) - except UnknownException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_get_custom_metadata_fields_succeeds(self): - """ - Tests if get_custom_metadata_fields succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - headers = create_headers_for_test() - responses.add( - responses.GET, - url, - content_type="application/json", - body="""[{ - "id": "62a9d5f6db485107347bb7f2", - "name": "test10", - "label": "test10", - "schema": { - "type": "Number", - "isValueRequired": false, - "minValue": 10, - "maxValue": 1000 - } - }, { - "id": "62aab2cfdb4851833b8f5e64", - "name": "test11", - "label": "test11", - "schema": { - "type": "Number", - "isValueRequired": false, - "minValue": 10, - "maxValue": 1000 - } - }]""", - match=[matchers.query_string_matcher("includeDeleted=false")], - headers=headers, - ) - resp = self.client.get_custom_metadata_fields() - - mock_response_metadata = { - "raw": [ - { - "id": "62a9d5f6db485107347bb7f2", - "name": "test10", - "label": "test10", - "schema": { - "type": "Number", - "isValueRequired": False, - "minValue": 10, - "maxValue": 1000, - }, - }, - { - "id": "62aab2cfdb4851833b8f5e64", - "name": "test11", - "label": "test11", - "schema": { - "type": "Number", - "isValueRequired": False, - "minValue": 10, - "maxValue": 1000, - }, - }, - ], - "httpStatusCode": 200, - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - } - - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("62a9d5f6db485107347bb7f2", resp.list[0].id) - self.assertEqual("62aab2cfdb4851833b8f5e64", resp.list[1].id) - self.assertEqual( - "http://test.com/v1/customMetadataFields?includeDeleted=false", - responses.calls[0].request.url, - ) - - @responses.activate - def test_get_custom_metadata_fields_succeeds_with_include_deleted_true(self): - """ - Tests if get_custom_metadata_fields succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - headers = create_headers_for_test() - responses.add( - responses.GET, - url, - body="""[{ - "id": "62a9d5f6db485107347bb7f2", - "name": "test10", - "label": "test10", - "schema": { - "type": "Number", - "isValueRequired": false, - "minValue": 10, - "maxValue": 1000 - } - }, { - "id": "62aab2cfdb4851833b8f5e64", - "name": "test11", - "label": "test11", - "schema": { - "type": "Number", - "isValueRequired": false, - "minValue": 10, - "maxValue": 1000 - } - }]""", - match=[matchers.query_string_matcher("includeDeleted=true")], - headers=headers, - ) - resp = self.client.get_custom_metadata_fields(include_deleted=True) - - mock_response_metadata = { - "raw": [ - { - "id": "62a9d5f6db485107347bb7f2", - "name": "test10", - "label": "test10", - "schema": { - "type": "Number", - "isValueRequired": False, - "minValue": 10, - "maxValue": 1000, - }, - }, - { - "id": "62aab2cfdb4851833b8f5e64", - "name": "test11", - "label": "test11", - "schema": { - "type": "Number", - "isValueRequired": False, - "minValue": 10, - "maxValue": 1000, - }, - }, - ], - "httpStatusCode": 200, - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - } - - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("62a9d5f6db485107347bb7f2", resp.list[0].id) - self.assertEqual("62aab2cfdb4851833b8f5e64", resp.list[1].id) - self.assertEqual( - "http://test.com/v1/customMetadataFields?includeDeleted=true", - responses.calls[0].request.url, - ) - - @responses.activate - def test_get_custom_metadata_fields_succeeds_with_additional_attributes_in_response(self): - """ - Tests if get_custom_metadata_fields succeeds with additional responses - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - headers = create_headers_for_test() - responses.add( - responses.GET, - url, - body="""[{ - "id": "62a9d5f6db485107347bb7f2", - "name": "test10", - "label": "test10", - "schema": { - "type": "Number", - "isValueRequired": false, - "minValue": 10, - "maxValue": 1000, - "currentValue":100 - } - }, { - "id": "62aab2cfdb4851833b8f5e64", - "name": "test11", - "label": "test11", - "schema": { - "type": "Number", - "isValueRequired": false, - "minValue": 10, - "maxValue": 1000, - "currentValue":1000 - } - }]""", - match=[matchers.query_string_matcher("includeDeleted=true")], - headers=headers, - ) - resp = self.client.get_custom_metadata_fields(include_deleted=True) - - mock_response_metadata = { - "raw": [ - { - "id": "62a9d5f6db485107347bb7f2", - "name": "test10", - "label": "test10", - "schema": { - "type": "Number", - "isValueRequired": False, - "minValue": 10, - "maxValue": 1000, - "currentValue":100 - }, - }, - { - "id": "62aab2cfdb4851833b8f5e64", - "name": "test11", - "label": "test11", - "schema": { - "type": "Number", - "isValueRequired": False, - "minValue": 10, - "maxValue": 1000, - "currentValue":1000 - }, - }, - ], - "httpStatusCode": 200, - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - } - - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual(resp.list[0].schema.current_value,100) - self.assertEqual(resp.list[1].schema.current_value,1000) - self.assertEqual(resp.list[0].random,None) - - @responses.activate - def test_delete_custom_metadata_fields_succeeds(self): - """ - Tests if delete_custom_metadata_fields succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields/{}".format(URL.API_BASE_URL, self.field_id) - headers = create_headers_for_test() - responses.add(responses.DELETE, url, status=204, headers=headers) - resp = self.client.delete_custom_metadata_field(self.field_id) - - mock_response_metadata = { - "raw": None, - "httpStatusCode": 204, - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - } - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/customMetadataFields/field_id", - responses.calls[0].request.url, - ) - - @responses.activate - def test_delete_custom_metadata_fields_fails_with_404(self): - """ - Tests if delete_custom_metadata_fields succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields/{}".format(URL.API_BASE_URL, self.field_id) - try: - headers = create_headers_for_test() - responses.add( - responses.DELETE, - url, - status=404, - headers=headers, - body="""{"message": "No such custom metadata field exists", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.delete_custom_metadata_field(self.field_id) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("No such custom metadata field exists", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - - @responses.activate - def test_create_custom_metadata_fields_fails_with_400(self): - """ - Tests if create_custom_metadata_fields fails with 400 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=400, - body="""{"message": "A custom metadata field with this name already exists" - , "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.create_custom_metadata_fields( - options=CreateCustomMetadataFieldsRequestOptions( - name="test", - label="test", - schema=CustomMetadataFieldsSchema( - type=CustomMetaDataTypeEnum.Number, min_value=100, max_value=200 - ), - ) - ) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual( - "A custom metadata field with this name already exists", e.message - ) - self.assertEqual(400, e.response_metadata.http_status_code) - - @responses.activate - def test_create_custom_metadata_fields_succeeds_with_type_number(self): - """ - Tests if create_custom_metadata_fields succeeds with type number - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.POST, - url, - status=201, - headers=headers, - content_type="application/json", - body="""{ - "id": "62dfc03b1b02a58936efca37", - "name": "test", - "label": "test", - "schema": { - "type": "Number", - "minValue": 100, - "maxValue": 200 - } - }""", - ) - resp = self.client.create_custom_metadata_fields( - options=CreateCustomMetadataFieldsRequestOptions( - name="test", - label="test", - schema=CustomMetadataFieldsSchema( - type=CustomMetaDataTypeEnum.Number, min_value=100, max_value=200 - ), - ) - ) - - mock_response_metadata = { - "raw": { - "id": "62dfc03b1b02a58936efca37", - "name": "test", - "label": "test", - "schema": {"type": "Number", "minValue": 100, "maxValue": 200}, - }, - "httpStatusCode": 201, - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - } - - request_body = json.dumps( - json.loads( - """{ - "name": "test", - "label": "test", - "schema": { - "type": "Number", - "minValue": 100, - "maxValue": 200 - } - }""" - ) - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/customMetadataFields", responses.calls[0].request.url - ) - self.assertMultiLineEqual(request_body, responses.calls[0].request.body) - - @responses.activate - def test_create_custom_metadata_fields_succeeds_with_type_textarea(self): - """ - Tests if create_custom_metadata_fields succeeds with type textarea - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.POST, - url, - status=201, - headers=headers, - content_type="application/json", - body="""{ - "id": "62e0d7ae1b02a589360dc1fd", - "name": "test", - "label": "test", - "schema": { - "isValueRequired": true, - "defaultValue": "The", - "type": "Textarea", - "minLength": 3, - "maxLength": 200 - } - }""", - ) - resp = self.client.create_custom_metadata_fields( - options=CreateCustomMetadataFieldsRequestOptions( - name="test", - label="test", - schema=CustomMetadataFieldsSchema( - is_value_required=True, - default_value="The", - type=CustomMetaDataTypeEnum.Textarea, - min_length=3, - max_length=200, - ), - ) - ) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 201, - "raw": { - "id": "62e0d7ae1b02a589360dc1fd", - "label": "test", - "name": "test", - "schema": { - "defaultValue": "The", - "isValueRequired": True, - "maxLength": 200, - "minLength": 3, - "type": "Textarea", - }, - }, - } - - request_body = json.dumps( - json.loads( - """{ - "name": "test", - "label": "test", - "schema": { - "type": "Textarea", - "defaultValue": "The", - "isValueRequired": true, - "minLength": 3, - "maxLength": 200 - } - }""" - ) - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/customMetadataFields", responses.calls[0].request.url - ) - self.assertEqual(request_body, responses.calls[0].request.body) - - @responses.activate - def test_create_custom_metadata_fields_succeeds_with_type_date(self): - """ - Tests if create_custom_metadata_fields succeeds with type date - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.POST, - url, - status=201, - headers=headers, - content_type="application/json", - body="""{ - "id": "62dfc9f41b02a58936f0d284", - "name": "test-date", - "label": "test-date", - "schema": { - "type": "Date", - "minValue": "2022-11-29T10:11:10+00:00", - "maxValue": "2022-11-30T10:11:10+00:00" - } - }""", - ) - resp = self.client.create_custom_metadata_fields( - options=CreateCustomMetadataFieldsRequestOptions( - name="test-date", - label="test-date", - schema=CustomMetadataFieldsSchema( - type=CustomMetaDataTypeEnum.Date, - min_value="2022-11-29T10:11:10+00:00", - max_value="2022-11-30T10:11:10+00:00", - ), - ) - ) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 201, - "raw": { - "id": "62dfc9f41b02a58936f0d284", - "label": "test-date", - "name": "test-date", - "schema": { - "maxValue": "2022-11-30T10:11:10+00:00", - "minValue": "2022-11-29T10:11:10+00:00", - "type": "Date", - }, - }, - } - - request_body = json.dumps( - json.loads( - """{ - "name": "test-date", - "label": "test-date", - "schema": { - "type": "Date", - "minValue": "2022-11-29T10:11:10+00:00", - "maxValue": "2022-11-30T10:11:10+00:00" - } - }""" - ) - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/customMetadataFields", responses.calls[0].request.url - ) - self.assertEqual(request_body, responses.calls[0].request.body) - - @responses.activate - def test_create_custom_metadata_fields_succeeds_with_type_boolean(self): - """ - Tests if create_custom_metadata_fields succeeds with type boolean - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.POST, - url, - status=201, - headers=headers, - content_type="application/json", - body="""{ - "id": "62dfcb801b02a58936f0fc39", - "name": "test-boolean", - "label": "test-boolean", - "schema": { - "type": "Boolean", - "isValueRequired": true, - "defaultValue": true - } - }""", - ) - resp = self.client.create_custom_metadata_fields( - options=CreateCustomMetadataFieldsRequestOptions( - name="test-boolean", - label="test-boolean", - schema=CustomMetadataFieldsSchema( - type=CustomMetaDataTypeEnum.Boolean, - is_value_required=True, - default_value=True, - ), - ) - ) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 201, - "raw": { - "id": "62dfcb801b02a58936f0fc39", - "label": "test-boolean", - "name": "test-boolean", - "schema": { - "defaultValue": True, - "isValueRequired": True, - "type": "Boolean", - }, - }, - } - - request_body = json.dumps( - json.loads( - """{ - "name": "test-boolean", - "label": "test-boolean", - "schema": { - "type": "Boolean", - "defaultValue": true, - "isValueRequired": true - } - }""" - ) - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/customMetadataFields", responses.calls[0].request.url - ) - self.assertEqual(request_body, responses.calls[0].request.body) - - @responses.activate - def test_create_custom_metadata_fields_succeeds_with_type_single_select(self): - """ - Tests if create_custom_metadata_fields succeeds with type SingleSelect - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.POST, - url, - status=201, - headers=headers, - content_type="application/json", - body="""{ - "id": "62dfcdb21b02a58936f14c97", - "name": "test", - "label": "test", - "schema": { - "type": "SingleSelect", - "selectOptions": ["small", "medium", "large", 30, 40, true] - } - }""", - ) - resp = self.client.create_custom_metadata_fields( - options=CreateCustomMetadataFieldsRequestOptions( - name="test", - label="test", - schema=CustomMetadataFieldsSchema( - type=CustomMetaDataTypeEnum.SingleSelect, - select_options=["small", "medium", "large", 30, 40, True], - ), - ) - ) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 201, - "raw": { - "id": "62dfcdb21b02a58936f14c97", - "label": "test", - "name": "test", - "schema": { - "selectOptions": ["small", "medium", "large", 30, 40, True], - "type": "SingleSelect", - }, - }, - } - - request_body = json.dumps( - json.loads( - """{ - "name": "test", - "label": "test", - "schema": - { - "type": "SingleSelect", - "selectOptions": ["small", "medium", "large", 30, 40, - true] - } - }""" - ) - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/customMetadataFields", responses.calls[0].request.url - ) - self.assertEqual(request_body, responses.calls[0].request.body) - - @responses.activate - def test_create_custom_metadata_fields_succeeds_with_type_multi_select(self): - """ - Tests if create_custom_metadata_fields succeeds with type MultiSelect - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.POST, - url, - status=201, - headers=headers, - content_type="application/json", - body="""{ - "id": "62dfcf001b02a58936f17808", - "name": "test", - "label": "test", - "schema": { - "type": "MultiSelect", - "isValueRequired": true, - "defaultValue": ["small", 30, true], - "selectOptions": ["small", "medium", "large", 30, 40, true] - } - }""", - ) - resp = self.client.create_custom_metadata_fields( - options=CreateCustomMetadataFieldsRequestOptions( - name="test", - label="test", - schema=CustomMetadataFieldsSchema( - type=CustomMetaDataTypeEnum.MultiSelect, - is_value_required=True, - default_value=["small", 30, True], - select_options=["small", "medium", "large", 30, 40, True], - ), - ) - ) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 201, - "raw": { - "id": "62dfcf001b02a58936f17808", - "label": "test", - "name": "test", - "schema": { - "defaultValue": ["small", 30, True], - "isValueRequired": True, - "selectOptions": ["small", "medium", "large", 30, 40, True], - "type": "MultiSelect", - }, - }, - } - - request_body = json.dumps( - json.loads( - """{ - "name": "test", - "label": "test", - "schema": { - "type": "MultiSelect", - "selectOptions": ["small", "medium", "large", 30, 40, true], - "defaultValue": ["small", 30, true], - "isValueRequired": true - } - }""" - ) - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/customMetadataFields", responses.calls[0].request.url - ) - self.assertEqual(request_body, responses.calls[0].request.body) - - @responses.activate - def test_update_custom_metadata_fields_succeeds(self): - """ - Tests if update_custom_metadata_fields succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields/{}".format(URL.API_BASE_URL, self.field_id) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.PATCH, - url, - headers=headers, - content_type="application/json", - body="""{ - "id": "62a9d5f6db485107347bb7f2", - "name": "test", - "label": "test-update", - "schema": { - "minValue": 100, - "maxValue": 200, - "type": "Number" - } - }""", - ) - - resp = self.client.update_custom_metadata_fields( - self.field_id, - options=UpdateCustomMetadataFieldsRequestOptions( - label="test-update", - schema=CustomMetadataFieldsSchema(min_value=100, max_value=200), - ), - ) - - mock_response_metadata = { - "raw": { - "id": "62a9d5f6db485107347bb7f2", - "name": "test", - "label": "test-update", - "schema": {"minValue": 100, "maxValue": 200, "type": "Number"}, - }, - "httpStatusCode": 200, - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - } - - request_body = json.dumps( - json.loads( - """{ - "label": "test-update", - "schema": { - "minValue": 100, - "maxValue": 200 - } - }""" - ) - ) - - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("62a9d5f6db485107347bb7f2", resp.id) - self.assertEqual( - "http://test.com/v1/customMetadataFields/field_id", - responses.calls[0].request.url, - ) - self.assertEqual(request_body, responses.calls[0].request.body) - - @responses.activate - def test_update_custom_metadata_fields_fails_with_404(self): - """ - Tests if update_custom_metadata_fields fails with 404 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields/{}".format(URL.API_BASE_URL, self.field_id) - try: - responses.add( - responses.PATCH, - url, - status=404, - body="""{ - "message": "No such custom metadata field exists", - "help": "For support kindly contact us at support@imagekit.io ." - }""", - ) - - self.client.update_custom_metadata_fields( - self.field_id, - options=UpdateCustomMetadataFieldsRequestOptions( - label="test-update", - schema=CustomMetadataFieldsSchema(min_value=100, max_value=200), - ), - ) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("No such custom metadata field exists", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - - @responses.activate - def test_update_custom_metadata_fields_fails_with_400(self): - """ - Tests if update_custom_metadata_fields fails with 400 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/customMetadataFields/{}".format(URL.API_BASE_URL, self.field_id) - try: - responses.add( - responses.PATCH, - url, - status=400, - body="""{ - "message": "Your request contains invalid ID parameter.", - "help": "For support kindly contact us at support@imagekit.io ." - }""", - ) - - self.client.update_custom_metadata_fields( - self.field_id, - options=UpdateCustomMetadataFieldsRequestOptions( - label="test-update", - schema=CustomMetadataFieldsSchema(min_value=100, max_value=200), - ), - ) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual("Your request contains invalid ID parameter.", e.message) - self.assertEqual(400, e.response_metadata.http_status_code) diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py new file mode 100644 index 00000000..d0caf5c4 --- /dev/null +++ b/tests/test_deepcopy.py @@ -0,0 +1,58 @@ +from imagekitio._utils import deepcopy_minimal + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert id(obj1) != id(obj2) + + +def test_simple_dict() -> None: + obj1 = {"foo": "bar"} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + + +def test_nested_dict() -> None: + obj1 = {"foo": {"bar": True}} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert_different_identities(obj1["foo"], obj2["foo"]) + + +def test_complex_nested_dict() -> None: + obj1 = {"foo": {"bar": [{"hello": "world"}]}} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert_different_identities(obj1["foo"], obj2["foo"]) + assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) + assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) + + +def test_simple_list() -> None: + obj1 = ["a", "b", "c"] + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + + +def test_nested_list() -> None: + obj1 = ["a", [1, 2, 3]] + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert_different_identities(obj1[1], obj2[1]) + + +class MyObject: ... + + +def test_ignores_other_types() -> None: + # custom classes + my_obj = MyObject() + obj1 = {"foo": my_obj} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert obj1["foo"] is my_obj + + # tuples + obj3 = ("a", "b") + obj4 = deepcopy_minimal(obj3) + assert obj3 is obj4 diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py new file mode 100644 index 00000000..396cbd05 --- /dev/null +++ b/tests/test_extract_files.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Sequence + +import pytest + +from imagekitio._types import FileTypes +from imagekitio._utils import extract_files + + +def test_removes_files_from_input() -> None: + query = {"foo": "bar"} + assert extract_files(query, paths=[]) == [] + assert query == {"foo": "bar"} + + query2 = {"foo": b"Bar", "hello": "world"} + assert extract_files(query2, paths=[["foo"]]) == [("foo", b"Bar")] + assert query2 == {"hello": "world"} + + query3 = {"foo": {"foo": {"bar": b"Bar"}}, "hello": "world"} + assert extract_files(query3, paths=[["foo", "foo", "bar"]]) == [("foo[foo][bar]", b"Bar")] + assert query3 == {"foo": {"foo": {}}, "hello": "world"} + + query4 = {"foo": {"bar": b"Bar", "baz": "foo"}, "hello": "world"} + assert extract_files(query4, paths=[["foo", "bar"]]) == [("foo[bar]", b"Bar")] + assert query4 == {"hello": "world", "foo": {"baz": "foo"}} + + +def test_multiple_files() -> None: + query = {"documents": [{"file": b"My first file"}, {"file": b"My second file"}]} + assert extract_files(query, paths=[["documents", "", "file"]]) == [ + ("documents[][file]", b"My first file"), + ("documents[][file]", b"My second file"), + ] + assert query == {"documents": [{}, {}]} + + +@pytest.mark.parametrize( + "query,paths,expected", + [ + [ + {"foo": {"bar": "baz"}}, + [["foo", "", "bar"]], + [], + ], + [ + {"foo": ["bar", "baz"]}, + [["foo", "bar"]], + [], + ], + [ + {"foo": {"bar": "baz"}}, + [["foo", "foo"]], + [], + ], + ], + ids=["dict expecting array", "array expecting dict", "unknown keys"], +) +def test_ignores_incorrect_paths( + query: dict[str, object], + paths: Sequence[Sequence[str]], + expected: list[tuple[str, FileTypes]], +) -> None: + assert extract_files(query, paths=paths) == expected diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 00000000..79dc2ef2 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,51 @@ +from pathlib import Path + +import anyio +import pytest +from dirty_equals import IsDict, IsList, IsBytes, IsTuple + +from imagekitio._files import to_httpx_files, async_to_httpx_files + +readme_path = Path(__file__).parent.parent.joinpath("README.md") + + +def test_pathlib_includes_file_name() -> None: + result = to_httpx_files({"file": readme_path}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +def test_tuple_input() -> None: + result = to_httpx_files([("file", readme_path)]) + print(result) + assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) + + +@pytest.mark.asyncio +async def test_async_pathlib_includes_file_name() -> None: + result = await async_to_httpx_files({"file": readme_path}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +@pytest.mark.asyncio +async def test_async_supports_anyio_path() -> None: + result = await async_to_httpx_files({"file": anyio.Path(readme_path)}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +@pytest.mark.asyncio +async def test_async_tuple_input() -> None: + result = await async_to_httpx_files([("file", readme_path)]) + print(result) + assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) + + +def test_string_not_allowed() -> None: + with pytest.raises(TypeError, match="Expected file types input to be a FileContent type or to be a tuple"): + to_httpx_files( + { + "file": "foo", # type: ignore + } + ) diff --git a/tests/test_files_ops.py b/tests/test_files_ops.py deleted file mode 100644 index ed7d6600..00000000 --- a/tests/test_files_ops.py +++ /dev/null @@ -1,3120 +0,0 @@ -import base64 -import json -import os - -import responses -from responses import matchers - -from imagekitio.client import ImageKit -from imagekitio.constants.url import URL -from imagekitio.exceptions.BadRequestException import BadRequestException -from imagekitio.exceptions.ConflictException import ConflictException -from imagekitio.exceptions.ForbiddenException import ForbiddenException -from imagekitio.exceptions.NotFoundException import NotFoundException -from imagekitio.exceptions.UnknownException import UnknownException -from imagekitio.models.CopyFileRequestOptions import CopyFileRequestOptions -from imagekitio.models.MoveFileRequestOptions import MoveFileRequestOptions -from imagekitio.models.RenameFileRequestOptions import RenameFileRequestOptions -from imagekitio.models.UpdateFileRequestOptions import UpdateFileRequestOptions -from imagekitio.models.UploadFileRequestOptions import UploadFileRequestOptions -from imagekitio.utils.formatter import camel_dict_to_snake_dict -from tests.helpers import ( - ClientTestCase, - create_headers_for_test, - get_auth_headers_for_test, -) - -imagekit_obj = ImageKit( - private_key="private_fake:", - public_key="public_fake123:", - url_endpoint="fake.com", -) - - -class TestUpload(ClientTestCase): - """ - TestUpload class used to test upload method - """ - - image = os.path.join( - os.path.dirname(os.path.realpath(__file__)), "dummy_data/image.png" - ) - - sample_image = os.path.join( - os.path.dirname(os.path.realpath(__file__)), "sample.jpg" - ) - filename = "test" - - @responses.activate - def test_upload_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.UPLOAD_BASE_URL = "http://test.com" - url = "%s%s" % (URL.UPLOAD_BASE_URL, "/api/v1/files/upload") - try: - responses.add( - responses.POST, - url, - status=403, - body="""{"message": "Your account cannot be authenticated." - , "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.upload_file( - file=self.image, - file_name=self.filename, - options=UploadFileRequestOptions( - use_unique_file_name=False, - tags=["abc", "def"], - folder="/testing-python-folder/", - is_private_file=False, - custom_coordinates="10,10,20,20", - response_fields=[ - "tags", - "custom_coordinates", - "is_private_file", - "embedded_metadata", - "custom_metadata", - ], - extensions=( - { - "name": "remove-bg", - "options": {"add_shadow": True, "bg_color": "pink"}, - }, - { - "name": "google-auto-tagging", - "minConfidence": 80, - "maxTags": 10, - }, - ), - webhook_url="https://webhook.site/c78d617f-33bc-40d9-9e61-608999721e2e", - overwrite_file=True, - overwrite_ai_tags=False, - overwrite_tags=False, - overwrite_custom_metadata=True, - custom_metadata={"testss": 12}, - ), - ) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(e.response_metadata.http_status_code, 403) - - @responses.activate - def test_binary_upload_succeeds(self): - """ - Tests if upload succeeds - """ - URL.UPLOAD_BASE_URL = "http://test.com" - url = "%s%s" % (URL.UPLOAD_BASE_URL, "/api/v1/files/upload") - headers = create_headers_for_test() - responses.add( - responses.POST, - url, - body="""{ - "fileId": "fake_file_id1234", - "name": "file_name.jpg", - "size": 102117, - "versionInfo": { - "id": "62d670648cdb697522602b45", - "name": "Version 11" - }, - "filePath": "/testing-python-folder/file_name.jpg", - "url": "https://ik.imagekit.io/your_imagekit_id/testing-python-folder/file_name.jpg", - "fileType": "image", - "height": 700, - "width": 1050, - "thumbnailUrl": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/testing-python-folder/file_name.jpg", - "tags": [ - "abc", - "def" - ], - "AITags": [ - { - "name": "Computer", - "confidence": 97.66, - "source": "google-auto-tagging" - }, - { - "name": "Personal computer", - "confidence": 94.96, - "source": "google-auto-tagging" - } - ], - "isPrivateFile": true, - "extensionStatus": { - "remove-bg": "pending", - "google-auto-tagging": "success" - } - }""", - headers=headers, - ) - - with open(self.sample_image, mode="rb") as img: - resp = self.client.upload_file( - file=img, - file_name="file_name.jpg", - options=UploadFileRequestOptions( - use_unique_file_name=False, - tags=["abc", "def"], - folder="/testing-python-folder/", - is_private_file=True, - response_fields=["is_private_file", "tags"], - extensions=( - { - "name": "remove-bg", - "options": {"add_shadow": True, "bg_color": "pink"}, - }, - {"name": "google-auto-tagging", "minConfidence": 80, "maxTags": 10}, - ), - webhook_url="url", - overwrite_file=True, - overwrite_ai_tags=False, - overwrite_tags=False, - overwrite_custom_metadata=True, - custom_metadata={"test100": 11}, - transformation={"pre": "h-100", "post": [{"type": "transformation", "value": "w-100"}]}, - checks="'request.folder' : '/testing-python-folder/'", - is_published=True - ), - ) - mock_response_metadata = { - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "http_status_code": 200, - "raw": { - "AITags": [ - { - "confidence": 97.66, - "name": "Computer", - "source": "google-auto-tagging", - }, - { - "confidence": 94.96, - "name": "Personal computer", - "source": "google-auto-tagging", - }, - ], - "extensionStatus": { - "google-auto-tagging": "success", - "remove-bg": "pending", - }, - "fileId": "fake_file_id1234", - "filePath": "/testing-python-folder/file_name.jpg", - "fileType": "image", - "height": 700, - "isPrivateFile": True, - "name": "file_name.jpg", - "size": 102117, - "tags": ["abc", "def"], - "thumbnailUrl": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/testing-python-folder/file_name.jpg", - "url": "https://ik.imagekit.io/your_imagekit_id/testing-python-folder/file_name.jpg", - "versionInfo": {"id": "62d670648cdb697522602b45", "name": "Version 11"}, - "width": 1050 - }, - } - request_body = b'----randomBoundary---------------------\r\nContent-Disposition: form-data; name="file"; filename="file_name.jpg"\r\n\r\n\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xff\xe2\x0cXICC_PROFILE\x00\x01\x01\x00\x00\x0cHLino\x02\x10\x00\x00mntrRGB XYZ \x07\xce\x00\x02\x00\t\x00\x06\x001\x00\x00acspMSFT\x00\x00\x00\x00IEC sRGB\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf6\xd6\x00\x01\x00\x00\x00\x00\xd3-HP \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x11cprt\x00\x00\x01P\x00\x00\x003desc\x00\x00\x01\x84\x00\x00\x00lwtpt\x00\x00\x01\xf0\x00\x00\x00\x14bkpt\x00\x00\x02\x04\x00\x00\x00\x14rXYZ\x00\x00\x02\x18\x00\x00\x00\x14gXYZ\x00\x00\x02,\x00\x00\x00\x14bXYZ\x00\x00\x02@\x00\x00\x00\x14dmnd\x00\x00\x02T\x00\x00\x00pdmdd\x00\x00\x02\xc4\x00\x00\x00\x88vued\x00\x00\x03L\x00\x00\x00\x86view\x00\x00\x03\xd4\x00\x00\x00$lumi\x00\x00\x03\xf8\x00\x00\x00\x14meas\x00\x00\x04\x0c\x00\x00\x00$tech\x00\x00\x040\x00\x00\x00\x0crTRC\x00\x00\x04<\x00\x00\x08\x0cgTRC\x00\x00\x04<\x00\x00\x08\x0cbTRC\x00\x00\x04<\x00\x00\x08\x0ctext\x00\x00\x00\x00Copyright (c) 1998 Hewlett-Packard Company\x00\x00desc\x00\x00\x00\x00\x00\x00\x00\x12sRGB IEC61966-2.1\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x12sRGB IEC61966-2.1\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00XYZ \x00\x00\x00\x00\x00\x00\xf3Q\x00\x01\x00\x00\x00\x01\x16\xccXYZ \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00XYZ \x00\x00\x00\x00\x00\x00o\xa2\x00\x008\xf5\x00\x00\x03\x90XYZ \x00\x00\x00\x00\x00\x00b\x99\x00\x00\xb7\x85\x00\x00\x18\xdaXYZ \x00\x00\x00\x00\x00\x00$\xa0\x00\x00\x0f\x84\x00\x00\xb6\xcfdesc\x00\x00\x00\x00\x00\x00\x00\x16IEC http://www.iec.ch\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x16IEC http://www.iec.ch\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00desc\x00\x00\x00\x00\x00\x00\x00.IEC 61966-2.1 Default RGB colour space - sRGB\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00.IEC 61966-2.1 Default RGB colour space - sRGB\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00desc\x00\x00\x00\x00\x00\x00\x00,Reference Viewing Condition in IEC61966-2.1\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00,Reference Viewing Condition in IEC61966-2.1\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00view\x00\x00\x00\x00\x00\x13\xa4\xfe\x00\x14_.\x00\x10\xcf\x14\x00\x03\xed\xcc\x00\x04\x13\x0b\x00\x03\\\x9e\x00\x00\x00\x01XYZ \x00\x00\x00\x00\x00L\tV\x00P\x00\x00\x00W\x1f\xe7meas\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x8f\x00\x00\x00\x02sig \x00\x00\x00\x00CRT curv\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x05\x00\n\x00\x0f\x00\x14\x00\x19\x00\x1e\x00#\x00(\x00-\x002\x007\x00;\x00@\x00E\x00J\x00O\x00T\x00Y\x00^\x00c\x00h\x00m\x00r\x00w\x00|\x00\x81\x00\x86\x00\x8b\x00\x90\x00\x95\x00\x9a\x00\x9f\x00\xa4\x00\xa9\x00\xae\x00\xb2\x00\xb7\x00\xbc\x00\xc1\x00\xc6\x00\xcb\x00\xd0\x00\xd5\x00\xdb\x00\xe0\x00\xe5\x00\xeb\x00\xf0\x00\xf6\x00\xfb\x01\x01\x01\x07\x01\r\x01\x13\x01\x19\x01\x1f\x01%\x01+\x012\x018\x01>\x01E\x01L\x01R\x01Y\x01`\x01g\x01n\x01u\x01|\x01\x83\x01\x8b\x01\x92\x01\x9a\x01\xa1\x01\xa9\x01\xb1\x01\xb9\x01\xc1\x01\xc9\x01\xd1\x01\xd9\x01\xe1\x01\xe9\x01\xf2\x01\xfa\x02\x03\x02\x0c\x02\x14\x02\x1d\x02&\x02/\x028\x02A\x02K\x02T\x02]\x02g\x02q\x02z\x02\x84\x02\x8e\x02\x98\x02\xa2\x02\xac\x02\xb6\x02\xc1\x02\xcb\x02\xd5\x02\xe0\x02\xeb\x02\xf5\x03\x00\x03\x0b\x03\x16\x03!\x03-\x038\x03C\x03O\x03Z\x03f\x03r\x03~\x03\x8a\x03\x96\x03\xa2\x03\xae\x03\xba\x03\xc7\x03\xd3\x03\xe0\x03\xec\x03\xf9\x04\x06\x04\x13\x04 \x04-\x04;\x04H\x04U\x04c\x04q\x04~\x04\x8c\x04\x9a\x04\xa8\x04\xb6\x04\xc4\x04\xd3\x04\xe1\x04\xf0\x04\xfe\x05\r\x05\x1c\x05+\x05:\x05I\x05X\x05g\x05w\x05\x86\x05\x96\x05\xa6\x05\xb5\x05\xc5\x05\xd5\x05\xe5\x05\xf6\x06\x06\x06\x16\x06\'\x067\x06H\x06Y\x06j\x06{\x06\x8c\x06\x9d\x06\xaf\x06\xc0\x06\xd1\x06\xe3\x06\xf5\x07\x07\x07\x19\x07+\x07=\x07O\x07a\x07t\x07\x86\x07\x99\x07\xac\x07\xbf\x07\xd2\x07\xe5\x07\xf8\x08\x0b\x08\x1f\x082\x08F\x08Z\x08n\x08\x82\x08\x96\x08\xaa\x08\xbe\x08\xd2\x08\xe7\x08\xfb\t\x10\t%\t:\tO\td\ty\t\x8f\t\xa4\t\xba\t\xcf\t\xe5\t\xfb\n\x11\n\'\n=\nT\nj\n\x81\n\x98\n\xae\n\xc5\n\xdc\n\xf3\x0b\x0b\x0b"\x0b9\x0bQ\x0bi\x0b\x80\x0b\x98\x0b\xb0\x0b\xc8\x0b\xe1\x0b\xf9\x0c\x12\x0c*\x0cC\x0c\\\x0cu\x0c\x8e\x0c\xa7\x0c\xc0\x0c\xd9\x0c\xf3\r\r\r&\r@\rZ\rt\r\x8e\r\xa9\r\xc3\r\xde\r\xf8\x0e\x13\x0e.\x0eI\x0ed\x0e\x7f\x0e\x9b\x0e\xb6\x0e\xd2\x0e\xee\x0f\t\x0f%\x0fA\x0f^\x0fz\x0f\x96\x0f\xb3\x0f\xcf\x0f\xec\x10\t\x10&\x10C\x10a\x10~\x10\x9b\x10\xb9\x10\xd7\x10\xf5\x11\x13\x111\x11O\x11m\x11\x8c\x11\xaa\x11\xc9\x11\xe8\x12\x07\x12&\x12E\x12d\x12\x84\x12\xa3\x12\xc3\x12\xe3\x13\x03\x13#\x13C\x13c\x13\x83\x13\xa4\x13\xc5\x13\xe5\x14\x06\x14\'\x14I\x14j\x14\x8b\x14\xad\x14\xce\x14\xf0\x15\x12\x154\x15V\x15x\x15\x9b\x15\xbd\x15\xe0\x16\x03\x16&\x16I\x16l\x16\x8f\x16\xb2\x16\xd6\x16\xfa\x17\x1d\x17A\x17e\x17\x89\x17\xae\x17\xd2\x17\xf7\x18\x1b\x18@\x18e\x18\x8a\x18\xaf\x18\xd5\x18\xfa\x19 \x19E\x19k\x19\x91\x19\xb7\x19\xdd\x1a\x04\x1a*\x1aQ\x1aw\x1a\x9e\x1a\xc5\x1a\xec\x1b\x14\x1b;\x1bc\x1b\x8a\x1b\xb2\x1b\xda\x1c\x02\x1c*\x1cR\x1c{\x1c\xa3\x1c\xcc\x1c\xf5\x1d\x1e\x1dG\x1dp\x1d\x99\x1d\xc3\x1d\xec\x1e\x16\x1e@\x1ej\x1e\x94\x1e\xbe\x1e\xe9\x1f\x13\x1f>\x1fi\x1f\x94\x1f\xbf\x1f\xea \x15 A l \x98 \xc4 \xf0!\x1c!H!u!\xa1!\xce!\xfb"\'"U"\x82"\xaf"\xdd#\n#8#f#\x94#\xc2#\xf0$\x1f$M$|$\xab$\xda%\t%8%h%\x97%\xc7%\xf7&\'&W&\x87&\xb7&\xe8\'\x18\'I\'z\'\xab\'\xdc(\r(?(q(\xa2(\xd4)\x06)8)k)\x9d)\xd0*\x02*5*h*\x9b*\xcf+\x02+6+i+\x9d+\xd1,\x05,9,n,\xa2,\xd7-\x0c-A-v-\xab-\xe1.\x16.L.\x82.\xb7.\xee/$/Z/\x91/\xc7/\xfe050l0\xa40\xdb1\x121J1\x821\xba1\xf22*2c2\x9b2\xd43\r3F3\x7f3\xb83\xf14+4e4\x9e4\xd85\x135M5\x875\xc25\xfd676r6\xae6\xe97$7`7\x9c7\xd78\x148P8\x8c8\xc89\x059B9\x7f9\xbc9\xf9:6:t:\xb2:\xef;-;k;\xaa;\xe8<\' >`>\xa0>\xe0?!?a?\xa2?\xe2@#@d@\xa6@\xe7A)AjA\xacA\xeeB0BrB\xb5B\xf7C:C}C\xc0D\x03DGD\x8aD\xceE\x12EUE\x9aE\xdeF"FgF\xabF\xf0G5G{G\xc0H\x05HKH\x91H\xd7I\x1dIcI\xa9I\xf0J7J}J\xc4K\x0cKSK\x9aK\xe2L*LrL\xbaM\x02MJM\x93M\xdcN%NnN\xb7O\x00OIO\x93O\xddP\'PqP\xbbQ\x06QPQ\x9bQ\xe6R1R|R\xc7S\x13S_S\xaaS\xf6TBT\x8fT\xdbU(UuU\xc2V\x0fV\\V\xa9V\xf7WDW\x92W\xe0X/X}X\xcbY\x1aYiY\xb8Z\x07ZVZ\xa6Z\xf5[E[\x95[\xe5\\5\\\x86\\\xd6]\']x]\xc9^\x1a^l^\xbd_\x0f_a_\xb3`\x05`W`\xaa`\xfcaOa\xa2a\xf5bIb\x9cb\xf0cCc\x97c\xebd@d\x94d\xe9e=e\x92e\xe7f=f\x92f\xe8g=g\x93g\xe9h?h\x96h\xeciCi\x9ai\xf1jHj\x9fj\xf7kOk\xa7k\xfflWl\xafm\x08m`m\xb9n\x12nkn\xc4o\x1eoxo\xd1p+p\x86p\xe0q:q\x95q\xf0rKr\xa6s\x01s]s\xb8t\x14tpt\xccu(u\x85u\xe1v>v\x9bv\xf8wVw\xb3x\x11xnx\xccy*y\x89y\xe7zFz\xa5{\x04{c{\xc2|!|\x81|\xe1}A}\xa1~\x01~b~\xc2\x7f#\x7f\x84\x7f\xe5\x80G\x80\xa8\x81\n\x81k\x81\xcd\x820\x82\x92\x82\xf4\x83W\x83\xba\x84\x1d\x84\x80\x84\xe3\x85G\x85\xab\x86\x0e\x86r\x86\xd7\x87;\x87\x9f\x88\x04\x88i\x88\xce\x893\x89\x99\x89\xfe\x8ad\x8a\xca\x8b0\x8b\x96\x8b\xfc\x8cc\x8c\xca\x8d1\x8d\x98\x8d\xff\x8ef\x8e\xce\x8f6\x8f\x9e\x90\x06\x90n\x90\xd6\x91?\x91\xa8\x92\x11\x92z\x92\xe3\x93M\x93\xb6\x94 \x94\x8a\x94\xf4\x95_\x95\xc9\x964\x96\x9f\x97\n\x97u\x97\xe0\x98L\x98\xb8\x99$\x99\x90\x99\xfc\x9ah\x9a\xd5\x9bB\x9b\xaf\x9c\x1c\x9c\x89\x9c\xf7\x9dd\x9d\xd2\x9e@\x9e\xae\x9f\x1d\x9f\x8b\x9f\xfa\xa0i\xa0\xd8\xa1G\xa1\xb6\xa2&\xa2\x96\xa3\x06\xa3v\xa3\xe6\xa4V\xa4\xc7\xa58\xa5\xa9\xa6\x1a\xa6\x8b\xa6\xfd\xa7n\xa7\xe0\xa8R\xa8\xc4\xa97\xa9\xa9\xaa\x1c\xaa\x8f\xab\x02\xabu\xab\xe9\xac\\\xac\xd0\xadD\xad\xb8\xae-\xae\xa1\xaf\x16\xaf\x8b\xb0\x00\xb0u\xb0\xea\xb1`\xb1\xd6\xb2K\xb2\xc2\xb38\xb3\xae\xb4%\xb4\x9c\xb5\x13\xb5\x8a\xb6\x01\xb6y\xb6\xf0\xb7h\xb7\xe0\xb8Y\xb8\xd1\xb9J\xb9\xc2\xba;\xba\xb5\xbb.\xbb\xa7\xbc!\xbc\x9b\xbd\x15\xbd\x8f\xbe\n\xbe\x84\xbe\xff\xbfz\xbf\xf5\xc0p\xc0\xec\xc1g\xc1\xe3\xc2_\xc2\xdb\xc3X\xc3\xd4\xc4Q\xc4\xce\xc5K\xc5\xc8\xc6F\xc6\xc3\xc7A\xc7\xbf\xc8=\xc8\xbc\xc9:\xc9\xb9\xca8\xca\xb7\xcb6\xcb\xb6\xcc5\xcc\xb5\xcd5\xcd\xb5\xce6\xce\xb6\xcf7\xcf\xb8\xd09\xd0\xba\xd1<\xd1\xbe\xd2?\xd2\xc1\xd3D\xd3\xc6\xd4I\xd4\xcb\xd5N\xd5\xd1\xd6U\xd6\xd8\xd7\\\xd7\xe0\xd8d\xd8\xe8\xd9l\xd9\xf1\xdav\xda\xfb\xdb\x80\xdc\x05\xdc\x8a\xdd\x10\xdd\x96\xde\x1c\xde\xa2\xdf)\xdf\xaf\xe06\xe0\xbd\xe1D\xe1\xcc\xe2S\xe2\xdb\xe3c\xe3\xeb\xe4s\xe4\xfc\xe5\x84\xe6\r\xe6\x96\xe7\x1f\xe7\xa9\xe82\xe8\xbc\xe9F\xe9\xd0\xea[\xea\xe5\xebp\xeb\xfb\xec\x86\xed\x11\xed\x9c\xee(\xee\xb4\xef@\xef\xcc\xf0X\xf0\xe5\xf1r\xf1\xff\xf2\x8c\xf3\x19\xf3\xa7\xf44\xf4\xc2\xf5P\xf5\xde\xf6m\xf6\xfb\xf7\x8a\xf8\x19\xf8\xa8\xf98\xf9\xc7\xfaW\xfa\xe7\xfbw\xfc\x07\xfc\x98\xfd)\xfd\xba\xfeK\xfe\xdc\xffm\xff\xff\xff\xdb\x00C\x00\x03\x02\x02\x03\x02\x02\x03\x03\x03\x03\x04\x03\x03\x04\x05\x08\x05\x05\x04\x04\x05\n\x07\x07\x06\x08\x0c\n\x0c\x0c\x0b\n\x0b\x0b\r\x0e\x12\x10\r\x0e\x11\x0e\x0b\x0b\x10\x16\x10\x11\x13\x14\x15\x15\x15\x0c\x0f\x17\x18\x16\x14\x18\x12\x14\x15\x14\xff\xdb\x00C\x01\x03\x04\x04\x05\x04\x05\t\x05\x05\t\x14\r\x0b\r\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\xff\xc2\x00\x11\x08\x02\xbc\x04\x1a\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x1c\x00\x00\x02\x03\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\xff\xc4\x00\x1b\x01\x01\x01\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\xff\xda\x00\x0c\x03\x01\x00\x02\x10\x03\x10\x00\x00\x01\xf3\x7f\xcd\x7f\xab\xea\xe3\xd3\xd4\xfc\x7fn\xef?,\x9d\xbc\xfc\xefW\x82\xcc\xb4\xf2\xe9w=\xca(\xe9i\xdfY:\xd9\x8d\xea\xe54r\xcc\xa1\xc3\xdc\x87me\xbd\xf0\xf6s=\\)\xdf;\xb1\xab\xb1l\xc5\xbf\r\xfc\xafK<_9\x85\x9e7\xb7\xc9\xe3~\xef\xcb\xe1\xfd\x1f/7\xe9p\xc3\xea\xe7\xab\x8f\xa3\xd2|\x9f\xa7\xeb~\'\xd4\xf4\x1e__K:\xc2\x9e[\xd1\xe3\xef\xfdN\x1fL\xed\xf3}G\xa3\xe2j\xef\xc2\xfe\x9c\xc4R\xc5T\xaaT\xb1\xcdDZP\x95J\x962\xa5Y\xd2"\xa0\x94\x95\x00\x04\x8e\x1c\x80Q\r\x00\x01\xa3\x01\xc8\xe4c\x90\x18\r\x1a4\x933\xb1\xa5x\xd6>\x1e\x9e\x7f\x9f\xd9\x83\x8f\xaa\xbcn\xe9\xce\xdb\x8b\x1c\xe6\xc5X\xe9\xc9\xf2{|\xaf\x9f\xd7\xe7\xfa\xb2\xf5\xc5;\xcd[\xcb\x12\x88\x96\xad\xa1\xabfz\xfao+\xe9\xdd~e\x1d\xf3\xef?G\xf9\xac?/\xd7G>\xb4\xe7\xa59\xe9L\xeb\x0c\xeb\xf3\xad\xfd_O\xc9\xe8\xf4\x7f/\xd1\xa3\x97\x1c>\x8f7?\xd3\xe1\xd1\xcfzyt\xb3\x1b\x94\x99\xfa\xaa\xd7Y:]\xcfZ\xb8\xe6\xferSJ\x8e\x8a\xfbo-\xed\x87\xac\xe5{<\xd5\xeb\x9e\x9e[\xb7\x1a\xb7\x17F\x1b\xb9\xde\x8f>P\xe30v\xe3\xc3\xfa\x1e?!\xf6\xbeo\x1f\xe9y\xb9\xbe\xfe\x18\xbd\\\xf5\xf1\xf4\xfa_\x8f\xf4\xfdw\xc5\xfa~\x83\xcb\xec\xe9f\xe0\xaf+\xe9\xf0\xf6\xfe\x9f\x1f\xa6u\xf9\xfe\xab\xd1\xf15v\xe1\xa3\xa7$%R\xa5R\xc6U\x11\x9a@\xb1\x95*\x95DmR\xa9R\xa0\x12\x92\xa8aB9\x1c\x00\x8c\x07\x00\xd0\x1c\x00\x8d\x1c4!\x81&Z4\xab\x1d0\xf0\xf5a\xe3\xea\xc3\xc7\xd5^79\x99\xb36\x1c\x99\xb9\xf6\xe3y}~w\x97NV\xf3F\xf3\rf\xbdf\xbdHo.\t\xa9MCR\x9d\xc5\xad\xdf\xcb\xbf\xbe\xf3\xf9\xbd\xa7\xa3\xc1o\xd1\xf2z\xdf\xa5\xf1r\xf8}\x14\xe3\xa5\x18\xebVzS\x9e\x95\xcd\xfec\xf4~\x9b\xbb\xf3\xfd\xbd\xcf\x9f\xdey\xe3\xca\xf6|\xec\xbd\xbc\xbayv\xd5\xc7\xa5\x98\xdd\x99\xcd\x1d\x15\xef\xad\x98\xe9\xa3\x9e\xb5\xf0\xb7\xf3Y\x9d\xa6\x9fK_Me\xe9\xd3\x07\\r\xfd~h\xdc\xe9\xe5\xbd\x1c\xf5vu\xa3-\xbc\xa6\xfe|\xe9\xc6y\x9d\xbc\xdc\x1f\xa3\xe2\xf2\xdfc\xc1\xc9\xfa\x1e|\x1e\xee\x18\xbdX\xd9\xc3\xd5\xe9~G\xd2\xf5\x9f\x1b\xe9z\x1f7\xb3\xa1\x9da\xb9\xf2\x9e\x9f\x17c\xe9\xf9\xfe\x9b\xbf\x1f\xab\xef\xf1u\xf6\xf3\xdf\xd7\x90)R\xc6U\x9a\x96*\xa1),E*\x95\tSQ\x94\xa5\x00*\x00\x80h\x0c \t\x18\x0e\x01\xa00\x81\x1a4p\xe0\x1a4\x86u\xcc\xf3{x\xfe\x7f\xa3\x9f\x97i\xcc\xc9\x96W\x9dc\xe7\xd7\x8d\xc3\xbf\x03\x17\x99\xbc4\x19\x86\xa4.cd5\x0b\x1c\xa4\xadcU\xea\xd9\x9e\xba9\xf5\xfa\x87?\x9b\xdb\xf4\xf9\xba\xbf{\xe4\xf6\xb1\xe3\xab\x97Jq\xd2\x9e}i\xcfJs\xb8N\x9f\x8e\xbe\xe7\xd5\xf4\x1f7\xdd\xd1\xf2\xf4\xab~~g\xaf\xc3\rcW.\xbax\xf4\xbb\x9e\xa4\x94\xec\xddo\xe5\xd2\xfe{\xd9\xc3W\xf3\xb6g\xa4\xf3\xda\xdc\xf6\xabW\x1f~x\xbd\x1cy\xde\x9e\x13\x8d\\w\xa7\x96\xf4s\xde\x9ew\xa1\xe7\xd6\xceY\xa2\xf1\xe6\xfa|\xdc/\xa1\xe2\xf3\x7fW\xc5\xcc\xfa\x1c1{8\xe4\xf4c\x7f\x9f\xd7\xe8\xfeW\xd1\xf5\x9f\x1b\xe9w\xfc\xde\xbe\x94\xde\x06|\xa7\xab\xc5\xd7\xfa^\x7f\xa6\xeb\xc9\xea\xfa\xfc}}\xbc\xfa:\xf1b\x96*\xa5R\xc5R\xac\x85BU,E*i\tP\x94\x80%,\x06\x80\x0c \x1c\x08@0G\x00\xc2\x04\x924!\xc04\xcb\xcb\xbf\x0f\xcb\xf4\xf9\xde\x7fd\xd1Fy\xbew>\xbc.]8\t\x8f\xa6g\x94\xb3\x89\xcc\xa6c\xa9\x1b#b\xa9C\x9a\x01al5\xbb\xb1\xd7\xa5\xe5\xe9\xf5\xae\xdf\x16=\xe7\xb1\xfd\'\xe74y3_=\xd3\x8e\x95s\xe9FzU\x9e\x95\xe7\x7f\x8b\x7fG\xf4z\xde\x1fV\x8ev\x9e\x9c3\xf4\xe3)o\xe7\xd2\xccY\xe6\xc7Y\x86\x83s\xce\xad\xc5\xbb\x9d\x94JnS\xa5\xb8\xe9V\xd9;s\xc7\xdf\x8eN\xdc\xad\xce\xb4r\xde\x8e[\xbf\x1b\xd3\xcb]\x0f.\xef\xe7\x9azq\xe7z|\xdc\x8fw\x97\x8f\xf4|\xb8}\x9cr\xf7\xe5GL\xf4<\xfe\xce\xf7\xcc\xf7\xfa\x7f\x8f\xf4{~?_S\x9f^v\xf9yo\xa3\xe0\xf4>\xbc\xfd_<\xbdf\xfeN\xde\xdem\x1dx\xb1\nX\xaa\x95*\xcdB\x05P\x95K\x15J\x94\x12\xa0\x80\x14@r0\x00\xa7 \x01\x00\xe0\x1a\x10\r\x1c\x8d\x18\x04\x01/?\x87\xab\x81\xe4\xfa\xd99\xf6\xc95\xca\xc6\xfc\xff\x00=p:1u[\x99fe\xb8\xcd\xb8\xcc\xb3\x95b\xb9\x8d*d\xb2\x1a\x8dCV\xbdn\xcc\xf4\xd3\xcb\xb7\xa4\xf2g\xe9\xbe\xcf\x87\x93\xa6\xbe\x85\xfa\xaf\xca\xd5\xf3\xfa\xd5\xcfucus\xe9N:U\x9e\x95\xe7\x7f\x97>\xbf\xe96s\xebn)3"\xdc\xdb\xa6\xa7\x9b(h\xa8\xb6J\xf1\x1c)!P\xd5-\xae\xcc\xfd3\x97|r\xf5\xc3\xcd\x94\xae\x14\x84_\xc2\x93\x9e~\x9c\xb0w\xe1\xcf\xf4\xf0\xc5\xea\xe3\x8f\xd1\xcb7nto:9v\xdf\xe4\xf5u\xfc\x1e\xbe\x9f\x8f\xd3\xbb\xcf\xd7\x9d\xe9\xe1\xc1\xfa>/Yz\xfd\x8f\xcb\xafd\xf9\xbb\xfbyt\xf5\xe0\xe8\x88\xaa\x95*\x95B\x05J\xa1Bi\tR\x82\x12\x90\x00\x050F\x10AM\x08\x06\x129H,!\xc8\xd1\x8d\x00\x02\x18\xd73\xcf\xed\xe0\xf1\xfa~S\x97\x7f7\xc7~\x7f\xac\xc5\xdeK6r\xdb\x8c\xdd\xcf6\xe2[\x8c\xca\x0b\x98\xd2\xa8\xd4\xa1\xc2\xaa\xf4\xabZ\x94\xeb\xa3\x97m\xfe~\xbe\xd3\x97\x8b\xdc{~Q\xec\xe5\xed~\xf7\xe7\xa9\xf0\xf5\x865W=\xd5\x9e\x95s\xe9N7\x06\xfeY\xee\xfd\x8d\xcc\xd9%\x8c\xcd&\xcc\xd2RN\xe6H\xd1\xa3\x01K\x15\x8a\xab`\xa8\x85B\xd8\xc0"5\x12!\nJ\xec\xa3Y\xcb\xbc\xd5\xac\xd1\xbc\xd1\xbc\xe6\xde`\xa6u>z\x96)5\x8b|\xf9\x1e\x8f?\xa9\xe7\xaf\xa6\xf9\xb1\xde\xd7\x8bo_.\x9e\xbcede\x04\xaa\x14\xa9AJ\x84\xaa\x12\xa5@\xa0\x80C\x00\x00\x86\x01D\x00\x8c\x02\x18#\x82\x00\x1c\x8eF\x08\xc0\xa3\x9f^o\x0fo\x0b\x1e\xef\x98s\xef\xe4\xf5k\xd1T\xf3m\xc2\xeer\xecf\xecK0h\xd1,iX\xa92YV\xacn\xed\xc7m\x9c{\xf4|\xbd~\x90\xf9\x1d\xff\x00g\x8bw\xdb\xf9\xfe\x8f\xaf\xcc\x8f=W\x8dC\x9e\xea\xc6\xea\xc7Jq\xd23\\_\x7f\xd1\xba\xf3\xb2\xe2\xcb\x89\xdc\xcd\'r\xeed\x92\xb1\xd8\r\x12\xa1*\x95*\x12\xa5\x04\xaa\x0bQ\x19R\xc6X\xb5\x15\x8c\xd4\x1a\x84\xd4\x1a\x82\xd7u\t\xaa\xd6\rU5U\xb4\xd6}g\xad\x9e}\x87\x9fn\xfc\xda\xbap\xbb\xa76\x80\x95J\x84\xaa\x05\x8a\xa0\x95BT\n\x08\x00J#\x00\x01\x80\x04\x80\xc2\x88\x06\x19\x8c \x82\x81\xc8\xe1\xa0\x06N]\xf9\xbc=\xbc\t\xec\xf8\xfe:\xf1zZ\xf5\x08\xbb\x16\xdc4s\xcd\xdc\xe5\xb9\xcc\xa1\xca\xc8\xd2\x15\x89\x0b#ez\x13\xa6\x9e^\x8d\xfe~\xfd//O\xab\xfa~\x05\xbe\x8e~\x93\xf4\x7f\x13\xa3\xc7\xce\xb2\x867^u_=\xd5\x8e\x95cuM\xbf\xa9,\xb9\x95\x93\xd6]\xcc\xa9\xd8\xecv=GM\x1d\x00\x80ZHP0\xa1\x1d\x14@)T\xb1\xc9MFX\xcb\x19\xa8\xcb\x19a5\x19\xa8MBj\x13U\xcd\xc2j\xb9\xb7W^Z7\xca\xfd\xf2\x9e\xf0()R\xa1J\x84\xaa\x12\x92\xa1\x02\x82\x01\x00\x00\xe8\x80\x06\x00\x00\x8e\n\x02\x01\x86c\x86\x89@\x1a<\x84R\xe2\xe5\xe9\xe5\xf1\xf6\xf9w\xab\xe3\x13Y\xb7j\xdc\xb36\xdc]<\xa5\xfc\xe5\xdc\xe4\xe6\\0\x12\x82\xb0A*\xd6j\xda\xdc\xf6\xd9\xc3\xd5\xbb\xcf\xdf\xb5\xe5}W\xdf\xf9\xec\xddu\xee\xff\x00O\xf9\x83\xc7T\xb0\xc6\xa1\x8dW\x8d\xd5\x9d\xd7\xcf\xa43\xa8\xfdK;\x99\\\xca\xc9Y-Gc\xb1\xeaKRV;\nv\x14\x0e\x84c\xb0\xa7d\xb5\x1e\xa1K5f\x80,\xd8\xe2\xc6X\xe6\xc6j2\xc6j9\xb1\x96-Bj2\xc6j\x13JTN\xe6\xcdbz\x8e\xc5\x02\xa5B\x12\xa9R\xa8\x04\xa0\x81P\x05(\x00\x11\xd0\xa4\x03\x00\x08\x10\x1c:\x02\x00\t\x1c9\x00\x80\x02#5\xcf\xe3\xeb\xe4\xf3\xf5\xf8i\xe8\xf9\'D4\x85[\x85\xfc\xee\x9eWG)ns)\x1c\x8a\x85b\x159\x15\xcd;\xccn\xae\xc7}\xbe\x7f_O\xc9\xdf\xd6r\xf2\xfb\xdf\x7f\xc6\x8f\xa7\x9f\xb9\xfd\'\xe7+\xf0\xee2\xc3\x1a\x8eu^5^7^:W5\x0f\xa5\xa9\xdc\xce\xe6Z\x92\xb9\x96\xa3\xb1\xd9-Gd\xac5\x1e\x8c,\x06\x16:c\xd4\x05\x11\xcd\xaf\x9e\xea\xe5\xd1E\x9d3\x7fnR\xdcX\xb1\x962\xc75K\x19c4\xa5R\xc6X\xaceJ\xa5\x06\x14\x00\x81P\x84\xaaP\x15\x08@\xa8@\x02\x00\x00\x18\x00\x00\xc6\x10\x04\x008` \x90\x1c9\x00\x80\n\xb1\xbeo\x1fw\x17>\x8f\x97\xde\xbf=\xeb+\xd5\x0b\xf9\xdd\x1c\xae\x9eM\\\x96g.A\x98\xda+\x10$\xa4\x86\xb3V\xf2N\xbayzz>oOC\xcb\xdb\xdf\xdf\x99\xe9\xfd\xdf?g\xd6\xf0\xfa\x8fo\xc8\x8f\r"8\xd43a\x8d\xd7\x8dW\x8d\xc2n\xaf\xa3\xb9\xdc\xcfY\x9e\xb3+\x1e\xa3\xb2V;\x99h\xf5\x1d\x8e\x9d\x8e\xc7E\x80,qa\x8b\x0e{\xaf\x1a\xa3\x1dqr\xf4\xf2\xf1\xee\xb2\xe3\xb3\xdb\xe7n\xf4y\xb4\xfa8\x89\x1c\xe9K\x19c\x9a\xa5\x8a\xa9R\xc6U\x9b\x05\x15J\x86\xa8\x00J\x84\x12\xa1)\tU%B\x00\x10\xd1\x00\x00\x00\x0c\x00`\xac\x07\x08 \x18C\t\x00\x08r\x08*\x97>:\xf2\xf8{x\x17\xb7\xc75\xbf/\xd5\n\x94_\xcfZy]\xae\x87\x9b\xd3\xb7\x86\xfe\xa5\xd3\xe3t\xbd~^\xe7\xdf\xf9\x1d\x9c\xf8\x94\xaa#\x8dG6\x18\xd5x\xddy\xdc3\xaa~\x8fK5\x99\\\xcfY\x95\xcc\xa9\xeb.\xc9h\xecz\x8fQ\xd9+\nQ\x1cj8\xb0\xc6\xab\xc6\xe8\xc7\\\\\xfd<\xcc{x\x99\xf7y\xddw\xf0\xfe\xae\x1aS\xeb\xbe^\x1e\xc7\xd3\xf0\xb6\xfa|\xd6t\xc2\x962\xc75J\xa5\x8a\xa9Q\x19\xa5*X\xca\tHJ\x81P\n\x12\xa5\x04\xa8B\x10\x08\x00\x00\x10\xb4@\x06\x00\n\xe0\xa6\x10\x0c\x02R\x04%`\x120\x81\x08R\xe3\xe7\xe8\xe4\xf2\xf5\xf9\xadv\xf8\x7fG;V\x159n\xe7uq\xbay.\xc4s2\x99U\r\x1c\xa1,\x9b\x030\xd4\'[\xf1\xe8\xd7\xc3\xd7\xbf\xcf\xdf\xa5\xe6\xd7\xd6}\xbf\x9e\xaf\xb3\xdb\xfe\x93\xf3v\xf9\xb2\n#\x8dFXcP\xc6\xab\xce\xe1\x8dS\xf4z\xcfX\x9e\xb3=fV;\x1d\x92\xd4z\x8d\x1e\x92\xd4Q\x0ez\xaf\x9e\xe1\x9dBj\x13Yq\xdf\x97\xcb\xdd\xc3\xcf\xbf\xcdo\xd1\xe4\xfb\xe3\xca\xfa<\x9c^\xfc8\xbe\xaf(\xc7\xde\xfe/\xda\xfa\xfe~/[\xd1\xe0\xd3\xdf\x8b\xa8\xca\xb3b)\xa8\xa9\tc*\x95\nT\xa9T\xa0\x95\x08%BQP\x84!\x08\x01\x154,\x00\x00\x07 \x00\x03P\x14W(\x03\x08%\x10W \xa4\x80\xe0\x92-`\xe3\xe9\xe3c\xd3\xe3:v\xf8\x87\\U\xad\x0bfn\x8eWW\x1b\xa7\x92\xdcdsdiZ\xe5d\xb3%3\x1df5<\xf6\xd3\xcb\xd3\xaf\x87\xabo\x0e\xfe\x93\x8f\x1f\xa4\xfb~-]\xb3\xef\xbfK\xf9\x8a\xfc[\x03(\xe7Q\x968\xb0\xce\xeb\xc6\xa35\x9f\xdf\xdaz\xc5\x9a\xcc\xf5\x87R\xd6e\xa8\xect\xecz\x81W\x1d\xe5\xe3\xdf/.\xf9s\xdf\x06}|\xa9\xec\xe0\xeb\xd3\xe4z\xcf\x1f\xe9\xf1\xf9\xdfW\x97/NQ\xb2r\xe6\xdf>O\xa3\xc9\xf4\x0f\x9f\xf4\xff\x00E|\xae\xfe\xcb\xbf\xc4\xd7\xdb\xcfo^jU5\x18M(B\x9aJ\xa1,eA*T\xa0\x85\tA4\x84" +\x12\x16\x000\x04\x10P,!\x85\x10JXJ\xc0\x01\\\xd3P\x03 \x07\x00@\x11\x0c\xeb\x99\xcb\xd5\xc3\x9d\xfeg\xd7\x7f)\xef!l\xa5\xbf\x9e\xb5q\xba\xb8\xdb\xb1\x99\xe7\x0ed\xb6\x14\xe5m6g\x98\xd9\x85\xcb\x96\xfcz4r\xf5\xec\xe1\xe9\xd9\xc7\xb7\xb7\xc7\x8b\xda{>e\xff\x00C\xc7\xec>\x97\xc3\x8f\r4R\xac\xd8\xcb\x1c\xd8gP\xc6\xe3.ow{5\x8b7\x89\\\xcbRZ\x92\xd6];\x1d\x86\x8b77\x9b\xb6\x1e>\xaf3\xc3\xecx\xad}\x1f;\xdb\x1e\x7f\xb7\x0eWN\\\xce\xbcq\xf4\xe3GLE\x0b#eZ\xcf/\xbf\x96\xc8\xfd\x11\xf0\xbe\xff\x00\xd7\xf9|\xae\xa7\x7f\x16\xae\xdc\x1d\x91\x9a\x8c\xa8\x162\x8a\xa2*\xa5\x04\xaaT%\x05\tR\x8a\x84D\x08\xd8!b\x00\x0b\x08,`\x80P0\x00\x00\x1a\x90\x04\x12\x83RV\xd0<\x80\x80\x02\x02\x9ct\xe5r\xf4\xf9\xfb\xd7\xe3]\xef\x88\xebk\xb6\xc9\xad\x1cn\xce:\xd1\xca[\x8c\xb9\x91\x92\xea6\xb9ZJK1\x1c\xccu\x89N\x97s\xf5h\xe7\xeb\xd9\xc3\xbe\xae=~\x9f>_{\xd3\xe3\xeb}\xdf\x93\xdd\xcf\x86H\xd0\x88\xe6\xa9c5\x1cXcQ\\\xbe\xefE\x9a\xe7f\xb3=fZ\xcc\xb7\x1aKP\xa7c\xa5\x9b\x93\xcb\xdf\x9b\xc3\xd9\xf3\xbe\x7f\xa3\xf8\xd7\xbf\xcb\xca\xed\xe7\xafP"\x95k\x15o4\xef\x02UsF\xf1\x93\xaf\x1ew_7\xd0\xbe\x7f\xd6\xfd\x17\xf1\xfd~\xdb\xa7\xc7\xd7\xdb\x85\xdd9\xc6T\xa9T)P\x95J\tT\xa8J\n\x05\x8a\x8a\x84"(\xaeJ@\x80\x80,hS@\x00\x01\x05\x11\xa8\n\xc0\x00% \x95\x80M5%y\x00\x01\x199\xf5\xe4c\xd1\xe6\xf7\xd7\xe1\x1d\xe7\x03\xa1-\xd8\xde\x9eZ\xd7\xc6\xdf\xceK9\x19bi]2Y\xcc\xf3\x99H\xae\x02\xccu\xbf\x1e\xbd<}z\xb9w\xe8y\xb7\xf5\xee\xdf\x19\xf6\xe7\xec\xfe\xff\x00\xe7t\xf0\xe7$h\x0b%,f\xa3\x9b\x0cj2\xe6\xf7w\x9e\xb1n\xf1;\x99o2\xd4v:,t\xf4$\xcf\xe7\xed\x83\xcf\xea\xf2\xbe\x7f\xb9\xf2\x1fG\xaf\xc1\xfb\xfe}\x17\x9d\x92\xb9k\xd6j\xd6s\xf4\xe7\rJ\xeei\xde3o\x9e\x0e\x9e{\x17\xef\xbf\x13\xee\xfd\xaf\xcd\xe1\xe9\xf5\xf1i\xeb\xc5\xd8\x95J\x85*PJBT\xa8\x05(%BT"7)\n@\x88,\x02\xe5\xa0\x03\x00\x01S\x00\x00\x01\x84\x03Q@S"Pp4\xe5y\xa0\x11\\<\xba\xf1\xf1\xdf\xc8\xf5\xeb\xf0\x1fF2\xe85\x7f=\xea\xe5\xad\xa7\xeb\xf8\xd4t\x9f@\xfd\x0f\xe5\xe1\xe2\xe8\xf5$\x82,\xd2#4\xb2\x867\t\xac\xde\xee\xd6k\x16o\x13\xd6g\xbc\xca\xc2\xc7N\xc2\x9d;\x9a\xf9t\xc5\xe5\xf4q\xf8\xfd\x1f\x9d\xe7\xef\xfc\xab\xdf\xe2\xe3\xf5\xe0\xd2QN\xb3F\xb3F\xf1^\xb1V\xa5:\xe7F\xf1\x9b|\xb0u\xe1\xed|\x7fC\xf4\x8f\xc5\xfa\x1e\xdb_/W^\x17o\x9a\x14\xa9P)\tP*\x12\x90\x84\xa2\xa1\x11EbB\x92 \xa1\x0b\x91\x00\x1a\x01@\x025H(\x03\x00\x00Pp+P \x94\x94\x86\xaeh\xcdek\xcd\xe5\xdb\x8b:\xfc\xf3\xbfO\x8av\xcd[9\xad<\xb5\xaf\x8e\xf4\xf2O(\xd9)\xa8R\xb5\xc8\xd8\xb39\xb3\x12s\x11K\xb1\xd2\xfcz\xb4r\xf5\xdf\xcf\xbe\xde\x1d\xbd\xaf\x1f7\xbd\xf5\xfc\xd7\xea\xf3\xfbO\xab\xf0!\xe5\xdc\xae]4!J\xa5\x8els\xa8\xe6\xe6\xf7v\xb3x\xb3Y\x96\xb3=\xe6Z\x8e\xc0v\x14\xe9\xd9\x1c\\\x9en\xf88z\xfco\x1f\xd0|\xcf\xd3\xd3\xc8\xfa|y5\xce5^\xb3\x9fX\xa3y\xae\xe6\xad\xe2\x9d\xe7>\xb9Sq\x87\xb7\x11>\xfb\xf1~\xf7\xda<~^\x9fO&\x9e\xbc%b\tR\x82\x08V\x90\x95\x00*\x12\xa1\x11\xb9HRD\x16\x08\xaeX\x02:\x10\x18\x00\x00\x80\x00`\x00\x00\xa0\xd4\x86\xa0J\x04\xa4\xae\t\xa75L\xbc\xae}\xb8\x17\xaf\xc8\xbd\x1a\xf9\xb7IV\x93\x9a\xd7\xc6\xeb\xe3\xab\xf9\x01S\x9b\x86\xaa\xb5\xccJb\xdcfy\xc8\xcc\xa5\xbf\x9f]\x1c\xbdzy\xfa\xee\xe7\xdbw\x9f\xaf\xd2\xf3\xe0\xf4\xde\x9f\x17G\xeb\xfc\xce\xf6|.I\xb2\xec`\x10\xa5\x8eQ\xce\xa3\x9b\x9f\xdf\xdaz\xcd\x9a\xcc\xf5\x99o2\xd4v\x160\xa7DG6\x8e=2\xf0\xf4y\x9f?\xda\xf0\xda\xfa~\x0b\xd7\xe4\xe4u\xe3\x8fx\xae\xe6\xadf\x9df\x9df\x9d\xf3\xa3Y\xcb\xbet\xef\x19\xb7\xcf\x0f_?\xb4\xf1}\x0f\xd1\xdf\x1b\xe8\xfblxuu\xf3\xe8\xe9\xc8\tP\x81P*\x12\x80\n\x84!\\\xc5\x92\xd8\xa2\x0b\x0b\x91\x01\xa3\xa1\x18\xc2\xc0\x10\x01\x00\x80\x00\x15\xa14\x00\x02\x80\xae\x01\xb4A))\x04\xb9\xb3\xd3\x93\xcf\xb7\x99\xe9\xd3\xe0\xfe\xb7\x97\xd4\x9euw=i\xe5\xad\x1c\xad\xb9&KSQ\xbbT\xe7;3\x8bq\x99Ifc\x9a\xd1\xcb\xd1\xa3\x9f\xaa\xfe~\x9d<\xbbm\xe1\xbf\xaf\xdf\x9b\xb3\xd1\xc3\xd2\xfd\xaf\x87\xb3\xcb\xcez\xcc\x92L\xbb\x18\xb3P\xb3c,sh\xf7\xf5\xb3Y\x96\xa4\xee\'\xbc\xcbP\xa2\xc6\x14\xe9Dy\xea\xae{\xcf\xcb\xb7\x1f\x87\xd0\xf2\x9c\xfe\xdf\x88\xf4<\x8f\xa7\xcd\xcc\xe9\xc7=\xc5Z\xcdvS\xac\xd1\xbcg\xde2t\xe5\x9f|\xe8\xd7<]9+\x9f\xbc|o\xbb\xf6\xaf\x9f:\xfb\xf2k\xeb\xe7\x9d\xca\x12\x8a\x08J\xa0\x82\x8bA\x11\x15\xc8\x8a\xc4!X\\\x00\t+\x01\xa3\xb0\x18X\x08\x04 \x00\x00\x94\x18\x02\xa0W\x00)+\x05%r\x91\x19q\xe3\xaf\x1f\x1d|\x7f\xa3\x7f\x9b~\x97\x0e}\xd6\xae=vp\xde\xde\x1b\xbf\x92\xcc\xc8\xdc&\x95\xd45\xa6Js\xb3\x19\xb7\x19q9ts\xebw?E\xfc\xfd7c\xbfC\xcb\xdb\xab\xc6\xfd{\xd1\xf2\xe8\xeb\x9fy\xf6\xbf7_\x8f\xa4\xaeed\xa6ec\x08P\xa5\x8c\xaaZ}\xdd%\xbc\xce\xc9\\\xcfy\x96\xa1N\xc2\x81\xd0C\x95\xa7\x9fJ\xb9\xf4\xc9\xcb\xbf\x9d\xe1\xf6<\xa6\xfe\x8f\x82\xf5y\xb8\x1d\xb8`\xe9\xca\xab#d.s\xef97\xcb\x1f^Y5\xce\x8d\xe3>\xf9`\xeb\xc3\xd8\xf8\xfd\xff\x00\xa2\xfe?\xd5\xfa\x07\x0f.\xce\x9e{\xf7\xc4U\tJJ\xa0\x00\xa5@\x84\x8a\xe4\xa4\x91\x0b\x92\xe4\x04\x07c\x0b\x1a;\x01\xd0\x80\x84*$@\x00\x03\x94Pr\xa1\x89Ep\x95\x8eP&\x88\x8c\xd7;\x9fN,\xeb\xe0=\x97\xf3\xd7\xbb\xcd\xce\xed\'\x9d\xee\xf3w\xe8y\xbam\xe1l\xc1\xcc\x95\x1b\xb8j\xca\'\x8c[\x9c\xcf!-\xc6\xb4r\xef\x7f?U\xfc\xfd\x17g\xb6\xef?_]\xc3\x9f\xd2}\x1e\x08\xf7\xe1\xee~\xb7\xe7\xb3\xf8\xfb\xc9\x99X\xec\x94\x8d\x00\x16leQ_\xd0\xdc\xee^\x93\xb9\x96\xb2\xf5\x1d;\naN\xc8\xe2\xd5\xcbus\xe9O>\x9c\xce>\xdf/\xcf\xedx\x7fF\xfc?\xaf\xc7\xcc\xeb\xce\xab\x98\xc8\xac\xa3Y\xc7\xd3\x96>\x9c\xf1u\xe3\x9f|\xe8\xd62o\x94n~\xe5\xf2>\xdf\xdc~oN\xcb\xcd\xab\xaf\t\xb0\x95(\xa8J\x05\x80\xa8A#a`\x88V\x17)\x1a\x16\x03@v\x17.\x81\x85\x08\x84\x82\xa0\x01\x00\xe0\xa2\x00\x1c\xd0\x12\x80\xa2\x90+\x95\x8aZ\xf3\xaeW>\xbcK\xd3\xe6_B|\xbb\xd3\x8ed\xcf:\xa9\xba\xd1\xcb[\xbc\xfb\xd3\xca\xe8\xe6\x96t\xd66\xca\'\x9c\xd9\x98\xe4q~:i\xe5\xde\xeczn\xe7\xea\xb3=v\xf9\xfa}+\x8f\x9f\xdaw\xf1\xe9\xfa?;\xd2k\xe7\xaez\x93-%cF\x8c\x05\x92\xcdR\xc7\xe9YX\xec\x95\xcc\xb5\x1e\xa3\xa7\xa8\xe8\x18XD9\xea\xae{\xa7\x9fL\xdc\xfby\xff\x00?\xd6\xf1\xfa\xfa_:\xf6\xf9\xbc\xe7\xa3\xcdF\xb2B\xb2\x9dg.\xf9\xe1\xe9\xcb\x17^Yu\xca\x9d\xe2\x9b\x9c}8\xfa\xcf7\xb7\xf4?\xc6\xfa\x9fD\xf3r\xd9\xbe\x1a7\xc5*P@ B\x8b\x10\\\xab\x01 +\x82\xc1\x18X ;\x15\xcb\xb0\x18X\x00\x08\x00@\x00\x000R\x00\x05r\x92\x80\xa4\xa2\xb8\x176w\xc9\xe7\xd3\x89\xbd\xfc\xbf\xe8\xbc\xa7i\xcc9\xb9\x9832UW7b\xeb\xe3\xbd/K\xcb\x86\xcc\x92V9$\x8e\xc6\x8aR\x16Q\x9a\x7fM+\x97d\xacv=GOQ\xd3B\xc2\x99\x1cZ\xf9\xee\xae})\xe7\xd3\x9b\xc3\xd9\xe59\xfd\xbf\x9f\xfao\x80\xf6\xf8q\xef\x04\x85U\xac\xe4\xdf<]y\xe3\xdf,\xbb\xe5N\xb1\x0b1\xf4\xe5\x1b\x9f\xb4|\xbf\xb3\xf7?\x97\xdb\xbb\x9eZ\xf7\xc2\xcb\x94%V\x00\x85\x08RB\xe4\xa4\x82+\x92\xc0\x11\xd8 \x16\x17%\x80\x02\x14\x00 \x08(\x00\x00\xa0\x00\x00(\x10\xd4\x94\x94W()q\xe3|\xac\xf5\xe2t\xdf\xcd\xbd\xee?I\xcf\xd3\x9ea\x8cx\x98\xa6r%:\x8a\xac\xc6\xf4\xf2\xde\xbe7G+n(\xcd\xb9\xde\xce\x1e\x9b\xf9\xfa-\xc7{q\xdbW\x1e\x9d\x1e\x1b\xfb}\xf1U\xd7\x9f\xb8\xfa\xdf\x9d\x8f\x8f\xa3\xb2VI\x99@\x92A\x1c)T,\xd9\xfdL\xcbQ\xd8\xect\xec*Z\x8e\xc2\xc7@\x01\x0ez\xab\x96\xea\xc7L\xfc\xba\xf9\xcf7\xd9\xf1\x1d>\x87\xcc\xfd\xfe/=\xdf\xcd\x10\xb2\xbb3o\x18\xbar\xc7\xd3\x9em\xf2\xa7X\xad\x9a7\x8c\x9b\xe7\xe98z\x7fC|\x7f\xad\xf4o\x1ez7\x9e\xad\xf1B\x15\x80!N\xc4\x17%\x82 \xb9.@F\x85\x80"\xd6K\x00B\x98\x02\x08!@P\x02\x1c\n\x00(\x12\x80\n\x04\x12\xb9\xa0&\x91\xcf\xc7N^zpz\xeb\xc1{u\x93S\x1db\xacv\xe2L\x99\x98r\xe73\x83\xa62v\x84\x9d\x0f6\xf6p\xde\xae:\xbb\x1a\xd3\xc7\xae\xce>\x9b\xb1\xdeY\xed)\xd7_\x1e\x9e\xb3\xca\xfa\xe6\xbc\x99\xfbr\xfa\x07\xd4\xfc\xee_\x17\xa2V4\x95\xcb\x92CF\x84\x10\xa1gV}ns\xd6]\x08\xe8\n\x96\xa3\xd4v\x03D\xad#-|\xb7_=\xd3\x8e\x9c\xaf?\xbf\xc8r\xfb_:\xf6g\xe7\x9e\xef\x06m\xe2,\xd5\xb9M\xce>\x9c\xb1\xef\x95\x1b\xe7M\xcdz\x94\xdcf\xdf5g\xd8~o\xd6\xfb\x97\xca\xf4\xfaN|\xf7\xeb\x9d\x97\x11\x0b\x12:\x10\xa1\x92\xc2\xc0\x10\xb8(A\x1d\x08X\\\xab\x00B\x81\xa0\x89\n(\n \xa0\x06(t@\x01(\xa4\xa0\x02\xa8p4\xe5\xaek\x99\x8e\x9c\xc9\xbf1\xe8\xbeK\xd5\xbaJj\x85\xce\xb9+\x12c\xac\x19b\xcey\xda\xcf7\xb7\x1a\xb5.\xe7\xab\xf1u\xf0\xeb\xd8\xf1z6y\xfdw\xe3\xbc\xa7[1\xd7W-\xfd#\xc9>\x85\xbf-\xde\xdf\x0f\xad\xeb\xf3*\xe1\xd2L\xba\x93-$\x8c$hB\x95K\x7f\xd7\xe5-G`\x00;\x1e\xa3\xa7cB\x88(!\xcfus\xdd|\xf7\x93\x8f\x7f7\xe7\xfb>\x1b\xaf\xb7\xe5\xfe\xff\x00\x07\x97\xf5y#U\\\xd1\xbc\xe4\xdf,\xdb\xe7M\xc5\x1a\xcd[\xc5L\xd7\xa9F\xb9\xfa\x1e>\x9f\xbf\xfc\x8f\xab\xf5\x0f\x0b\xa2\xe7\xaf\\\xd2\x142\x0fA\x15\xc1L\x02\xf3t \x8e\x84\x03XV\x03EB\x08#\xa5@X\x00\x00\x00\x00\x00+\x08\x05\x03D\xa0(\x10\xe6\xa8\xce\xb9X\xe9\xcb\xbb\xf1\xfe\xadp\xbb\xda\xe6\xa0FZ\xda\xa2\xb3\x99,\xcb\x18m\xc5\'>c\x03<\xde\xfc\xb2\xf5\xc5\xb9\xdf_\xc3\xe9\xebx\xfd\xbb\xbc\xfe\x9b1\xda\xfe}vq\xd7\xd9<\xb8\xf4\xbd<\xddo\xa7\xf2:\xfel4\x94\x92GcI#\x91\x82\x19%Q\xab\xecqz\xcb\xd0\x80\x07EJ\xe5\xd1`\x00\x02\xcd\xab\x9e\xeb\xe7\xba9u\xe5p\xf7\xf8\xfe\x7fg\xe7\x1e\xde_4\xfa\x1f;\x0fNUk4\xdc\xe5\xe9\xce\x8ds\xa6\xe6\x9d\xe2\xab\x9a\xec\xae\xe6\x9d`g\xeb_?\xeb}\xdb\xe5z\xbdG\x1cm\xd7;\xaeR\x08\xecv\xab\x86\x85\x08X\xeeDhS@5\x81\n\x10\xa4\x8d\x0b\nA`\x00!\x89\x00\x05`\x00\x00\n\x04\nf\x8a\x82P\xcb\x9e\x9c\x9ct\xe4k^+\xd9\xac;\xb5\xac&\x89b\xb1\x96\x11R\xe7\xac\x95\x8c\xc0\x98S\x9f3\xcb\xdf,=\xf2\xae\xfa>_gO\xc9\xec\xd5\xc7\xafG\xcb\xdf\xa7\xe5\xdf\xde\xfc\xfc_n^\xd3\xe8|;<\xdb\x929\x99H\xc7cF\x92\x90\x82\x10\xa5\xd9\xf6\xbc\xefQ\xd0\x00\x08\xe9\xd8\xec(\x00\x80"\xbcn\xae{\xa7\x97\\\x9c{\xf9\xee\x1f[\xc1\xf7\xf5\xfc\xaf\xdf\xe0\xf1\xde\xdf\x15:\xc5W9\xf7\x8a5\x8anj\xd6*\xb9\x85\xcc,\xae\xe6\x9d\xf3\xefq\xf4\xfd\xf3\xe5}_\xaa|\xfdt\xa7=\x9a\xc4\x90Gaar\xe9\x82\x17\x0e\x84v\x08\xc2\xe5\\\x94\xd0\xa0\x18(\xa5B\x00$\x04\x8e\x94\x16\n\x025\x14\x86\xa8\x00\x14\x083F\x81K\x87\x1b\xe4M\xf1:\xef\xc5z\xf7UBj*Jf\xa5SJZ\xe2\x9a\xcdn:\xc6\x9c\xf4\xe71\xc7\xdf>\'\xaf\x94o}\xde\x7ff\xbe>\x8e\xbf\x8b\xd3\xed\xfe_O\xbby\xb8\xe4\xeb\xcf\xe8\xbe\xff\x00\x81\x9b\xc9\xe9v6e#\x91\xd3F\x8e\t\x08 6\xfd\xcf3\xb1\xd0\x8c\x02\x81\xd8P:\x05\x04\x11\x19\xaa\xb9n\x9e}3\xf2\xed\xce\xe1\xec\xf1\x93\xeb\xfc\xdb\xdb\xc7\xe5\xdfK\xe6r\xfa\xf0\xa7X\xa7Y\xa6\xe2\x9dJ\xb5\x8a\xeek\xb2,\xc6\xca\xb5\x94\xcf\xd4\xbc_S\xee\xff\x00\'\xd7\xea\xf8g\xa1y\xe8A\x97E\xcb\xb1\xd3B\xe1\xd0\x8d\x1d\x08\xd1k\x05\x08\xc2\xc1\x044HP$\x05`\x80\x00\x00\x00\x00\rE PA+\x84\xa4\xb1^n7\xc9\x9b\xf3\x9d\xf7\xe5=:\x85\xb0\x96\x12\xc9\xa7\x9a\xa5sJX\xcb\x02\xa5\xcfm\x06*\xe4\xdc\xf9\x9e\xbe\x7f?\xe9\xe39\xd7W\x1fMz\xeb\xa3\x9f\xa3\xed\x7f\x9e\xf5\xfd{\xc9\xc9z<\xde\xeb\xd1\xf2s\xf9\xbb\xbb\x1a9\x1c4h\xc7#\x91\x04\x07G\xefy\x0b\x18\r\n\x00\x07E\x8cJB\x82\x14\xb5\xe3T\xf3\xebO.\x99yz<\xef\x1f\xab\xf3\xefGo\x95\xfd\x0f\x0f\x86\xf7|\xea7\x8a\xd2\x8db\xadf\xab\x9a\xeecs\x0b#ew5\xeb\x1d\xae^\x8f\xbb\xfc\xdf\xab\xf5O\x9b\xae\xcey\xeffV;\x06ec\xb0Gc\xb0\xb1\xa3\x1a+\x93PB\x84h\x91\xe8\xac\x01\x10X\x90\x00\x12\x00\x80\x00+P\x00Ch\x82\x14\r\x00\xb5f\xf2\xf3\xd3\x92\xd7\x93\xf5o\x83\xdbPj\x0bYfk\x9a%r\x93K:E\x05r\xc6\xeb\x9f\xac\xf0u\x9f9\xd3\x8e\rcg.\xdax\xf5\xc5\xbb^\xb7\xfa7\xf3\xbfC\xda\xf3\xe7\xd0\xf7\xfc\xde\xcf\x96\x994rHr\x03G#@2R\x91\xda\xfd\x1f\xcdv\x03\xb0\x1d\x14@\x03\xd4\x02\x14\xaa\x14\xaa#\x9dU\xcf\xa5<\xfag\xe5\xd7\x9b\xc7\xdb\xe2\xdfK\xe6\xbe\xee?*\xfa_/\x8b\xdf\xcbU\x95\xdc\xd5\xa9M\xc45+\xb8U\x0b\x98\xd9]\xe6\xd3\xe9~?\xa9\xf7O\x95\xec\xf6~\\\xf4\xdc\xf4\xd8\\\xb2V;\x0b\x97c\xb1\x92A\x0b\x92\xc2\x84(`\r\x0b\x01\x02+\x04\x01\x12\x00\x80\x00\x82\x9c\x00\xa2\x8a\x00))\x04%\x176o+=9:\xd7\x8b\xf6o\x99\xbdF\xd8\xcb\x05\x94\xaf:sO"h\x95KE\xb4\x14W+S\x83\xacr\xf7\x9b\xf9\xeb\xad\xcb\xac\xf3\xac"\xe5\xaf\xd1\xdf\x1f\xdd\xd0\x9c\xfd\x17\xbb\xe6C\xe7\xfb\xdc\x8d%\x020\x87#\x90\x82\x01JG\xac\xfdO\xc0t!N\x9a\x14\n\x01\xd0\x10\xa5P\xa5R\xc6XcT\xf2\xe9G>\xb99z<\xff\x00/\xa7\xf3\xdfOO\x95}\x1f\x0f\xcf\xfd\xdf/>\xb3^\xb3]\x95k5\\GY\x8dE\x98\xd9\x0b\x88\xdcu\xf9\xfa>\xe1\xf3\xbe\xa7\xd7>g^\xde9\xefb\xcb\x1d\x8e\xc1\x99S\xd4h\xd2H\xaeK\nhP\xc8\x85\x1a\x80\t\x12\x16\x08\x90@A@\x00\x08\x06\x01\rP(\xa0JA\t\xacY\xd7"o\x8d\xd3^#\xd9\xbc\xf6\xc6\xd8\xca\xa6\x9a\xbc\xe9\xca\xe6\x883\xa5-\x16\xe1\xd4\xe6jp\xb5\x8e.\xb1fw\xdf\xe3\xdb\xaf\xcf\xa1.T\xd5\xc7\xa7\xde\xbeG\xa7\x1f\\{^\xbe\x1e\x07\xcf\xfbNG#G\x049\x02R\x10A\n\x14\xbfA\xfd\x7f\xe4]\x8f@v:\x00\x05\x00\x04)T,\xd4\xb0\x95gUs\xdd\x1c\xfa\xe6\xe5\xdb\x9d\xcb\xd9\xe3o\xbf\xe6^\xff\x00?\xca~\x8f\xcc\xf3\xfe\x8f\x1c*\xbb\x9a\xf5+\xb9\x86\xb3\x1b"\xccncs\ra\x9fF\xf1\xfd/\xba\xfc\xbfg\xb7\xf2N\x9b\x1a\xd9v:w/Q\xa4\x92H!\xac\x94#\x0b\x06@\xd0\xb1\x02$\x11X \x88(\x12\x00\x00\x00\n\x00\x00J\xd5(\ne\x19y\xd3|y\xaf=\xdf^?\xd7\xb8\xaa\x96*K)\xa74JJ\xa6\x89h0\xe9\xc9\xd4\xe2\xeb<\x1dr\xcf\'\xa1\xe3\xe9\xf4\x9c\xbbtq\xba\xe5\x84zO/o\xa8|\xfd\xe4\xed\xcb\xbb\x9d\xf9\xef\x9b\xf7\x1c9\x18\xe1\xc8$\xa4r8 \x91f\xac\xbe\xaf\xfb\x9f\xc1=\x1d\x8e\x8b\x18X\x04\xaa\x05PB\x95B\x969\xb1\x9aQ\x0cn\x8ct\xa3\x97\\|\xbd\x1c<}\x0f\x9d\xfa\xb7\xf2\xbf\x7f\x8b\xe7_C\xe5f\xd6+\xb2\x1a\xcdw0\xb2\x1a\xca\xb2\x17\t!\xacF\xe7\xa9\xcb\xd1\xf6\xdf\x9f\xf4\xfe\xbf\xf3:w\xb9\xe7\xa3qm\xc8\x92\xb1\xeaHh\xd95\x97B1X\xd9C\xd1X\x02$,@\xc8\x88T\x02\x00\x00\x02\x00\x00\x00Pp(\xd2\tk\x8e^u\xc5\xba\xf2\x1e\xad\xf9\xbfF\x89E%\x94\xd4\xa6\xa54JgQk=\x9c\xfd9:\x9c[\x8e\x1e\xb9\xe1g\xb9\xc7\xd1\xe9y\xf7\xed\xf3\xe9\xa7:Y\xb0\x8fw\xe0\xef\xec|\xa9\xfa\xfc|o\x97\xfa\x0c\x1e?\xa0\xf2r\xc8\x96l\xa5\x94\x8d\'2\xe4r<\x96j>\xcd\xfd\x0b\xf9\xb4\xb4,h\xe8\x1d\x80\x835\tIVQ\xcdY\xb1\x9a\x8c\xb1\xcdR\xd5\x8e\x94s\xe9\x9b\x9f|\x1c\xbd^7~\xdf\x98\xfb\xb8|\xab\xe8|\xcf5\xe9\xf1\xc2\xc8k5\\\xc3R71\xb9\x8d\xccnb\xccu\x92=\xff\x00\x97\xe9}\xd7\xe6\xfa\xfd\xdf\x89\xd8\x98\xd9p\xeaZ\xcb$\x8d\x1d\xc9c\xb0\n\x19\x10\xa2\xc2\x92\x08\n\xc1\x91\x10\xa8\x04@\x01`\x08\x00\x95\x84\xa8\x01A\x84\xa2\xa5\xa2^Fu\xc0\xde\xbc\x0f\xb7|\x9e\xb4W,\xa6\xa7\x9bnm\x93Nj+\x92\xcev\xaf&\xce-\xcf\x17\\\xb1\x1d\x9e=\xbd\x0f>\xfd\xact\xeacs\xcdR\xac\xeb\xea\x1f/\xb7W\x13g\xab\xc3\xe2>\'\xeb\xa3\xcb\xa3\xc9\xcb9g\x8b9\xbb\t\xc9$\x94\x8ee\xe4d\xa5\xfbW\xf4O\xe6oQ\xa0\x14\xc2\x84\x14\xc9\nU*\xc9K\x1c\xd8\xe7Q\x9a\x8c\xa4W\x9dS\x8e\xb4r\xeb\x97\x9f~\x16}\xbf:\xf5\xeb\xe5\xbe\xff\x00\x17\xcd\xfd\xdf3.\xf9\xd5\xac\xc2\xca\xf5\x98k1e\\\xc6\xe5Y\x1b\x91:\x1c\xfb\xfd\x97\xc5\xf4\xbe\xc3\xf3:z~\x18\xe9\\\xdd\xa8\xec\x921\xb2\\\xba,(d@,,\x05B\x16&D\x05B \n\x11X\x02\x00\xa2\x01(\x13@\x00\x95\xc0%\xc7\x9d\xf1\xa5\xf3]\xf5\xf3_wL\x9a\xad\\\xb6E\xf3Wf\xdb\x9a\x97\x1d\xbc\xcd^M\x9cfy\x0etY\xd8\xe5\xdf\xb9\xcf\xaf_=:|\xf5\xb3;%R\xd9\xcf\x7f\\\xf9]\xb2t\xc7w\\>\x7f\xf1\xbfR\xb3\xa2W,\xf3l\xcd\xb7:\xb4\xb2&<\xc7\x12\x90\xc8\xcb\xed_\xd0\xbf\x99\x9a\x10\x0e\xc7@\x08 \x14\xab%*\x96\xbcn\x18\xd4f\xa3(\x15\x0cj\x9ct\xcf\xcf\xaf?\x97\xaf\xc5\xf5\xf4\xfc\xc3\xdf\xc3\xe5~\xef\x9d\xe5\xfd^*\xf5!s^\xb3\x1db,\xa4W1\xb0\xb1!/\xb3\xf3\xfb\xbe\xe9\xf3\xbd\x9fH\xf0;\x13\x1b5\x99SF\xcbe\xd1aB+\x90\x19(B\x8a\x10\xb0d\nH\x08\x02\xc2\xe4\x00\xa0@\x10Q(\x02\rC4\xce\xdcEy\xf9\xd7\r|\x7f\xaf\x7f5\xf6t\x82\xb2\xc9o\x97D]\x9dEsW2\xb9\x15\xc7g\x97q<\xeb\xb1\x8e\xbd\x8e}zy\xde\xfckv5<\xea*/g\xcd\xd7\xe9\xdf;|\xce\xd8\xea\xf3\xd7\x87\xf9\x7f\xa2Y\xa4JY\xe7VKnm\xd1b\xd9\x94\xb2p\xe4 \x8f\xb2\xfe\xe7\xf9\xba\x88\xe7M\x1e\x8e\xc0A*\x85*\t^\xa2\x95D&\xa1\x8dBm\xd9+\x1d\x95c\xa58\xe9\x93\x97\xa3\xcf\xcf_\xce\xbd\xb9\xf9_\xbf\xc7\xf3\x8fo\xcd\xc3\xd7\x9c.!\xac\xc2\xe67)\x92\xc4\x16$\rX\xeb\xf6\x0f\x1f\xd1\xfb\'\xcd\xe9\xea\xbc\xf3\xa0\xc5\xb6;\x04h\xd0\xb0\xa6\x85\xc8;\x04v\x03\xa2\xe5\xa0\x85\x14\x90\n\x10\xb0@) 0\x00P!Tzr\x86\xf0\xf3\xa9\xf1\xee\xf3\xb8G3:\xe0k_?\xf7o\xc2\xf7\xd8\xb3[\xa3B\xdb,\x0c\xb6\xf3\x93\x95\\v0I\xb3=;\x18\xed\xd5\x9b\xe8\xe3[s\xadX\xd5\xd9\xaaU,\xa6\xbd\x87\x87\xb7\xb5\xf2\xda{\xf9\xf9~?\xaf\xc2\xf1}%),\xf3g\x9bful\xb7Kl\xb3\x89\xe5)\x0c\xd7\x04\x9fa\xfd\xa7\xf3\xa8cUcp\x9a\x86w\x15\x95\xc9\tE\x88\x95\x92\xb9\x95\x92\xd6f\x95gtc\xab,\xe9\xce\xddb2\xd3\xcf\xad\x19\xeb\xcd\xc7\xa7\xc2\xfaw\xf2\xef\x7f\x9f\xe6~\xdf\x07\x96\xf4\xf8\xab\xd6#r\xacI\x1b\x95`\x8a\xc4\x08\xd7\xd6\xf9\xfd\x9fe\xf1\xfa\xbe\x8f\xe0\xbd\xaeY\xd2\x92I\xd3eX\x86\x8e\x9b-\x0b\x19+%cGr\xc6\x8e\x84(\x0b!dQ\x02$B\xa0\x91amIE\x8e\xb1G\xa3\xcd\x9f\xaf\x19g\xae\xaf\'\xae\xce]i\x8eD\xd7\x9c\xe9\xaf\x99\xfb\xf7\xe7w\xb9\xad\xcbj\x85&J\xc1/.\xe7\x9d3\x94\xdb\x9e\xbd\xbcu\xe9Mo\xcd\xd9\x9d\xdf\x9d_\x9d\x12\xac\x89e\x9d}\x17\xe7w\xee\xf1\xcd\xde\x9f7\x86\xf9\x9f\xa5\xab\x8fe4\xf2\x9eufuf-\xd2\xdd-\xb2\xceY\xe5,\x9eJ\x08\xfb\x17\xec\xbf\x9c\xc6j\xacn\xac\xf4\xc3\xc7\xd5\x8b\x1e\x84\xd4\xaeB\x13U6\x96I\xa3\\\xb5k\x86\x9d\xf0\xb3Y\xa3\x1d0\xf3\xf5Um\xfa\xe7\xaf\xa7\x19\xdc\xc3:\xa7=8\x8e\xfe\x13\xd5~\x7f\xec\xe1\xe0\xbd>_#\xe8\xf1\xe4\xdf:\xf5\x88k)#r\xaeP\n\xe4\x02\xfc\xef\xe8\x9c=\x9fK\xf2\xf5\xf5\xbeWc\x94\xe8bkfIMg\xb1\x13F\x91\xb26\x05\xa9l\x97\xa5\x84\xeed\x92\xa6\x92\xa6\x86\xb3\x12\x04n@\xac\xc9\x8a\xdc\x95\xa67\xc6\xc8\xd1k\xb6\x1a\xe5\x8f\xd9\xe2\xc1\xeb\xf2Y\xcb\xd1\xd0\xf9\xdfK_\x1e\xd9#\x8c\xbe[\xbe\xbea\xee\xe9\x8a\xea\xe9f\xb1\xb72c\xb7\x9cs\x93\x9f2\xe6\xba9\xe9\xd4\xcfN\x9c\xd6\xdc\xddSZ1g)6\xa1K,\xd9\xe7\x7fX\xf9]\xab\xd6:\xba\xc7\x80\xf9\x9f\xa4\x864D\xb3\xb9\xe6\xd9\x9d[\x9bvut\xb6\xcb<\xa5,\xb2y\x12\x99}\x9f\xf6\xbf\xcd^\x91\x96\x9e}9\xdeog/\x8f\xbf\x9f=v1;\x9c\xce\xd9\xdd\x92\xd0\xde\xe7\x9b\xb5\xd3\xe6\xdbqNw\x96\xf4\xcd\xea\x9c\x8e\xbb\xc3\x9cv\xa7\x1e\x863fmY\xd7#Z\xf0\xde\xa7\x8c\xf4r\xf2\xbdy\xf9\xee\xbe|;\xe5\x8f|\xe8\xd6k\xb9VF\xc4$V\n\x99\x8dt\xa5\xf6\\\xfb\xfa\xbe{\xf5\xfek\xe9|\xf3n2YMb\xda\xab/\x8a\xb5\x9a\xb4\xcd\xa8\x8e\x863\xdb\xe3\x9e\xb6f\xc4\xb0\xb2\xc9\xa4\xac\x95;\x1a+"+\x11\x04\xe7\x1cM8]u\xb7\x17\xb9\xc9\xd3\xcd\xd8YP\xdf,\x1e\xff\x00\x07\'\xdd\xe0\xbb\x87\xaf\xad\xf3>\xae\xee\x1d\xb0G\n\xdf#\xea\xbf9\xf5\xf5v\xb8\xa6\xdc\x95\x89y\xa7.g,io\xa77\xd2\x9a\xe8\xe7[s\xbd9\xb7K<\xd8\xca\x95M<\xd7/K\x8fO\xa9\xfc\xce\xbc\x8e\xd8\xed\xf1\xe9\xe0\xbew\xdeC\x9a\x94\xd5\x9c\xf5d\xd5\xd9\xb7gWKnl\xe2P\xf3e\x9aB\xcb\xee\xbf\xd1\xbf\x96Kp\xd2<\xeeO\x1f\xa3\x93\xf3\xbe\x87\x0f\x87\xd3\xael\xac\xb3\xbe{\xbc\xda\xa2t\xa7(\xa6]c\x8f\xdb5\xfbs\xc2\xf7\xdf?\xe9\xca\xdf.6<]\xce:\xee\xf9zu|\xbb\xdf\xc9R\xf9n\xf3\xc1z\xb9y^\xbc9=9F\xc9\xd94\x9a\xb4u\x14\x8a*\x11\xeaZ\x9dy\xae\xe6o{\x95\xebsm\xcc\xcb\xbc\xf3\xfag\x9b\xd2D\xd7\x95:\x99\xaer\xed\xa3X\xf6\x1c8\xfb//>\xf7;\xd0[\t\xd4\x91\x80\r\x15\x08\rk\x8e|q\xab\x89\xbd_\x9b\xdb\xe5zq\xb2\xd9\x10\xe9\xcb\x9f\xed\xf9\xfco\xa1\xf3tp\xf5\xf6>g\xd6\xd9\xc3\xd1\xcb\xcb\xcfn\xf8\x8f^\xbc_\xa7\xa4W-\xb8k\t\xce9\xb30\xcd\xde\xdfFo\xa2\xd6\xfc\xebfn\xac\xea\xd9\xa2\x12\xa9c)4\xe5y\xd7\xa8\xf2u\xf7\xbe-`\xef\xce\xbf\x1f\xd0\xf3>\x1f\xa8,\xe6\xa7\x9d\xdb\x8d[\x9bl\xd5\xf9\xb7Kd\xb3\xcaR\xb8\x96K4\x8f\xbd\xff\x00R\xfeNX\xe8\x8c\xfe>\xdc\xaf\x91\xef\xe1\xf8\xbe\x9e,z\xb1\xce\xf9/\\\xda\x95\xef\x1d\x07\x18\x1c>\xba\xe2\xf5\xc7\x87\xf5\xf0\xb3\xeat\xe6\xfb\'/\xaf\x17\xd3\x9f\x95\xe1\xf3=\x0e:}\x0b\xe7z}W\xcf\xeb\xd0\xe3\xaaW\xcfux\xcfO?-\xdb\x97\x17|%Z\x97L\xb6\xd92D\xc9\x12\xb9\xb0\xb1\x9d\x17:\xa3n-\x85\xb9iJu\x9ev\xf2\x93f[\xb0\xbeH\xdc\x9a\xce\xfb\xc7\xd4\xf1\xe5\xe8s\x8e\xa2\xeb\xba\x90\xeeV\xb3\x14\xaa\xe6\xab\x95N\xc7d\xe5\xb3\x1d\x0e{\xa3\x1a\xc9\x18\xd6r\xed\x8dV\xda:\xaf|\xf0\xfb<\x1c_\x7f\xcc\xbf\x8f\xa7\xaf\xf3~\xc5\xfc;\xf1\xb1|\xcfMx\x7ff\xfc\xb7}`\xd5\xe6[\xca9\xf9\x99\x8be\xea\xcd\xf4\x9a\xd6\xbb%\xd7\x9dh\x96\xcc\xd2j+\x19IIII\xa75\xef\xfewoO\xc2\xcf\xaf\x0f!\xe1\xfb\xd9\xfc\xde\x99gS\xcd\xb2j\xdcn\xdc\xea\xdc\xea\xe8\xba[3g\re\x98\xe4Y\x11\xf7\x9f\xea\x7f\xca\x94\x00S\xe6\xe9\xcf\xf9^\xceG\xcf\xfa\x1c\xde^\xcev=\xb9\xa7U\xa9=c\x1e\xa7+\xae|\xef|xoO\x9f\xc7\xfa\xfc\xbb\xfd;\xf4\xbe\xcfV\x8c\xe5y8h\xe5\xcb\xaf\xcbz\xf8o\xa5\xc3[\xf9\xa9\xae7Y\xe4\xfb\xe3\xce\xf5\xe7\x86\xe7A\xb2MF\x84\xb95F\xac\xcd1t\x9a&vft17g:$\xb4VWevI\x9b\xa4\xb9.\x92\xe4\xb1%\xac\xdb\xbezu\x8d\x9b\xce\xad[5\x00\xb26UqF\xb1\x9bX\xa6\xe27.\xe5\xcdY\xcf\xac\xf9\xf5\x9f>\xa42v\xcdf1\x95k8\xbd^\x1e_\xb3\xe7\x9b\xe1v\xd7+\xae\xb9\x1do\x9c\xe9|\xc9\xca\x89\xafM\xae\xac\xd6\xc5\xb8\xb2kD\xba2R\x93I\xa8\xcd\x10\xa5j\xf3\xa9g_S\xf9}\xf5L\xf45\x8eO\x83\xecvg2]Yie\xa4\xb3\xaa\xb3\xbbQ\xc9(\x92C:\xbe\xf3\xab;\xb9\xce3_H\xfd\xff\x00\xe2\xe3\xcfK\x14*\xe1\xbc_?\xd3\x83\xcb\xeb\xcf\x8e\xb4s\xeb\x9b=\xea\x9bK\x92\xf4\xe7\xeb^w\xab\x83\xdf\x99\xacYf\xda\xd5$\xb0\xbb62\xc1)\xacu\xca\xdey\xda\x97I\xb3\x12\xdc\xcb$\xd13z\\\xcd\xf2i\x99\xd1%\x84\xe4\xb72\xfb\x9dW:\xf7\x8d;\xc5\xfd0\xecH\xec\x95\x92\xb1\xd4\xac\x91+\x13*\xc2\xc9\xeaJ\x9d\x8e\xc5b\xb9\x85\xcd6S\xacU\xacB\xc5\xac\x91.]\x0e}\'\x8e\x93Y\xc14J+$CX\xcf\xdf\xcd\x8f\xd1\xe5SW\xf9}Y\xb8\xf6\xf3WXu\xb8-v\xd1\xab\x8b\xa5\xe6\xf4\xd7;L\x1a\xd6[alm\x1a\x89Q\xab:r\x92\xc1\xa54\xe1M\x12\xca[\xf1\xbf\xae|\x9e\xbc\x9e\xd8\xf7\x9e]\xea\xe1\xd7\xbd\xe9\xf0Yse\xce~]\xb5t\xe0\xa5\xc9\xc7\xd1\xb7\xaf\x9a\xb9\xbb\xf5\xc5[$\xbb\xa78gV\xb9\xc7Z\xdf\xf6\xfc\x90\xe5\xaa\xf9n8\xb4\xf9\xfa\xe5\xf3w\xab\x9e\xddFX\xcdFXM\xd37\x9f=\xaaj\xa6\xa2Aj\xb1\x15MU5\xcfo\x95\xa77S&\xb3V\xa6\x89=\x0f)\xd7\xe7\x9d\x19\xc2A\x97d\x99\x9e\xb3c2I#\x1aYsv\xb3\xa7\xae5u\xe7o\\Ws\x9e"]\xac\xdd\xb3\xb0IXX\x922\x964u\x10%r\xf5\n\x8aWel\xd7d,\x84\xcdY\xd5X\xd9\x12\x87,s\xb8\xe7JY\xe6\xdb\x9b&a\xdb\x95]\xb8\xadG\xcf\xaf+\x17\xcd\xf5\xd5mF\xd8\xaaT\xa2\xc6\xd8\xcbU\xb5[N\xb5E\xb8\xb5y\xfb\xb8\xb5sj\xd6\xd4!4\xa6\x88%R\xfa\x1f7_\xa2\xfc\xfdb\xe8\xf6\xbe\x1e\xf8q\xe9\xf6~\xaf\x95\xd7\xf5xn\xe9\xce\xde\x9c\xe5c\xb1\xd8\xe9\xd8\x00Q\x00B1\xf2\xf4\xc3\x96\xe1\xcbUs\xdd\x1cz\xd3\x8e\x85[\xbcOXz\x89a\x9d\xd7\x8d=#-8\xebFzW\x9d\xd3:U5\x92o\x9c\xe9\xcb\xd2\x8a\xcf\xac\xe7\xd6(\xd4\x95\xcfg\x9ez\xdc\xf3\xab8p\\\xcd\'qbX\x92\xb2V(\x07sm\x97o\x1a:at\xc6\x14\xe3\xae{6\xdc\xec\xb9\xb7Q\xd94\x9d\xcb\xb1\xd5\x9a\xcd\xda;+\xc9K+\x1d\x8e\x9d\x85\x8bY\x8d\x91\xb2\x12FZ\xd2\xb20\xb3c-b\x95\xe3S\xe7\xb9Ar\xbad\xdc\x89\xcf\x97\x85\xd1\x82\xee+\x1aSB\xa5\x14\x96\x16\xb9g,\x9a%MV\xd6z\xc7\xbb\xce\xdd\xe7n\xf3\xf7\xac\xcb\\E}\xcf\x87\xaf\xae\xf2\xdd8\xeb\xdc\xf2w\xc5\x9e\xddm\xf0\xecz<\xddn\xde-\x1a\xe5wNWt\xe7-\xc6\x80\xd1\xe8\x05\x80\x01\xcb\xf9\xfe\xf5\nU-X\xe9^4\x0e\x9d\x88\x8c\xd2\x96\x13S\xd6R\xd1\x8e\xb9y\xf6\xcf\x8e\xb9\xe7L\xcb\x9bLu\x8bs\x9b\xd3<\xde\x99\xe5\xf5\xc7/\xb7<;\xc7C\x9c\xeb\xf1k\xcc\xbc\xd0\xce\xac\xcd\xfc\x9b\xb9\xdd2\\\x96\xd9m\x93fz\x96\xdc\xd9d\xac\xcb/\x19\xbf1\xdfY\xb5:\x19\xcfBf\xc4\x95\xcd\xac\xda\x96\xb3v\xb3\xa3x\xbfYvW\x11\xc9\x8fR6;\x9bw\x9b7\x1e\xb3\x0b"\x91\x88$,\x89\x18\xcefX+\x88MP\xad4&\x83D\xd6\x9b0\xa7\x8d\xeb\xbc\xba\xdc\xf3e+\x9aj4\x18\xb5an\xbewV4+RW6JJ*k:\xe1\xd5\xe6n\xfa?7N\x8e\x1d\xae\x1dt\xf9=\x97\xef\x95\x8bf\xf1t\xdfO\xbf\x9b\xa5\xd3\xc7\xa7|o\xdf+zs\x9d\xcb\xd4\x96\xa0*\x00#\x83\xf2>\xb4U\xd9=e\xea1\xd8\xea9\xb5\xe7u\xe3U\xe3q\x9a\x8a\x8bF:g\xe7\xdb4\xd66\xb1\xd6=\xe75\xce\x1e\x98\xe7u\xc7?\xa69\xdd\xb1\x8b\xbe\x13:\xf97sm\xcby\xaagTm\xe6\xd9\x8bt\x96\x96\xd96]\x93I\xd4\xac\x89\x9b:\xe7\xbaf\xb4\xb95\x92\xc5r\xd2W-\x97d\xb5\x89\xdc\xadg.\xb3F\x8d,*\xd6s\xeeJgIm\xcdZSd\x89&z\xc9\xa4l\xb1iL\x1d-\x1aJVWll\x92j\xc3\xa9\xca\xf6y\xa2p7\xac\x1d.\x1dj\x9b\xa9\xe6\xe9\xcd\xbf6\xdc\xe8T\xb6e<\xd94+\x94i\xcaM9\\\xd4\xa6\x9c\xb2\xce\xa7\x9b\x0b~\x85\xe7K\x97\xa7\xa9\xdf\xcbFz]\xacs\xb9z\xf9\xf7\xd3=^\x8d\xf2u5\xe1\xd7\xaf=\xfb\xe5wNv\xf4\xc4\xachP\x11\xe3\xfe\x0f\xe8\xabj-Y\xacOX\x95\x92\xb0\x16le\x86t-\xb7\x9d\xba\xe6,3\xaa\xf3\xaa\xb3\xd2\x99\xaa\xa2\xbb+\xb2\xb2\xbb"\x86\xf2\xb7\x91\x1c4d\xac\x02\xc8\xdc\xc3y\x8d\xcaGar\x0e\xc6\x85\x85\x95\xcbM\xa9\x1d\xcb\xb2W-%r\xec,hX\\\xe7\xd4\xe3ur:\xa7%\xc4l\xcb\xa9F\x91\xad\x19GS6\x90\xab"l\xa36\x95i$\xbf)E:g\xd6\xa1S\x92\xc4\x82\xd6\xb0Vk\xe6\xd9\x85\xf8\xb0\xb7\x1e\xb5\x83v\x8dY\xe7[1v\xf3\xd5\xf9\xb3\x95\xe6\xb5r\xc9\\\xd0\xd1+\x96Y\xd4\xe6\xa5,\xf1\xac\xbd\x1fU\xf2\xe3\xb7\xdb\x94e\x83U\xb7\t\xb85T\xdei\xde\xb9\xad\x19\xe5\xa5\xc3U\xe1\xa7\\o\xdf+5\x8b7\x89\xeb%|\xff\x00\xf3\xdf\xad\x9d\xccf\xa4\xcc\xaeE\x8a\xc5c5\t\xaa\x9d"u\xbaxw\xf5\xf24\xaen\xac\xee\x19\xdc\x1a\x84\xd2"\xb1"E\x15\x91\xb2\xbb+H,lQ\x12&.\xdcx\xde\xdf-\x1d1$\x92Y$\xd2\xe8\xbd-\x92\xc4\x9d\x93K\x12l\xca\xc9$\xec\x92N\x9d\xcbB\xb3\xa4\x12\x8d9;q;\x12\xea\xcc\x12\xbd3i\x97b\xcdY\x91\xb3&\xae]\xa4\x9bq\x03.\xaeM\x83Nf\xac+\xac].m\x19\xa3\x1a\x0e~\xef;\xa3"\xce:\xdc\xdd^7\xb1\xc59qo\\\xae\x8c;\xd15\xb7\x9d\xe9s\xba\xb1\xa9\xe6\xb9\xb9+\x95\xb4\xf2\x94\xb6gs\x96\xcc\\\x9dg\xd6~\x9e\xeb\x97.\x8fLi\xdc\xb3P\x94\x90\x95d\x0cR\xc6Z\xb1\xbas\xd6\x99\xd2\x13Ic*^\x07\xcb\xfa\xd7^V1L\xea\xec\x9a;\x95*T\n\x96\x0b\x16\x92\xa1J\xa8X\xc1J\xa1eVUd\xeel\x98h\xecU\x14\x19VT\x9c^\xd9\xf0\xbfK\xcb\xe8z\xf8\xb6\xe3\x18\xfbs\xb5:\x9c]\x1ez\xd9\x95\x8c\xca\xc92S\xa6\xc8\x8e\xc0,v\x16\x08\xd1X\x01\x14t Ug\x9a\xed|_\xb2\xf4\xb9\xb6\xe2`\xeb6g\x04\xba2\xf2~\xadtp\xdd\xcc\xc8V{8\xddm\xa7[\x9er\xea\xd3\xa5+\xce\xdd\xe9s\xbbs3\xe9\xcc\xe9|\xff\x00]g\xb3/>\x94\xe9\xae\xc8\xe3V\xe6\xec\x93W=jK&\xbdo\x95\xe98Z\xd6+fm\xb3Wf\x8b\xca\xebx\xbd5\x8a\xdf\xb5\xfc\xddM\xcf\xa3\xd2k\xd2\xc2\xc5\xb5%D\x10@)H\x11f\xc3:\xaf;\xaf;\x84\xd7\x95\xf9_v\xfb\xcel\xc2j\xb6\xa1w+\x96*J\x86\x04V-)b\xb1PHj*\x8b\x10Hj\x01q4\xbd\x86\x95\xd4\x08X\x8c\xfa\x9c/g\x96\xbfg\xce\xb9\x92\xe4\xb3f\x1as-\x89Y9%cGM\x90\x924t\xeeecA\x18\xe9\xa0\x8e\x84\x00U\x9bS\xe7\xfe\xady\xae\xf7\xd5q\xcc\xe3\x8f\xd9\xbd\x88F\xce[\xf1^\x8dw\xf1\x9bnj\xb6\xd8\xe7Z\xf3z\x8c\xcc\xb2ID\x96\xd8\x9em\xb9h\x97To\xc5\x99\xaa\xde\xbe\x9d}<\xdf+Z\xf3\xee\xba\x19l\x96\xb5\xebb\xf9.\xf3\x81\xadu\xb9k\xbd\xe7\xd7\x07}9\xbd\xb1\xe0\xfdW\xd3\xf8\xfa}\xd3\x86t\xf4\xc6Nz\xaf\x16v\xda\xd4se-\xd76\\\xba$%Y\xa4\x12%2p\x97\xc7|\x8f\xd0\xca\'\xacI!5\x99\xda\xa7K.+\x9bE\xb7\x15]\x80\x80J\xe9K\x15V+\x94&U\xc8\x91\xb26;-f\xc6$\xca g\xba\xaa\xb9\xbd\xf9\xf3\xbe\x8f\xc7\xb1.K$\xb5\x99\xc5\x88\xeeg\x12\xa9#F\xcc\x89X\xc7c\xb9\x902\xc7M\x04v0\nb\xb0 \x9c\xdd\xdf\r\xe9\xd7e\xce\x95\xf5\xfeV\xcc\xc9\x06\xadZ\xbb\x8b\xcb*\xcbl\xb2Ub\xce\xa7R\x96vL\xf9\x17\xa2x\xcd_O\x97\xd3<\xf7\xd2j\xeb\xea\x9d\re\xe5#\x9b\xb3\xaa\xcaKO*J\xa5"2\x92\xb1J\xa6\x94\xb1\xcd\xb0\x035f\xc3:\x8eu^u\x19\'-\x96N\xe5\xa2\xcdR\xac"\xa6D)\xaf-\xf3\xbe\xe4sIerT&\xf1g\xd1M\xe9e\xc4f\xac\xd6#*&\xcb\x91\x19\xb5\x02\xe9dF\xc4WdQ\xdc\xc4v\x16I\x91$\x92\xb2)E#\x81\xdb9\xfe\x97\xc8\xd0\xe7r[34\x9aM$H\x9a6ZJ\x9a2V;\x99\r\x97L\x0b\x12\xddKt\xba\xae\xd5\xb2\x9e\xe5\xe5\xb5mOK\x0b-\xb6\xa7S\xb1\x88\x8c\x02\x14 "\x8aT\x8a#\x0e\x94y\x1d\xbamz=K\xf7X@\x0b"\x11\x0cU\x0b5+\x9a\x14\x80b\x95\x8eRiA)\x9a\xc1L\x98\xb3a\x9b\x0cj\xbcmK+\x1eB\x19\xb1\x95db\x91\xe4\xfc\x1f~S3\xb9\x83UN\x8d9\xefNV\xae\x99\xdb9\xca\xca\xb3\xb2\xc2\x86k\xb2\x9b+A)\xaa\xf4\xd1\x9a\xe6\xa1U\xdc\xbb&\xcd\x81cf\xc6d\x96\\\x96g9\xbd\x1c\x1f\xa3\xf3:[\xf2\xcc\x9b3I$\xe5\xd1s\xaa\xb4k7\xeah\xd3F\xa5\xf6]\xb9u\x97U\xfbYe\xb5n\x97i*$\x8a@\xa62\xad\x11J\xd1\x14\xcbI\x9aZ\x0c\xa6E\xceg\x96\x95\xcf\x19W)A\x95q\xaeS%g.\xaf{\x8e_Jr\xf5\x1df\xad\x98\xe9JS\x90\x01\xb5\x0c\xb9\xfc,\xa5\xbbI\xab\x1b`\x04\x00\xa2\xbc\xa5*#\x9dC5\xc4\x95\xc3\tPd\xb3a\x9dC\x1a\x8elsIV\x11\xe7c\x8dy\x7f\x17\xdc\x84\xda\x8bn \xd2Q1\xbb\xe5\xb6\xc8\xabR\x16\x16i\x86\xc9sM\x95\xdc\xd6e\xb3\x07\\\xe4\xe9\x8e\x87;\xaf\x1a\xd7\x8dA]\x80\xacrZ\xcb$\x92B\xe7&\xe7\xcf>\x8f\x9c\xf4y;8\xcf\xa4\xdf.\xe7\xa7\x9d\xdaX\x90\xb7<\xb5E\x12\xe72\xcb\xcf\xba\x8af\x8a\x0c\xa6#\rd1\xd6\x1dg\x15\x99S=\x94U\x15JU\xa9M\xcdu\rJ\xca\xb5**\xb25\x12\x12\xa2D\xe1\xad\x91ay\xed3\xcf\xec\x13\x9f\xd8\xf1=\x0e\xd6X)N\x00\x05t@\x04%\xab\x9d\xcf\xca\xc6$\xb3\xb6\xcbd\x03\x05\x02V\xae%(\x12\xc6if\x91!\xc2\x00\x83&(Y\xab69\xb1\xc6\xa1\x8b\xe2\xbeO\xe9\xads\x83U\xcd\xc6\xd9\xb3D\xe8"Zm\x8e\xf3]\x91\xb3\x9f\xd3*\xe2"\x8a\x82\xe36\xa6\x8c\xb0\xeed\xeb\xcf\xe8]\xfc>\x8b\xaf\x9bD\xb1\xa9\xeb2\xb2\xcdg\x87\xe5\xf5\xf8?\x9f\xf5ef\x99\x90\xa6\xbcW\xab\x9f\xcc>\x87\x9b\xcf\xf6\xe3\xcb\xef\xcb7nu\xeb\x10\xdf(k\x99\x0c%d\xa5kd\x0bdL%\x99nm\x85\xb9\xbb\xf1ve~m\xd1t\xb7f\xeb\xcasZ\xe3f\x16\xc6\xac\xb4%\x84\xaa\xe8\xd3\x94\xe5t\xd2\xdb:Z\xcf\xa6\xef\xc3\xd8\xfaxu\xbbb\xcd\np\x00\x00\x00J\x8a\xe36g7\x9ep\xafoz\xe8j\xc7\x16\xbcZ\xf3T\xd4\x89\x12\xa6;X+\x89M\nCW+\x80R\x88A\x04\xa6c\x10\xb3H!K\xf3\xcf\x81\xfa\xfa\'K\xaf;\x9c\xe2\xb9\xddl\xb8\x83Q\xb2\xc4\xa6\xdc{\xc1\xbc\x99\xbc\x0fG\x1c\xdd2\xa2\xa8\xc3\xa9fe;\x97\xcc\xfd/\xdb\xf3=\xaf_+\xb9t\xac\x07\\\xee\x1d\xbc\x0f\xc7\xfby&\xea#\xa8\xec\ne\xc3\xbc\xf2:Lz\xc9e\xd1\xaaM\x88fh5I\xb6bU\x8a\xea\xeb\x9d36\\\x92\xcd\x08E\xa9;\x1a4\xd1\x89m\xc1#\xb0\xb2\xc4)\xa4*w*\xcbl\xb5\x1d\x1b\xcd\xddq\xb3\xb6v\xf7\xc6\xefF4t\x93\xd0\x00\x00\x00\x021Nf\\9\xbc\xb7\x1ez\xdf\xd36\xeeN\xa7V\xea\xce\xc0%\x8eu\x1cX\xe6\x92\xa8k!\x80\xd4\x9bcW,\xa1\x80\xed G\x00A\x04)VFJ_\x9a~\x7f\xf6\x92\x93;\xa5\xfa\xe5{\x11\x9a\xcftk\n\xcat\xbaNo~Y\xf5/\xc5\xcds\xe7}\\\xab\x88\xc9\x8fLz\xce\xcb\x9f\xb5z>_\xa5\xeb\xe7\x19)\xd8Ps\xb1\xbf\x89y~\x9f\x9f\xd5\xf4\xbe{\xe9\xbc\x9d\xb4T%\x80\x95,\x99\xd0\xcd\x97:\xdc\xe5e\x8c\xb2\xfds\x9d\x82;\x1d\x95\xcak;/9\x15\xaaI\xea\x10\xech\xe8\xa9\\\xaai\x02{\xcc\xf5\x98\x98\xbc\xfd\'e\x9d3w\\j\xed\x8d\xbd\xf3\xa7\xbe.\xd9\xd8\x0e\x88\x02\x80\x80\x89VY\xb0\xe5\xf2\xbc.[\xf3\xb3\xaelo\xbd\xcez\xde\xbc\xb6t\xcd\xbb\x93\xda\xcdIS\xa6 V\x19\xa4\xb1\xce\x94\x12\x90\x82Q\xb2Bj*\xe2k"c\x86\x001\x84\x84)VD|\x9f\xf3\x9f\xba\xd2\xc4\x93\x1bw\xeb\x9d\x88\xd2\xbb\'\xbc\xc7(\xeaf\xe9\x8c\x9b\xc5Uv\'\x9c\xf5q\xe6u\x99\xec\x84\x9d]c\xec]\xbeg{\xa7\x03YCB\x9dC/\x9by>\x87\x9a\x9dx\xdd\xb3V\xb3\xeaq\x8f\xb1\xfa>{!*\x96$\x14IX\xec\x9d\x8d%\xa8\xd1\xd8\xf4h\xeaV;\x1d;\x04(G@\xe8\x1d0\xa6\x8e\x80\x02\x8eZ\x95\x93\xdaV0\x00\x00\x01\x88\x84U\x99\x93._;\xc6\xe5\xd3\x8b\xcb\xaf/\x1b\xc0\xd77\xab\x97\xd7^\xdb\xcb\x8fexv:gn\xe5\x9a)A\x12\xab4\x9dJ\xa5R\xa7\x02\xb5\x1a\x8c\x8b%)*\x95M)\\\xb2\x1a\xca\x18\xe6\x81\xc3Ga\x90\x01\x06_\x18\xfc\xf7\xeet\x9a\xf3\x02\xab-\xb9W5X#\xd6f\x179w\x9c\x1dq\x9bX\xa68~\x8eT\xf4\x9e\xe2y\xbe\x91\xdf\xc1\xbb\xaf(\xb2S\xa394\x9e\x92J\xf3P\x99t&\x8e\xb9v\x03\xa7@\xaeX!L5\x18!@\x80\x04\x80\xd1\x8a\x81\x80\xe8\x01\x850\x00\x00\x00\x00\x11\x19+\x8a\xe4\xcf\x97;\x17\x8f\x8e\x9c|t\xe4\xe3\xa6\x1ct\xa2jr\xc0R\xc6\xa99\x9b\xb5\xef\x19:\xe7\xad\x9cz\xccs\xf4\xb3\x9fm7j\xce\xa40\x9au:\x9dN\xa7M]1\xd2\x82U\x04\xaajSR\x96@\x12\xc3;Y\xa1$hC\x00\x87\'\xc7??\xfb|\xf6Yf\xcc\xac\x92\x01\xbc\xab\x9a\xd1Xk2dJ70\xf5\xc6\x1d\xe2\xbdg\xd8\xf4\xf1\xfb\x1e\xfe\x0bu\x85c\xb1\xd4\x99u=K\xacv\nX\xd9,c\xa4\x80RB\x8b$2@\x1a\x84\x14\x84$\x05\x05\x8ch\xe9\xd3\x1d\x00\x80Q\x055H\xa5I^P\x96\x19\xb4\xe6\xe5\xc6\xb2cx3\xbcx\xe9\x8b;\xab\x168W\x9d[\x9b8k8\x96d\xc9\x8e!P#\x11\xaa\xab.\x9c\xfe\xb3\x9d\xdf\'^}\x17>\xbd\xe7\xdd\xcez\xa6\xcd/\xb6d\x94$\xb2\xa9\x8fI\x8e\xa4\xa2;I\xa3:\x9c\xb2\x18\x96\xbc\xea\x19\xd9+\x1c\x8e\x00G#>=\xf0?g"\x8b\x95\xa9\xa2-\xc2\xaa\x8e\xb3\x1df\xb4\x8d\x91\xd6lD\x95o\x18:g\x99\xd7\x11\xeb\xce\x8e\xbc\xea\xe9\x8d\x12Y%\xa9\xb3\x05\xd3\x16k\x9e\xbb\x96i\xca\xab\x95N\xe6\xe6n\x8a\xaa\xfcZ\xc6\x92\x85\xac\xe9\xcbn/G\x95\xd5\x8d2+\ta,e\x88\xc7$\xaaI;\x97d\xee];,\xb1\xa4V\xaa\xadf\x97\xc8\xc5\x11\xcd\xa7;\xaf:\x84\xaaU(\xadQ\x18\x8dE"4\x95\xc8\x8d\x00#\x00\x86D\x88\x95\xa2XV{p\xf4\x9c\xee\x93\x1fXt\xc6\xdb\xcf\xb13\xd8g\xa4\x9b*\xdbfL\x95IekV:\x92\xb9gl\x89UR\xe5\x9a\xcf7)\xa9\xcd[%\xb9X\xcc\xee@\x8f\x87\xfc\x0f\xd9]s"\x8df\xc2\xc4\xbf\x15YV\xf1\x0b!ez\xcbD\xcdVWf]\xe7\x9f\xd38\xf5\x9c[\xce}\xe6\xcc\xda\xd3Nn}\xcc\xdb\xce\x1d\xe6+\xd3\xca)\x9fR\xca\x96\xf1\xd3\xc2\x9e\x98\xe9a\xd0\xc2Z\xcd\xf2\x1a\xce\x89\x97f\x9c:\\oC\x96\xb6\xf3\xd5\x99\xd3\x12\xa9\nu$i+\x99\xa4\x86\x80T\xd2\xc4#-\xb1]\tc.\x98\xd9*2\xc5k\x96*\x80tBX\x95-v\xd7asb9\x1c\x8aX) Hh\x11\xb5\x11\x18\x96\xb5\xcb\xa9\x8bs\'I\r-\xd6v\xdc\xf4X\xe8\xdc\xf4\x13V\x97[j\xca\x98\xd5\xac\xa2v\xd9Ed\xb3\x93\xdb8\xf7\xad\x1c\xf5v5n:m\xe7\xadx]p\r>\x1f\xf0\x7fbk\x17\xc5\x1a\xccl\xb1-4f\xd5f~\x99\xa6\xe26+\x97s\x19!A\x97Y\xe5\xf5\xc6}\xe6\x9b \x02K`\xb2\xbb2\xee*\xdb\x94,\x86\x90\xde:\x12h\x91jt1\'a\xac\xc3Y\x9d\x97%\xb9\x89.:\\5\xd0\xe5v\xf3\xb7\xe6\xceWjA\x04*I$\x92L\x00e\x89R\xc5oD\x91\xab"w%DM(Ef{h\xd2\xad"9\\U\xa5:S\xab\x12\xec\xb5an$\xe4\x94\x84\xa2Hh\xe9\x88J\x11\x96\x0b\x04\x82\xc1k\xac\xfa\xb9\xf6\x86\xf3v\xb3\xaa\xe7\xa1q\xba\xe3]\x9aV\xc2mMl\xabiY\x87s\xcez\xe77\xb6\x8aU\xd1\xf3\xeb\xd2x\xb5\xd4\xe7\xbd\x043\xa8\xcb\xf1o\x8b\xfa\x95\xa9s1\xb2\x9b\rK"\xf8\xbb6\x8dg/LSs\x1dA\x95b\x04I\x9bS\x0fLf\xdc\xaa\xc4\x8a\xc8\x97E\xd2B\xa9\xd4\xb6-\x15\x91\xb2{\xc6\xfcB\xac\xb3D\x8e\xca\xb5\x98\xef6%\xf9\x82X_\x85\x92\xda\x9by\xcd\xbc\xed\xd3L\x98C\xa9H\xd6I(`I$ &@\x85Y\n\xa1\xa3I\xc4\n\xad\xad\xaan\xaa\xb2\xadeY8\x9c\x90\xaa\xf4\x80\xa6\xac\xce\xf4cR\x87\x13\x99\x9a9\x04V2l\xb0\x94\x12\xc6\x05\x8c\xb1\x95\x8a\xd8\xdb]Qm\x15N\xd1\xdel\xd6u\xdc\xed\x93Lk]\x96_c\xd4\xcb\xa7?\xab>\xedzU\xa5\xd9\xbd\xaf6\xba\xdc\xee\x85\x867\x1c\xeb\xe2\xff\x003\xf4m,f\xdaVUae\xb1\xab\x06g\xde2\xef4k5\xeaE#s\x14\x90\x15\\\xe6\xdes\xef0\xaa5#a%\xa5\xf2\x80\x93\xa7s8\x9d\x9a&l$\x9abHY\rd\xd4\xb2fd\x99\xb2,\xca:CY\xb2\xcb\xf3,["\xdc\xdb\xa5\xd1\x95\xf8\xb6A(\xad$\x8c\x90#\x12\xc1jk6\xd9\xf7\x1d\x96I]Qu)n\xcd\xbf\x9a\xf9-\xb8\x92\x04B\xa2K:K\x19b\xb1X\xacQ\x82\x08\\\xc4$\x16I"Y9EB\nDm\x82\xd7u\x15\xadk\xaa\xea\xbd,\xadz\x9b\xa4\xd2\xce\x8b\x15U\xab\n\xafHitt\xf0\xdf\x94\xf3\xa8\xce\x92\x8f\x8f|\xef\xbe\xeeD\xb4\xb4\x85\x94\xeb%\xcd\xb2]\x9b\x1a\xa3Y\xcb\xbc\xe6\xd6k\xb2\t\x1dfQa2)\x97Y\xcb\xd35jUr\x87V\xcb\xaa,\x91\xd9\x1b\x9bcB]-\xb32IU\x92J\x8b\x1b\x0c,,\x92NFU\xb9\rEb\xb9*I\xb3\x17\xa5\xc7[\xf9\xebF\x13QD`\x92\x05\x00\xaaZ\xae\xe9\xb6\xad\xe2\x1a\x88\x88\xaaR\xd9\x9d_\x86\x99\x9b\x99\xb1$\x02\tP\x8a\xd6+\x19kj\x02\x12\xa1Y\x11"\x04"Q(\x9c1\xc1HU\x1bR\xc5cu\x10j\x15\x9a\xdau\x16\xeb\xab\xe3\\\x9a\x8b\x92\xc8*u\xaaM\x105\x19\xa9\xc7\xca\xbc?l\xd4\x94\x8e\xa6\x8c\x8e\xa57\x05\x92K%\x82Q\xa9\x9bY\xa7R\xbb\x95cIE\x84\xd2\x06}g>\xe45\x9a\xb4\x12\xd9tI\xab+$5\x99\x96\xc5\xd2M%M\x0b\n\x95\x96L\x88\xa9X\xaeec\x88\xd4u"\x85\x8a\xc2\xc9\xa6\xeew\xa1\xcbZ\xf9\xdb\xe5\xb6-\x89\x85%\x88\x04D\x8a\xa5T\x90\x05\x10R\x05\x9aI&\x92$2 )P\x80\xadjj*\x0c\x08\x10X\x82\x04a(9\'\x00\x00\x02\x8d+R\x82)\xb7&\xae-o6\xac*\xd2\xfa\xbe4I\xa0\xd1%\xf1i)\xa7,\x8f\x9e\xf9>\xa9b\xb1\x85\x92\x1b1J\xf7#p\x16\xe6\xa2\x8df\xa4Z\x91\xb0\xb2H$\xd2P\xac\xaa\xcc\xfb\x90\xd4\x8d\x8c\xb6-\x93Fe\xab4\x95\x96I4\x07RF\x8bRD\x99\x124X"\xd6J\x11Q`\x89\x15\x85\x97F\xbez\xd9\x8b\xab\x9bL\xb7E\x92\xc9@\x02"\x10\r@AZ"*\x862i!\xaaD\xa8%i\x15\xa5\xaaV#&\x80\x88+$\x05k\\\xb0T\xa8!\x8cd\xa0\x05q "\xd5-e\xd6\xb2\xb5F\x95\x11\xd6\x90\xeavN\xad[\x0b%\x92\xce_9\xe2\xf7\xab\x1a+\x1d\t&dB\xc8k0\xb0f\xc9Q]\x91\xb2W)\x15\x16\td\x96K\x1b!\xa9]\x8bR\xbb\x16\xb39,\xca\xd9l$\x93F\x85I$\x93\x18\xec\x91)\x96-Ear\x91h\xach\t\x0b#E\x92,\x8b\xf0\xd7\x8b\xaf\x16\xec\xdb%\xb2Y\x93\x18\x84\xa9\x12\xa9AZ#\x04\x00cF\xa0\xa0\n\x15K\x05\xa9\xaa\x16\x16\x896B*\xa2D\xd2A\x11\x9a\xad\xa8\xd2X\x80@1\x92\xcaK \x12\xa9\xa8\xdb\t\xa8\xad+F\xad\x16\xd3T\xdb]F\x92\x96\xaasP\xf1z\xe3`\x85\x0c\xba\x12hY\x0b#s\x1b\np$.]\x80X#\xb2qlH\x126F\xca\xaa\x9d\xe1j2q8\x9cM\x1d\x82;&Li2\xcc\xa6\x92F\x8a\x8dB\xe4\xb1\x11EL,V\x04l\x00\x0b#Fm\xf8\xbap\xd1\x95\xb3S&La\tb\xa0\x95,B\xc6\x08\x00\x94\x94\x10\x95\x11Z\xc8,-\x88*D\xa0\xd1\xc9!\x84\xac%J\x84 \x05d\xa1\x8ePj\x94T&\xa2\xb0\x96\xbb`\xd5\x0bE\xb9\xaa\x9a\xa6\xda\x96+o\x9b\xd0TR56D*I4dnc\xa4R7*\xe5\x06\xa0\x8d\x1cLq+\'\x12\x12+#ez\xcdu^\xe2\xb1\xa5\x99[\x13\x89XX\xc9\xd4\x92D\xe4\xb2I\xc9!\xd3B\xc2\xc1#J\x921\x08V\x16F\xa1I\x15In\xcbNZ9\xdd\x19\xb7f\xdb\x16\x92$N\x05K\x15\x8a\xa2"\xa4\x89R\xc6\x01\x89B D\x89\x15\x83P\xb6\x02@h\xd6P\xc7+\x86\x03\x10ZC\x86\n+Pr\x82\x13PX\x91\xb62\xc1\xa8\xadkL\xb4[\x99`\xb99\xf5t"\xa1&1\\\xcd,\x87dR:\x91\xb9\x8d\x90\xb1P\x8d\x99,\xd2y\x14\xaar9\x18Y\x0b+\xb2\x1a\x95\xeaCR\xc9,\x96\xc9,$\x92\rGR\x8939$L\x928\x90\xd1X\xacAJ\x92DB\xd0\x15\x90\xb2\x14\x82\x9cY\x1a3ua\xa7+\xf2\xb76\xc5\xb6\'\x12V)SQ\xa8\x10X\x88K\x12"T\x004\x05,-K\x12$HRI,\xa2K(\x06!(\x03\x89MHj\xc0\x15\x11T\xd4H,\x16+\x115\x01KZ\xd75\x13\xcf:](4ad\x99`\x93&\x8c\x86\xa2\xb8Dj7*\xc6\x92Y$\xf2\x05QI\x16HQs\x1b+\xb2\x15\rejN,\x92e\xb1!\xd8S\xb9\x94\x92$\t"D\x89C\x81\x0b\x04T\xaa6DB\xd25\x1b"\xb1\xb9T\x86H\xb2.\xcbNZ1o\xca\xfc\xdb\xb3l&9X\xc5,Z\x89\x026\xc6X\xd4V\x02\x01\x00\x00B\xa4"*\x84\n\xc9\x04\xa8U\x15!\x8eYK%\x91 \x1a\xa15\x15\x81\x05\x83P"+\xa8Dm\x84\xb0P\xf2\xcd\xdf\x16\xcd\x0c\xbar:i!\xa4\xc9"\xb2\x17(VF\xe4\xb0$\xb2\x92A"\xb1+I\x16 \x8a\xc8\xd4,\x8d\x0c\x9a\x85\x92I\x16,\xd2C\xb9\x11\x8d$\x03\xa9C%\x13\x91\xc0\x8a\xc5\xa2\x15$D4\x8dF\x90\xacB\xb1\xc3$H\xb2.\xcd\xd1\x86\x8c[\xb2\xba[e\x9cHj@\xa4\xa8\x8d\xb0\x88\xd4V"X\x88T\x84\x00\x02"\n\x0c\x04\xb1X\x91RVJ$\xaeVHj\xc1P\x9a\x89\x05\xae\xd8\x11X\xddF+\xb6\n\xa5,\xf23W\xc5\xf2\xd82H\xc0\x92H\x11\xdc\xb0HRB\xe5\x06\xa2%\x12\xb1\xc8\x00\x12IY8b\xb0HQr\xaa:\xca\xa9$\xaa\xc2rJIXC\xb1\x80\xc2\x82I(\x92O&\x8a\x95*AQ"\x90\xa8\xe8\x82\x90\x86\x8c`\x88\x99vW\xe6\xdd\x9b\xa3\x0b\xf3g\rg,\x96P\xd0RTF#j \xb1\xa8\x91X\xd4@J\x91(4\x00\x8bQ"\xb1RP\x91(\x94IZ\xb5\x00J,J\xf5\xa8\x90X\xacn\xa2VV\xd2\x04\xf1\x96\xe9\xcd\xb6.\x96\xc8\x92X\x01M%2\xe9\x82$\x8d\x89\n,\x88X\xd2D\xb2t#%d\x89HP\x82+\x15\x16+\x16\xa3\xa9\xa4\x92q<\xc9\xa1h\x8cv\x01B\x04\xe2RN\x18\x08T\xa8\x11\x1a\x8dDV%\x11\x05\x85\x00\x91\xb5$\x8b%\xbb+\xb3n\xc2\xdc\xdbb\xc8\x9a\xce$\xacp\xe5P* B\xd8\x95\xdd@\x85\xb1 \x14AN\x18\x84!(\xa2\xb1\xca\xe1\x92\x95\xa8%B\xba\x85\xb1\xa8,*\x16\xa9b\xb0\x88Z\xaa5\xe1\x97FZb\xec\xdb\xe2\xc9ed\xe0Gc\x1c;\x18\x92,\x94\x82\xc0V$,u8i"D\x91\x8d\x18\x90\xb0\x0b\x1d\x80\xf5\rfRH\x94NF1\xd0\x08\x05\x8d\x1d9d\x8f)\x0e\x00\x15\n\x90\xa4F\xa3Q\xb0\x01\n\x95!Y\x11Z\xd2qfm\xf9]\x9bl\x96el\xb3\x8b\tJ\xd6Q(JDV%v\xd4\xdc*$HTD\x03\x01\x08`IZ\x84\xa5p*\x82\xd4E\xa5QXj\xc5amd-\x8a\xc6!ll\x8e\xa7\x85\x9a\xbf7Dj\xca\xe8\xba\'\x13$2R:\x01\x1a\x08\x91R\xa8\xd9\x1b\x1aF\x9b2%$\x96d\x89#\x04\x92\x04\xa9\xcc\xba,,v+\x18\xd1\x93\x89\x0chX\x02\x14\xc7\x0e\x9eL\x90\xe1\x82\x80\xa0H\xaa6*V*\x8d\x04H\xd9\x126\x82\xb1\n\xc9D\xe5\xbb+b\xe8\xb36\xdc\xae\xcak)f\xa48%K\x19\xa8T\n\xee\xa1H\x89\x10\xa4\x89P\x80\tT\xa5\x90\xd5\xc0%\x8a\xa9al-\x8dB\xe9,\n\xed\x85@\x89]\xb5\xeaB\xbc2\xe8\xcd\xbaM\x19\xbar\xba[\xf2\x9cI&IZ4\x01$4B\xb2)\rEM\x12\x16Hd\xe2D\x89#I\x13I\r%M\x0b\x15\x89\x15+\x192C\x92C@\x10\x18\xe9\xc3\x00\x19)e\x0c\x10RB\xd1\nDl\x8e\x8a\xa3Q"DU\x14T\xaa(\xa8\'\x16E\xb2\xdd\x95\xb9\xb6\xe5nm\x91`\xf3e4J\x96""F\xd8\xacT\x10R"\x90\xb6!L\x942Ci\x11"\xb1X\xdb\x05\x85V\xd4-\x89\n\x82\xc3H-UMS\xb9\xe2\xb3n\x92\xf9n\x8dX\xba2\xb8\xbb+r\x910FHI"h*Es\n\x8d\x8a\xc7`\x03\x198\x95\x92\x92\xc4\x91$\x992H\xd0\x15\x91\xb1Ti\\\xba\x98\xd2YId\x89\x05hPH \x01\x92\x89+\x87\x05\x03@\x05QH\xd2\xd2\x14\x88\xd4D\x88\x85\xb1\xb9\x8dF\xd4\xc9S\x96\xd8\xbb\x1a\xb4\xb3\x0br\xbb6\xd8\x9c\xd3\x95@\xb1\x00\x08-\x04\x04ZU\x11\x11\xa8\xa8\x8dA*\xa8\xaaTEamk\x0bk\xb6\xb2\x1bV\xb0\xa8\x14\xdb\x9bJl\xff\xc4\x001\x10\x00\x02\x01\x04\x02\x01\x03\x03\x04\x02\x03\x00\x03\x01\x01\x00\x00\x01\x02\x03\x04\x11\x12\x05\x13\x10\x14 !\x06\x150"1@P\x07\x16#2A\x17$3`4\xff\xda\x00\x08\x01\x01\x00\x01\x05\x02\xaf\xf3+h\xfe\xaaQ\xf8ER_\xbcD/\x0c\x7f\xb8\x84/c$M\x95_\xce~P\xbc/\xda\x88\x86U\x7f\x17\x0f\xf4VeVT?\xf6\x91jP(x\xac\\\x7f\xdb\x8f,\x88\x91\xfe\xcfl\x0e\xb6\t\xdc\x93\xb9%Y\xc8\xfd\xc5\xe1\x1b$N\xee1+\xf2z\xab\x9e]\xc8\xa9^u<1\xf9\xc7\x86c-\x1c-.\xdb\xc1\xae\xcb\x9a\xdf\t\x8d\x0cc^\'\xf3;TC\xc5F7\xf3\x11y\x97\x98\x91\xf2\xfc2d\xca\x9f\xba\xfd\xd0\xbc/\xda\x87\x892\xb3\xf8\xb8\x97\xe8\xaa\xca\x84\xcf\xfd\xa6Z\x94\n>+~\xd7?\xf6\xe3\xcb\x16D\x8f\xf6R\x9e\t\xd6\'\\\x95\\\x9f\xb8\xbc\xee\x91;\xa4\x8a\xfc\x82\x89[\x93r%RS2g\xc3\xf71"(\xfaz\x97\xe98\xd8v\xf2U~f\xc61\x8c\xc0\xabf\xa5\xb5B5\x89W*\\\x1d\xff\x001\xaew\n\xb1\xdc:\xc7h\xaa\x11\xaaF\xa1\xd8)\x9b\x9b\x0eD\xe4ND\xdf\xea\x8b\x10\xbc\x7f\xe5\x0f\x13+2\xe5\xfe\x89\xbc\x95\t\x9f\xfbH\xb5-\xca>+\x17?\xbf\x1eX\xfe\xd1#\xfd\x8c\x9e\n\xb5\n\x95O\xdf\xceGU"\xa5\xdaEnE"\xb7#)\x8enFL\x8d\x993\xeeg\xfe\xfe\xe2G\x11K\xaa\xcc\xe0\xa1\xfa\x061\xa1\x8f\xc5\x1b\x8f\xd5B\xe4\xf5d\xefI^\n\xe8\x8d\xd0\xae\x8fV;\xb3\xd5\x91\xba\xf9\xf5D.\x85vz\xa1]\x1e\xab\xe7\xd4\x92\xb9\'rJ\xe0\x95\xc7\xcck\x8a\xb9\x1a\xe7y\xdf\xf1F\xb7\xc7q:\xe5j\xc8\xbb\xad\xfa\'T\x9c\xc9L\xcf\xcd"\xd5\x94\x1a(\xc8\xff\x00\xca\xcf\xe2\xeb\xf7\xe3\xd9b\xc8\x11\xfe\xc6\xa3+O\xcb\x92D\xae\x12*\xde\xe0\xaf\xc9\xa4U\xe4%3v\xc4\xcc\x993\xe3>\xfc\x88\x89J.r\xa7\x1e\xbau\x9b\x8d+\n}6\x03\x18\xc6?\x14\xa9\xb2\x9ah\xc3$\x99\xd6\xc5M\x8a\x93:\xd9\xa3\x1d6u\xc8P\x91\xac\x88\xa9\t\xc8r\x90\xaaHU\xa4w2U\xe4J\xbb%]\x9d\xcf1\xae\xc5\\\x8d\xc1\xea~}IJ\xeb\x0b\xd6\x13\xbb+]\x9779\x8c\xab\x12\xaaJ\xa1\xb9Jx-\xeb\x14n>(\xdc\x1e\xa3\xe2\xbd\xcf\xc5\xc5\xc7\xcf\x19W%\x83\xca\x81\x11\x7f^\xca\xcc\xad/\x97Q"w)\x15op\\rX*\xdfJ\xa1\xb6D\xc4\xc5\xf82d\xc9\x93""qT{n\xd9Uo\xdc}\xb8\xfbi\xf6\xd3\xed\xa7\xdbO\xb7\x0f\x8e>\xdc}\xb8|x\xf8\xe1\xf1\xa4\xb8\xc3\xedl\\[>\xd8\xcf\xb6\xb3\xed\xec\xf4\x0c\x8d\x94\x87i"VreK9\xe6\xad\x94\xc7c2V3%c3\xd1L\x8d\xb4\xd1N\x94\xd1\r\xd1JrC\xad,U\xad"\xadG\x9e&O\xde}\xb9\x1fn>\xdc\x8f\xb7\x0f\x8dC\xe2\xd0\xf8\x9c\x8f\x88\x1f\x0e\x89p\xa3\xe1Q\xf6S\xec\xe7\xda\x8f\xb6\xe0|yS\x8e*qm\xbe7\x8d\xd4\xb3\xa1\xa2\x81\x11\x7f_6T+\x9c\xdd\\O&L\x88B\x10\xbf\x13\xf0\x88\xa2\x9cN\x16\x96\xb6\xa7\t\r\xaayc\x18\xfc+QZ\x9e\x95\x1e\x99\x1e\x99\x1e\x9d\x1e\x9d\x1d\x08\xe8GB:Q\xd2\x8e\x94t\xa3\xa1\x1d\x08\xf4\xe8\xf4\xc8\xf4\xc8\xf4\xa8\xf4\xa8\xf4\xa8\xf4\xa7\xa4="=!\xe8\xcfFzC\xd2\x1e\x90\xf4\x83\xb4=!\xe8\xcfF;#\xd1\x1e\x88\xf4C\xb2\x1d\x88\xec\x07\xc7\x14-\x14\x08D\x8a#\xfd\x85FTe\xcc\xbe9Z\xbd\x97\x992!\x08B\xfcLlD\x08\x11\xf9v\x90\xeb\xb6\xa9-)\xf1T\xbal<\xb1\x8c~\x121\xe3\x06\x0cy\xc1\x83\x06\x0c\x180`\xc1\x83\x06\xa6\xa6\xa6\xa6\xa6\x86\x86\x86\x86\xa6\xa6\xa6\xa6\xa6\xa6\xa6\xa6\xa6\xa6\x86\x88\xd0p44\x14\x05\x11!\x7faQ\x95Y}=cZ{\xd6l\xc8\x84DB\xf3\x93>\xe6\xcc\x91!\xfbA\x16T\xbbn\x11s\xfa\xa3\x08\xe9C\xd8\xc6?\x0b\xf9Y\xf6`\xc1\x83\x06\x0c\x180`\xc1\xa9\xa9\xa9\xa9\xa9\xa9\x8f\xec\x19Q\x95\x99\xccW\xeb\xa1\x91\xb3"\x11\x11~,\x8f\xc4Jh\xa6\xb0p\x94\xf7\xb9)C\xba\xfa\x7f\xbf\xb1\xf8~\x17\xf1\xb2l9\x8e\xa1\xb8\xa4\'\xfc\x0c\x7fe"l\xaf#\xea:\xf8\xa7\x9f(DE\xed\xc9\x93>_\x84E\x14\xd1\x14pt\xb1H\xe2#\xd9v\xff\x00\x7fs\xf0\xbf\x85\x93&\xc6\xc3\x99*\xb8\'t\x91;\xf4\x8aw\xc9\x94\xea\xecF_\xff\x00\x05Q\x95\x19q/\x8ez\xb6\xd7/\xc2\x11\x12"\xf6d\xc9\x9fc\xf0\x88\x90"q\xd0\xeb\xb4o\x0b\x85\xa7\xa5\xa7\xb9\x8f\xf89666\x1c\x89UH\x9d\xd2EK\xf4\x8a\xdc\x9aE\xcf0\xa2\xaf~\xa3\xeb|o\xd4]\xf2\xe3\xaf7P\x91\x17\xff\x00\xf05\x19Y\x97s\xc4o\xea\xf6]\xbf(\x89\x11x\xcf\x8c\xf8\xcf\x9c\x8f\xc4H"\x05\xbcw\xab\x05\xacn?\xfcm\xe1\xd7k\xf8W\xe5r777\x1dD\x89\xdc\xa8\x95o\x92+\xf2i\x17\x1c\xd2\x89s\xf5\x02+ss\xa8U\xb9\xa9T\xaa\x8bK\x8fO[\x81\xbf\xde6\xb5w\x84X\x9f\xf7\xec\xa8\xca\xcc\xe5+iFS\xdaNFL\x91"#>27\xe7>\xd4D\x81\x03\x87\x86\xf7h\xd7\xb2\xea\x7f\x1f\x87\x1f\x95\xb2R%S\x04\xae\x92*_\x15y\x14W\xe5\x92.\xf9\xddK\x8ej\xa5B\xa5\xcdJ\x86L\x99*~\xd2\xf8\x7fOr\x1a\xcf\x8b\xba\xde1\x91\x17\xfd\xfc\x8a\x8c\xae\xcf\xa8\xab\xe9m\xe1\x08\x89\x11\x192d\xc9\x93&}\xa8\x89\x11~\xdc\r?\x84q\x91\xed\xe4d\xf3/{\xf0\xbc/\xc2\xc92L\xbe\xba\xebU\xf9\x84\x8b\x8eu\x159y\xcc\x9d\xcc\xe6M\x8c~\xc92\xaa,\xee==n\x03\x91\xde6\xd5w\x8cX\x9f\xf7\xd3*2\xe6_\x1fRW\xcc\xf3\xe1\x11"/92d\xcf\xb5\x88DDD\xe2i\xf5\xd9\x9c\x0c\x7f\xe3\xfc\x8b\xf0\xb2e_\xdb\x96\xd9\xc6\xf3uQ\xb3c#05\xe5\x8c\x99/\x83\xe9\xdeGIqW{\xc6/\xe2/\xfb\xea\x8c\xaa\xcb\xb9~\x9ef\xafm\xf7\x88\x91\x17\xb3&|d\xcf\x9c\xf8B "\x92\xcc\xa8C\xae\x9dw\x8a64\xfal\x7f\x1a\xfcsD\xe2_\xdb\xe4\xe5\xec\x89\xc7\x06\x04\x8c\r\r\rxc\x1a\'\x12\xda\xb7E_\xa7\xb9\x1d\xe3kWxE\x89\xff\x00{U\x95\x99\xc8\xd4\xd6\x15\xe7\xd9[>\x11\x12>3\xe3&L\x99\xf6\xa1\x08\x89\x13\x8b\xa5\xd9v\x8a\xdf\xaamiO\xf1\xaf\xc7"e\xdc2\xb9;}\x95\xe5\r&\xe3\xe3\x03$?\x0cc$\x89,?\xa7\xaf\xba\xaaq7\x9b\xc22"\xff\x00\xbceFV\x91\xcfW\xeb\xb6l\xc8\x88\x88F~2d\xc9\x93>r7\xf0"$D@\xe0)\xe6\xa2,\xe3\xdd\xc9Uy\xa9\xf8\xd7\xe3d\xca\xd1\xccoid\xe4m\x89\xd3\x1cq\xe1\x8f\xf7h\xc1!\x8ceD[U\xe8\xad\xc0r\x19\x8d\xa5m\xe0\xa4\'\xfd\xdc\xd9Q\x97\x12>\xa6\xb8\xfd\x0f\xc2"D_\x81y_\xb9\x11\t\x10\xf88:ZZ\x9c\x14w\xab\x9c\xbf\xc6\xbf$\x89"\xee\x05\xf5\x0c\x9745u 5\xe1\xa1\xaf\x0cc\x19!\x9c\x07!\xd7>"\xf7x\xa9\x11\x7f\xdd\xd4eV]\xcf\x0b\xea\x1b\x8d\xef7\xf0\x88\x88\xc9\x93&L\x992/?\xf9\x11\x11\x11\x16Ad\xb2\xa7\xd7oV]t\xb8\x9a}<\x7f\xf2\x18\xcb\x98d\xb9\xa4^\xdb\x95!\x82q\xf0\xcc\x12D\x91"C$H\xa1[\xa6\xaf\xd3\xfc\x8e\xca\xd2\xbfd"\xc8\xbf\xeeYU\x95\x99\xc8\xd4\xc4y+\x8e\xcb\xd5P\x8c\xc8L\x8b\x11\x9f\x192g\xca\xf1\x91\x08DDE,X\xd3\xec\xba\x89s\xfa\xa0\xa3\xd5k\xfcv1\x95#\x95qH\xbb\xa3\x92\xf2\x96\xae\xa2\x1f\x96I\x12D\x89\x0c\x92&p7\xddU8{\xdd\xa3\x19eE\xff\x00s"\xab+3\x9b\xab\xa5\n\xd4\xdc\xa4\xe2\xd1\x19\x10\x99\n\x84dg\xda\x84#>\x10\xbcD_\xbc\x0e\x06\x9e\xf7H\x84;\xaf\xeb\xbf\xd7\xfc\x861\xa2\xbd2\xe2\x91\x7f@\xad\x1c9{$\x89"D\x862h\xa3Q\xd2\xa9\xf4\xf7!\x95e_\xb2\x11b\x7f\xdcH\xa8\xca\xcc\xfa\x82\x7f\xf1N\x81R\x81:G\xccHT!P\x8dCs>\xd5\xe6$E\xe2$\x19\xf4\xed,P8X\xf6\xdf\xce[K\xf9,d\xe3\x95qL\xbd\xa3\x95}K\x0e^\xc9\x12&H~$\x87\xfb\xf0\x97\xbdS\xe1\xef\xb6\x8d9\xec\xa2\xc4\xff\x00\xb7\xa8\xca\xac\xae\xfe9\x8f\xd7Vt\x8a\x94\x89\xd2%D\x95<\x1f(\x8dB5\x05P\xdc\xd8\xcf\x8c\xf8DD/\x11\x17\xc9\xc5\xd2\xe9\xb2rP\x8f\x05\x07J\xcf\xf9LcEzy.\xa9\x9c\x85\x12\xaa\xd6^2H\x99!\x92\xf0\xc9\xa2\x95GN|\x0f#\x95aq\xbc#"/\xfbz\x8c\xaa\xcb\x9f\xfa\xde-\xebN\x99:D\xe9\x12\xa4N\x91*$\xa9\x1f\xf5;p*\xc4j\x11\x98\xa5\xe6"\xf0\x99\xfb\x91-a\xbdzq\xd27o\x14(C\xa7\x8f\xfeS\x18\xc9\xc7*\xea\x99}D\xbf\xa3\xabl\xc9\x91\xb2l\x90\xc9y\x98\xce\x1a\xf3\xaa\xaf\r}\xb2\xa7Se\t\x7fl\xca\x8c\xa8\xcb\xc9b3Yr\x81*d\xa9\x92\xa4J\x91*D\xe9\x13\xa6J\x88\xd3\x88\xaa`\x85b\x15\x05<\x99 /(\x81\xc0R\xec\xbeD\xd7e\xdd\xc7\xe9\xfec\x18\xd1qO%\xe5#\x90\xa0W\x8e\x93\xc9\x91\xb1\xb2llcfF\xc9\x94\xa7\xa4\xf8\x1e@\xe3\xeew\x8c\x19\x17\xfd\xac\x8a\x8c\x9b9\t\xfe\x861\xa1\xc0\x94\tS%H\x9d"tIQ*Q\'D\xd5\xc5\xf6`\x85b5rR\xa9\xfac/\x19 \xc8\xfe\xdfK\xd2\xf8G\x17O\xbb\x96\xac\xf6\xab\xfc\xb61\x93YWT\x8b\xea\x1f\x1c\x8d\x0c\x1926I\x92c\x1b21\x8cg\x13w\xd3W\x85\xbd\xca\xa5Sh\xc2F\x7f\xb4\x9b*2\xa39\t\xe5\xb1\xf9q\x1c\x07L\x95"T\x89Q\'D\x9d\x02T\n\x94\x8f\xfa\x90\x99J\xaf\xc4*\x11\x9eL\x90"pTz\xb8\xf4}8\xbf\xe3\xfek\x19"\xe2\x9eU\xdd\x13\x92\xb7.!\xd7S#d\x99&6?,c!=%\xc0\xf2\x07\x1du\xbca"2\xfe\xd2\xa3*2\xb4\xbe.\xe5\xb5Q\xfbu\x1c\tS%H\x95"t\x89R*Q*Q%O\x04g\xab\xa7\\\x85aT)T\xf8\xa3\xfa\xea[S\xea\xa1s=-\xf8\xea~\x9f\x8c\xfek\x18\xc9\xac\x97T\x8b\xfa\x07\'n1\xb1\xb1\x8cc\xf6?\x1c]\xd7M^\x1a\xfb*\x85]\xe3\t\t\xff\x00gU\x95Ys,)\xbc\xcb\xdf\x81\xc4p%L\x952T\x89\xd2*R+\xc4\x99\x16F\xa3\x8a\x85\x7f\x9aUO\xa7\xe1\xeao\xd1q\xfa\xe5Qu\xd1\xfes\x19$W\x86U\xed\x13\x93\xb7.\xe9\xf5\xcd\xb1\xb1\xb1\xb1\x8f\xda\xc8\xcbW\xc1\xdf\x9ce\xd6\xd1\x84\x88K\xfb&UeF^\xcf\x11\xf3\x9f\xc1$k\x92P*S+D\xb9\x1a!L\xeb\xf8q\xc1N\xa3\x8b\xfa*\x8ef\x8bJ~\xa3\x95\xb9\x96k\x7f9\x8cd\x91wH\xe4(\x1c\x9d\xb1/\x86dc\x1f\x97\xec\xe3nzj\xf0\xb7\xd9V\xf5w\x8d9\x11\x7f\xd8\xcc\xa8\xca\x8c\xbf\x9f\x97\xe1{\xb0L\x8f\xed"\xa1p\xca\xc7NH[\x8e\x87\xc5J%*;\xd4\xfab\xdb\xa2\xc5\x1fN\xc3{\xa9Oillllllllnlnno\xfc&1\x92+\xc3*\xfa\x89\xc9[\xe5_Q\xd2y\x1b\xf0\xfd\xec\x8b\xc3\xe1/\x8e.\xebh\xd3\x91\x07\xfd\x8dFTeW\xf1y<\xd4\xcf\xe3\x99\x17\xf19\x95j\x15\xead\x97\xc9N\x9eHQ\'H\x95"\x95,K\x8d\xa5\xd3eV\xa7U\x1e\x1a\x1e\x97\x88\xdc\xec7;\x0e\xc3\xb0\xec;\x0e\xc3\xb0\xec7;\x0e\xcf\xe1\xb1\x8c\x92.\xe9e_\xdb\xfcrv\xa5E\xa4\x9f\x861\xfb_\x8e>\xe3\xaa\xaf\x0b{\xf1k[xS\x90\x9f\xf6\x15YU\x97\x12\xf8\xad-\xa7\xf8\xe4\xc9\xcf\x05Z\xa5j\xc4\xeae\xd3[:\x14\x88\xd3\x1c\x07H\xb0\xb6\xee\xbbK\x05\xeb\xff\x00\x8e\xe5\xab~?\xb0\xec;\x0e\xc3\xb0\xec;\x0e\xc3\xb0\xec;\x0e\xc3\xb0\xec\xfe#\x19$V\x86U\xf5\x03\x92\xb6\xc9\xc8P\xd6^\x1f\xe1\x8b\xc3\xe1o\x8e.\xefeJd$/\xeb\x99U\x95YyS\x11o?\x8b$\xa6T\xaaU\xacU\xaeV\xacC2v\xf4\x8a0\x14G\x13S\xe9\xfb}\xaf\x8d{\xf9.n\xbaW]\xa7i\xdav\x9d\xa7a\xd8v\x1d\x87a\xd8v\x1d\x87g\xf1\x18\xc9\x12E\xdd,\xaeB\xdc\xe4\xed\x8a\xb0\xeb\x98\xfc\xb1\xfb\xack\xf4\xd5\xe1\xaf\x8bJ\xdb\xc2\x9c\x88\xbf\xeb\xa4TeY\x1c\x8d_\x8d\xcd\x8d\x8c\x993\xec\xc9)\x95*\x95+\x15\xab\x95\xab\x8b5\x1d\xbd\x12\x84\x08/-\x1fN\xd2\xd6\x99\xc1\xc3\xbb\x95\xbe\xb8\xee\xbb\xdc\xdc\xdc\xdc\xdc\xd8\xd8\xd8\xd8\xd8\xd8\xc9\x93?\xc5c$\x8a\xb1\xca\xbf\xa0rV\xf99\x1b}|\xbf\xc2\x8e\x1a\xf4\xe2\xaf6T\xa6S\x97\xf5\xd3eVW\x9f\xc7!W57766\x14\x84\xcc\xf8r%P\xa9T\xab\\\xabX\xabXI\xcd\xd0\xa2Q\xa4R\x89\x04c\xcf\x17K\xaa\xcaR\xd2<\x1bv\xdcVM\x8d\x8c\x992d\xcf\xf3\x18\xc6M\x17t\xb6\\\x85\x03\x92\xb7+\xc3\xae\x7f\x8e\xce\xbfM^\x1e\xf8\xb1\xaf\xbciH\x8b\xfe\xb6\xa3+2\xeex\x8d\xd5]\xaa\xeenl)\t\x89\x99\x1c\xc9\xd5*V*\xd7*\xd7\'W$`\xe4Q\xa0R\xa5\x82\x9c\x08D^i\xc3z\x94\xe3\xd7N\xfez\xda_\x7f\xf58\x0c\x992d\xcf\x84/\xc5\x93&}\xb93\xef\xc9\x93>\x18\xc9"\xacr\xb9\n\x07%nr4\x06?\xc7\xc4^j\xf8\x8b\xcc\xaa\x152\xa1#&L\x992d\xc9\x93>2d\xcf\xf3\xd9U\x95\xd9\xc8\xd5\xd62\x9ee\xb1\xb0\x98\x84ds\'T\xa9X\xab\\\xabX\x9c\xf6)\xd2\xc9J\x89N\x91\x08\x10\x89\x13>x\x9a}\xb7\xa5\xcf\xfc\x97?R\xd4\xd6f}\x88B\x17\xbf&M\x8d\x8d\x8d\x8d\x8d\x8d\x8d\x8d\xcd\xcd\xcd\xcd\x8c\x99\x1b2d\xcf\x862h\xbb\xa5\xb2\xe4-\x8eF\xdc\xb9\xa7\xd7?\xc7oS\xaa\xa7\r|X]m\x1as#3cccccccccccc&L\x992g\xf1d\xc9\x93&}\xd92g\xcc\x8a\xac\xaf#\x96\xab\xfa|!\x08\xc8\xe6J\xa9R\xb9R\xb1V\xb1\x977N\x8eJTJt\x88@\x8cH\xafo\x03O\xf5\x1cm?Q\xcd\xf3U\xbb\xb9/b\x10\x85\xf8Y\x93c\xb0\xed;N\xc3\xb0\xec;N\xd3\xb0\xec76269\x8af\xc6F1\x95\xff\x00nA\x1c\x8c\x0b\xeaCX\xfc\x9cU\xde\xaf\x8c\xbe-n\x94\x94*\x9d\x86\xe6L\x9b\x9d\x87a\xb9\xb9\xb9\xb9\xb9\xb9\xb9\xb1\xb1\xb1\xb1\x93&L\x99667777;\x0e\xd1T\x14\xcc\xfb%#qHO\xc4\xd9U\x973\xf8\xe5jfBB^29\x93\xaaN\xa9R\xa9:\x995r)Q)\xd1!L\x8c\x08\xc4K\xdd\xc3CK3\xe9\xcf\xd3Nrry\xf6\xa1\x0b\xf0\xe0h\x92&\xc7S\x02\xacw\x1d\xa3\xad\x83\xbc\xee;\x88\xd4\xc9\x19\t\x9b\x92\x91:\x98;\xd1\x1a\xd9#3c$\x99uSU{[%\xeb\xd8\xbc\x81R\x9f\xcb\x81\xa9\x83\x1e\xdc\x180Q\x9b\xa7>6\xf7\x05\x9f!\xf1F\xfd\x14\xeeT\x88\xd4\x15C\xb0u\x07TUN\xd3\xb4\xef;\xcfP*\xe2\xaa)\x9b\x1b\x192g\xdd\x83\x06\t2upz\xaf\x98W\xc9\n\x84d\'\xe1\xb2\xa4\x872\x13!/\x15\x19Y\x97s\xfd7\xb2\xde\xb2B^\x1c\x89L\x9dBuJ\x95G,\x91\x86JtJt\x88@\x8c\x08\xc4K\xc6L\xf9\x8f\xcb\xb6\x87]\x0b\xda\x8e\x8d\xaf\xff\x00\xe0\xfak>\xd4!\x0b\xf0jjhN\x04\xe9\x15i\x8f\xe1\xaf\x13fG#\xb4\xa1=\x88\xc8\xec%]#\xbfwZ\xe64\xc9\xde\xc2M\xdf\xba\x13\xa3\xc9&\x95\xecY\xea\x91+\x94\\\xd6SW\x94\xdb.a"\xe2,\xa9\x05\x99Dp\x1c\x0e\xb3\xad\x9dL\xd0\xd0\xd4\xd0\xd0\xd4\xb5\xac\xe0\xedod[\xdd\xcd\x96\xb7\x05;\x93\xd4\x9e\xa8\x95\xda%x+\xd3\xd6|;\xc1\xde\x8e\xfcW\xe5+\xad\x8aS\xc9\x0f\x91DQ555550`\xc1\x83\x04\x91Q\x15\xc9\xcb\xf5Q\x99I\x90#\xe1\x95I2\x9b)\x9f\xf9T\xaf"\xfexS\xfdSH\xc9)\x12\xa8N\xa9R\xb1:\xc6\xceE8\x14\xe9\x90\x81\x08\x11\x88\x90\xbc\xe7\xceK\n}\xb7G!\xfa\xcf\xa8\xaauZ\xe7\xda\x84!\x0b\xdf\xa9\xa9\xa8\xe0N\x99Z\x99R\x9f\xce02c\'2\x9d=\xcduN\xbe\xa4\xef0\\r\x18)_\xebF\xe7\x91\xf9\x97%\x99T\xba\xde\xd6\x1c\xdb\x89C\x9c\xc9O\x94s)\xd7\x94\xc8\xac\x95\xa9,]\xd2E\xdc>n!\xf3\x83\xaf\'@\xad\x8fJzc\xd2\x1e\x8c\xf4H\xf4(\xf4(\xf4(\x8d\x92E\x1a8),\x14j\xeaB\xe4\xf5?\x15.\xf0T\xbd%zF\xed\xb1]\xbc;\x99\x12\xb9\x99\xdf6R\x9c\xdc\xac\xe1"\xde\x0c\xa7\x11D\xd4\xc1\x8f\xc1\x82H\xa9\x02\xb5"\xa5\xb9F\x8e\nT\xc8@Q04VD\x97\xcd\x14SG\xfeV+\xb3\x92\x9f\xe9\x1c\x89T\'T\x9dRuI\xd4\xc8\xa3\xb1N\x89N\x89\nd F"^rg\xdb\xc1S\xda\xe0\xa5\x0fS\xcd\xfdE_\xb7\x93\xf6\xa4$!/\xc1\x8fd\xd1Y\x15\x17\xcc\x87,\x12\x91&\xd8\xa9\xbc\xc3\xf4\x95+|U\xb8+\xde\xa8\xab\xeeU\'G\x92\xda\xda\xb5}\xa4\xeb\xbd\xa7p\xd5\xbd:5j\x16\x96\xb3\x83\xb2\x86U(4\'\x82\xa4\xfe.\xbeK\xaae\xc52T\xfecHT\x85L\xeb:\xce\xb3\xa8\xe8g\xa6g\xa5\x90\xac\xa4+\t\x11\xe3\x99\x1b\t\x1e\x86dm\xa6\x85\x06\x8a\x94\xb2;Y\xb3\xed\xd2\x91O\x8c\x91\x1e2G\xda\xd8\xf8\x96\xc5\xc3\xb2\x97\x12\xe2\xed\xac\xb5)P\xc1\nB\x81\xa1\xa9\xa9\x81\x8d\x9b\x1b\x1b\x1b\x1b\x19\x1a%Lv\xf9#o\x824\x85\x03\x06\x06\x8a\xa8\x9cJH\x80\xff\x00j\xec\xac\xceK,\x94Z\',\x15*\x95+\x12\xac9d\x85<\x94\xe8\x91\xa6F\x04`E\t~.\n\x9e\xb6\xe7\xd3\x91\xed\xbe\xb8}\xf7\x1a\x1a\x1a\x1a\n"B\x88\x90\x91\x83\x06\x0c\x180k\xed\x91Q\x15bN\x04\xe28\x8a&0M\xe0\xab6\\I\x97\xf5\xd9:3\x91\xc7\xc1\xc1\xfd\xaal\xa5\xc2\xcaE\xc5\x8cd\xa8\xf1i\x10\xb2Q#KR\x95F\x85$\xc9\xb2\xe0\xb9*\xc3\'\xa6m\xc2\xd5\x91\xb3dl\x85b+\x02\xde\x8f\xb7!\xf1\x91>\xd7\x11qh\\jB\xe3\xd0\xacEfz$z4+DF\x86\x08\xd3\x14L\x180`h\x92\x18\xfc\xe4O\xce\xa6\x86\x86=\x92*"p)\xc4\x897\xf1^d\xfeJ\xf47s\xb2+q\xe5\xc7\x1c\xca\xdc|\xc7gQ\x10\xa0\xd1N\x99\nb\x88\x97\x88?\xc1\x9f\x08\xb0\x87]\xa5j\xbd4xzn\xd3\xe9\xfa\xdb\xfa}\x0c|\xe4\xc9&M\x92Y\x1d2T2;Tzl\x0e\xdc\x9d\xa9R\xcc\xb9\xb3r\x1f\x0b\x93\xecq>\xcc\x8aVu(\x92\xb6\xa9UF\xc7S\xd2\x9d\x03\xa28\x93\xaa\xa0U\xbc\x1dy\xcd\xc2\xd1\xd4\x17\x11\xb1\xf6Qp\xf8\x17\x14.4\xfby\x1e6\x0c\x9f\x12T\xe3*"V\x95"8\xb5\xe7\x80\xa7\x88\x1c}=\xab\\\xc8HR\xea(\xdd\x91\xaa\xa4)\nf\x7f\x1e\xc6\xc6\xc6F`\xd4\xc7\x8c\x99\xf62L\x93\x1c\x891\xc8\x94\xc9L\x9c\xca\x95\xb0T\xba*^\xe0\xa9\xc8#\xee8t\xb9l\x10\xe6R\x173\x91r\xb9\x17$B\xf9\xb2\x17-\x91\xaa\xc5PR\x13\xf2\x85\xe3$\xe6W\xa8Vs\x9bP\xa8S\xd9\x11\x9b;Y\xda\xc5U\x8a\xb0\xab\n\xa8\xaa\x8a\xb1\xdc:\xa7i\xdav\x9d\xe7x\xab\x1d\xa7a\xdav\x9d\xa3\xaaw#\xb4u\x89WC\xb9G\xa9G\xa9\x1d\xd8\xefEzF\xed\n\xb9\x1a\x99\x17\xed],]\xbdd\xab\x1d\xa8\xd8\xc9\x93#&&\xc8?\xc2\xe2\x99+js*\xf1Tj\x158"\xc2\xdf\xd2\xdb\xe7\x0b\x8c\x86)\xcf\xf5J\x85\x179O\xff\x00\xd1\xa1Wp)]\xe4\x8dT\xc51LO\xf1d\xd8\xc9\x9fk\xf1\x93&\xc6\xc3c\xc9,\x92R%\x19\x12\x84\x89S\x91:3*Z\xcd\x93\xb0\x9b\'\xc5T\x91.\x16\xa3\x17\x052\x1c\x0c\x88pL\x87\x08G\x87\xc1\x1e-"<~\x08Z\xe0TELQ0c\xd9\x91\xc8\x93\'\x0c\x9d\'I\xd2u\x1dGY\xa35f\x19\x81du0J\xeb\x07\xad\x15\xd1\xeaI]\x8e\xf1\x8a\xf0\x8d\xd9\xeb\t_\x1e\xbcW\xe7\xad%z;\xf6}\xc0\xf5\xd9%v\xc9\\\xc8\xf5R\x15\xdb=I\xda\xd9\xdb$+\xa6\x88^\x94\xaf\xca|\x84\n\xb7\x91\x92\xab\xff\x00#\x9d\x12Qh\xechW\x02\xb8\x15D\xcd\xbcj#>3\xec\xc9\x9fu_\x98\xdb\xd9u\xd3\xf4E\xbc\x15\x15\xe9\x93\x94\xa8-enN\x84\x93\x8dY\xd3)\xde\x10\xb8LS\x15AL\xd8\xcf\xb7\xd4\x0e\xe0\xf5\x02\xacv\x9d\xc7i\xdav\x1d\x86\xe6\xe2y545\x1cG\x01\xd3C\xa4:GH\xe8#\xd3\xa3\xd3#\xd3#\xd3#\xd3\xa3\xa5\x1dH\xebGZ4F\x83FM\x8d\xce\xc3\xb5\x1d\xc8\xeeGy\xdew\x0e\xa1\xb1\x9f\xc5\x82q+\xc4\x94\xb4#_&\xe3\x99)\xb1\xd4b\xac\xd0\xaa\xecI6<\x9b1U\xc1\xba\x90\xe2494*\xa2\x92f\x99\x1c\x06\xb0o\x81TG\xc4\x87\x03\xe6\'\xa8\x94O^\xd1\x0eE27\x90\x91\xb4$:\nD\xed\tP\x923(\n\xe9\xc4\x85\xe2dk\xa6)\x192d\xc9\x9f\x19\xf3\x9ff\xfbr\x16\xb5\x9c\xcdG\x01\xc0i\xf8\xc4X\xe8\xc5\x92\xb4\x15\t@R\x94H\xd6#XUE3sc&H\x985\xf3\x93css\xb0uE[\xe6\x9c\xc53a\xc8\xd8\xd8\xd8\xd8\xd8\xd9\x1b#dn\x8d\xd1\xd8\x87U\x0e\xb2;\xd1\xde\x8fPz\x83\xd4\x15n\xb0T\xe4\x1a~\xb9\xb3\xd5\xc8\xf52;\xa4*\x8cRb\xc8\x930\xcdY\xa3:\xd9\xd6\xce\xb3\xac\xeb:\x8e\xb3\xac\xeb\x1cI/\x12\xa4\x99Z\xd4\xa9A\xc4\x84\x8dM\x07D\x95\x11\xc5\xc4\x8d\\\t\xa9\x8e\x91*M\x1f(\x8dmH\xd4R\x1c\x13\'Hi\xc4UZ#p~\x99\x92\xa1\x92t\x1a\x1eb*\xcd\x11\xac\xa4IE\x95)\x13\xa6\xe2)N/\xee\x13\x83\xa3\xcaj\xa8\xf2\xd1\x9bW\x14\xa6\x9d\nu\n\x9cyR\xc6Ht\xeaS\x15\xd4\xe0S\xe4\x11\x1b\x98\xc8S\xc9\x93&L\xf8\xcf\xb7\x8b\xff\x00\xec}Ai\x0f\xd6\xbc\xe0\xd0t\x87\x06\x8c\xb4v\x1f\xa5\x9di\x9dm\x1f(\xd9\x8a\xa8\xab\x8a\xb1\xdc/;\x1by\xc1\x81\xc4p4!-ET\xee\x1dc\xb4\xecgc7gc7f\xec\xdd\x8eL\xd9\x99cl\xf9\xf6\xb7\x82\xbc\x89,\xb4\x84\x8apLT\x91\x1ahT\xd0\xa9\xa1S\x154h\x8dQ\x83\x1e\xcc\x996624c\xc62T\xb7R+Z\xe0Rt\xdc$\xa6j\x8dQ*Qej\x08rt\xdd;\x9c\x99L\x94\x14\x89[\x92\x84\xa2*\xf2\x81\n\xeaB\x8a\x91;d\xc9[\xb4~\xb88\xdejF\xe6\x135\x8c\xc9\xdb\xc6C\xb5\x94W\xfc\x91\x94\xee\xfa\xce\xd52\xa3C\x81Y\xe5Q\xcc\x17\xdc&\xa7C\x94\x9d([\xf3\xff\x004\xf9\x1aU\x16\xb4\xab\x158\xe8\xc8\xab\xc4\x93\xe3\xebS;\xab\xd0t\xf9B\x9d\xec&)\xa6d\xc9\x91\x08^>\x93\x86\xea\xd1b)\xfb\xb08&:#\xa4|\xa3v\x8e\xc3\xe1\x98\xf1\x96l6E\xf8b\xfe.\x0c\x1a\x9a\x9a\x1a\x1a\x8e$\x91q\xf0n\xb3\x11\x8a\xbe\xae\x17%:\xf9!P\x8c\x8d\x8d\x8d\x8d\x8c\x99\xfcLhO\xc3\x8aek5"\xe2\x85Z\x05>E\xe7\xbfd\xea\xc8\xdeL\xd1H\x9d\xb6N\xc9\xd0t\xee\xe3P\xc8\xf0\xc9\xd0\x8c\x8a\x94$\x85Z\xad\xb9O\x90\x8dAKa\xa8\xb2v\xc9\x95-\xa7\x12\x15+\xd2)r1\x94\xbbT\x86\xa0W\xb7\xa5"\xbd\x0f\xd1N\xda\xbaun\x92\x9f\xef\x19K\xe3\x11\xda\xbdU5h\xf3:\xb7\xdf\xf2q\xf7\xac\x8f\xd4\x93\xa7+N~\x95r\x9d{z\xe4\xec\xd4\x8a\xdcM*\x84\xf8&\x89Z^[\xba5o\x11EU\xa8:2\x88\x96\x05\xe3\x93\xab\xd1a\xc1P\xf4\xfcU)8(\xd4\x13\xfc:\xa6:C\xa4u\x9a\xb3\xe7\xd9\x15\xe1\x8b\xd9\x93&}\x99\xfc,\xc8\xa4m\xe3\x1e\xcc\x12\x89wO1\xa8\xe7\x1a\xd4?\xeb\x82\xa4>i\xc4\xa6\x88\x91fL\x992d\xc9\xb1\xb1\xb1\xb1\xb1\xb1\xb1\xb1\x93&L{\'MH\xbc\xe2!\\\xab\x0b\x9e>V\xf7\x94\xeb\xc7U\x8a\xcd\xc5\xc6\xca\xe1\xc6\x95\x86j\xdd\xf14e\x1f\xfe\xd5\xa3\xa1s\x1b\x85\xd7#VJ\x8e\xe4\xec)J=\x17\xb6\xf2\xa7\xc8\xc1\xd4\xec\xddJK/\xaeD\xed\xad\xa6\xa7B\xe3kI\xdd\xcc\x9d9\xb2T\x1cUi2\xa5:2\x9d\xc4\xd3\xa2\xad\xaez-\xf4\xafZ\xa5\xb2\x90\xb8\xfa\xcc\xfb=\xc4\xc8\xf1W\xd0\xa3W\xe9\xab\xbaQ\xa7\xc2^J\xde\x1fO\xdd\xa7\xc5\xdb\xdd\xd9\xcb\xbc\x94\xb2/\n2\x159\xb2P/\'J\xda\x8d\x0eZ\xde\xb3\xe4\xaaF\xf2\xf6\x95\x83\xea\xdat\x9d:\xd1\x97\x85&\x85P\xce\x7f\x0e\r\x10\xe9\x9dgX\x84\xfc\xb3&L\xfe<\x992g\xce<\xa1\x1a\x8dy\xc9R\x1b*\xb6\x88\x8cp$8d\x8d<\x11X\x13666663\xfc&\x8a\xb4cR<\x8f\xd31)\xf2\xd5mj\xaa\x92\xac\xae\xd5\xd5{xZ\xdc\xd0\x1d\xbf#5O\x8f\xe4\x9d~K\xe9XV%y}\xc3T\xb7\xe5\xad.a.J\xdd\x1fp\xc8\xae\xeb\x95l\xa7\x7f^\xeb\x81\xe48\xf9\xc2\xc3\x94t\x97\x13\xc9\xb3\xecw\xec\xff\x00_\xb9g\xfa\xcbg\xfa\xbd\x01}3h\x85\xf4\xed\x91\x1f\xa7\xedE\xc0\xd3)\xf1U)\xc1p\xb5\xb3\x0e\n\xe2E\x1f\xa7\x92%\xc3\xd2\xeb|\x0f\xea\xab\xc4\xdb\xdbR\xa5\xf6\xda\x84l\xad\xe4\xfe\xddI\x14\xe8\xd2\xf5\x1e\x92\x94N[\x9d\x87\x11p\xfe\xb2\xb88\xfez\xf2\xf6\xb5\xc5*\x93%\xc7\xdcI\xf2\x1c\x1d\xdd\xcd\xb7\xfa\x15\xfb\x7fO}\x19q\xc5]ZE\xc6\x94\xa9\xa9\x12\xb4\x8b:e\x11g\xc7\xec)\nF\x7f\x1e\x86\xbe5\x1c\tC\x06pdlR\x19\x93&L\x99\xf1\x83\x06\rML\x180jc\xc2\x90\xa6|3\x03\xa6:c\xf8+\xcd/\t\t\t\t\x7f-\xc4\xbd\xe3i_\xd1\xb7\xfaB\xbd\xb5\xcb\xfasgS\xe9*\x15\xa1kj\xadm\xf0(\xb6N\xcaU\xe3K\x84\x8d%\x1e.\xa2>\xdbX\xfb]Qq2>\xd2}\xaa"\xe2\xe9\x1fm\xa0\x8fCn\x8fIE\n\xde\x92:\xe0\x8f\xaa?\xc8\xd4~\x99\xe4\xff\x00\xf9\x8au\x1f\x1f\xf5\x8f9\xca\xbb7\xc8\xd5T\xe8\xc8Q\xc7\x99\xd1\xd8\x8d\xa2\xc2\xa4\x91\x8444\x8db\xcdbk\x13X\x1f\xa7\xdb\x1f\xdf\xc6\rM\r\r\x0f\xd8\xd8\xdb\xf0g\xc2\x7f\x02\xf1\x92R\xc1&\x99\x9c\x1f\xb9\x8f\x91\xf8\xc1\x81\xa1\xfc\x1b\xb3\xb0\xd9\x1b\x1b\x9b\x9b\x1b\x8eBf|\xe3\xc6Y\xdd\xf2\xe6\x99\xc8\x7f\xf9\xd3\xfdQHB\xfe\x1a\x8bgEF+J\xec\\}\xc3\x17\x17p}\xa6\xb1\x1e\x1e\xaeW\x0e\xc5\xc4D\\M#\xed\x94\x10\xb8\xebqX\xd0B\xb7\xa2\x8e\xb8!$\x8c\x99\xfc\xb8/\xfe\x93\xb1\xe5/\xad>\x9a\xe3\xec\xc8P\x84=\xeex;M\x8c\xbf8\xf7\xe0\xc7\xe0q\x1c\rO\xd8\xd8\xd8\xd8\xcf\x9c\x99##"f\xc6\xff\x00.I\x95\x7fI\xd9\xa1\x0b\x84\xc4\xd3>=\x9b\x0ecf\xc6\xc4\xbe\x07q\xa1\x1a\x8ac\xf1\xf2`R2\x9f\x9c\xb3c#I\x92\xa6\x8b\x8a\x19\xa7e\x9e\xacx\xc9\x93"y#Jr#g]\x91\xe3\xaeX\xb8\x9b\xa6.\x16\xe5\x8b\x82\xac.\x06b\xe0E\xc1\xd3\x17\rA\x0b\x8a\xb6B\xe3m\x90\xac\xad\xd0\xad\xe9!$\xbd\x9b\xa4J\xe6\x94G\xc9\xdaD\xfb\xcd\x82\x1f=\xc7\xc4\x7fRq\x88\x7fTqh\x7fV\xf1H\x7fY\xf1H\x7f[\xf1\x88\x97\xd7\xbcr%\xfeB\xb1%\xfeD\xb5\x1f\xf9\x16\x88\xff\x00\xc8\xd0\x1f\xf9 \x97\xf9"\xa0\xff\x00\xc9\x17#\xff\x00$^\x13\xff\x00"_\xb2_\xe4.@\x97\xf9\x0b\x90%\xfe@\xbf\x1f\xd7\xb7\xa5\x0f\xab\xf9;\xd9q\xf6|\xf5\xf1c\xc3U\xa4\xa9\xdb\xa8\x18\xc7\xe1\x92%\x98\xb5P\xdb\xf2`\xc1\xf2\x8c\xfb\xf54450c\xd9\xa0\xfe\x05\xe7X\x8e\x19*RS\x15\x08\xd3\xf0\xe2\xd8\xddH\x11\xaclm\x839\x1eQ\xb9Vh\xbc\xbc\x9d\x8d\xcc>\xa8\xb7\x81G\xea\x9e1\x96\xff\x00S\xf0d~\xab\xe0\x11\xfe\xe5\xc2D\xff\x00y\xe2\xd0\xfe\xbd\xe3\x90\xff\x00\xc86#\xff\x00 \xda\x8f\xfc\x83H\x7f\xe4"\x7f\xe49"_\xe4j\xe7\xff\x00"\\\x92\xff\x00"]\x0f\xfc\x85vK\xfc\x83vO\xeb\xfb\xa6K\xeb\xda\xe4\xbe\xbb\xaaK\xeb\x89\x13\xfa\xdc\x97\xd6I\x92\xfa\xb2\x9b\x1f\xd54G\xf5E!\xfdQ\x11\xfdN?\xa9\xe6?\xa9j\x9f\xec\x95\x87\xf5\x15\xc1\xfe\xc1r>v\xe9\x8f\x99\xbac\xe5n\x99\xf7\x1b\xa2W\xf7S=E\xcc\x8d\xee\x99\xa5\xdb\x15\xad\xe4\xcfAx\xcf\xb4\xde\xb1pw\xd2T\xfe\x9c\xbf\xac\xf8\xff\x00\xf1\xaf+x\xf8\x8f\xf1E\xa5\xb9c\xc0Zq\xf0\x8c#\x1f\xc7\x9c\x1b\xa2Q\xd8\xeb1\x83"\x91\x93?\x8f\x06?\x16\rMMMN\xedM\x94\x87\x04\xcd57\x1b\xcar\xc1\xbeF\x9e\rL\xe0x\xa8\xa4\xe5LW?\x0e\xbe\x07r:\x93\x15n\xd2\xa5V\x9c%\xff\x00\rY\xa7N\x9a\xec8\xce\r5\x1e&\xd2\r\xd8P>\xdfnz\x1a\x07\xa2\xa0+j(\xe5j\xd3\xb5\xa5R\xa9\x0b\x92\x17[\x1d\xe8\x97\xed\xcb\xd0\x95R\xe6\x0e.\xaa\xc98\x8dT7\xac\x8e\xfb\x83\xbe\xe0\xef\xb9;\xaeM\xeeY\xff\x00\xdagE\xd3\x15\x95\xd4\x8f\xb6^\x0b\x8b\xbb\x91\xf6{\xc6}\x8e\xf9\x9fc\xbdg\xd8o\x90\xbe\x9d\xbeb\xfaj\xfd\x9f\xea\xf7\xd9\x87\xd2\x172K\xe8\x9a\xe2\xfa&\xa0\xbe\x89\xc8\xbe\x87X\x8f\xd1\x10#\xf45,G\xe8\x9a\x08\x87\xd1\xd6\xa9\xc3\xe8\xfb3\xfdJ\xd8_JZ\x8b\xe9[d\x7f\xac\xc3\x1f`\xd4\x8f\r\x11p\x92B\xe0\xe6}\x8ag\xd8\xea\x90\xe0\xea\x14~\x94\x9c\x9d\xaf\xd24b\xed\xf8\x9a\x16\xca4\xe3\x1f\xc7\xb0\xea\x13\xaf\x82\xa5\xd3;\xeb9P\xa8\xda08\x98\xf1\x93&L\xff\x00!\xb3\xf6p\xa8e2G\xfdE<\x8d\x8a\xa3M\xfe\xa2D\x88K\xf5Md\x9c\xb0\xfb0\xe3[\xf5T\x9e\x83\xac:\xea\xaci^:R\xad\t\xd78>\x0b\xd2C\xddy{\x0bHVu+T\x96\x11\xd5\x19\x12\xa6\xf2\xa1,\xa8`\x9d\r\xca\x9c\\j\x15~\x9fS?\xd6VW\xd2\xf0\x90\xbe\x99\x8aT\xbe\x9e\x8e\x7f\xd5\xed\xe6\x97\xd3\t\x11\xe0\'\x15\x0e#\x04x\x8ad\xf88MO\x80\x89K\x88\x8a>\xc9Jg\xd8\xd2>\xc9\x03\xec\xb1g\xd9\xa1\x9f\xb2D\xfbD1\xf6x\x1ff\xa4}\x96(\x8d\x86\xa7\xa1\xa6z(\xa1Y\xc4Vk>\x9d\x1e\x9d\x1d(\xe8H\xd4\xeb\x89\x18\xe0\xc4Y\xd7\x13\xd3&+:\x848\xfc\x90\xe3\xe2\x88\xdbF"\x82_\x8bd\x87Y"w\x91\x89.R\x04n\xddB9\x91\xd4t\xa3\xa5\n\t\x18\xf6`\xc1\x8fvL\x992g\xf8\x19\x1aM\x7f\xd0\x85U#a\xb4\xcf\xfa\x9b\xe4\xd8\xdfW\xff\x00h\xb9\xb4\xdc\xb2\xe3<\xab\x98\xb6O\xe2R\x9a\x1c\x95H\xc9,e*\x94l\xea\xde\xd6\xe1\xf8\x88\xf1\xf4\xbd\xd7\xd7\xb0\xb2\xa3\xc8r\x12\xe4*\xfd\xce\xee\xd0\xb2\xfa\x8a\x8dr\x12\xa5Xt&8\xb3Fh\xcf\x91\x11y\x14Vp\x8e\xb4(\x98\x136?L\x8e\xb8\x9a\x1dB\xa6\xc5\x13\x03\xa6~\xa4i\x93Vj\xcd\x19\xd7#\xaeg\\\xce\x99\x1d\x13==C\xd3\xd4=$\xc7g\x9a\x07\xa5\xa6z:g\xa1\xa6}\xbe\x91\xf6\xfaG\xa1\xa6z*g\xa3\xa6zJg\xa5\x81\xe9\xa0zx\x1e\x9e\x07D\x0e\x98\x9dQ:\xe2i\x13H\x9aD\xd2&\x915F\xa8\xc25F\x0c{\xdd\x18\xb6\xa9$j\xbf\x16M\xd0\xeb\xa4N\xf21+r\xf4\xa9\x95y\xe8\x95y\xc9\xc8\xa9\xc8Jd\xef0U\xe4>#\xc8\xe2V\x15aQP\xbb\xa7\x05N\xe62\x14\x93\xf1\x8f\x1b\nF\xc6\xc6L\x99\xf7c\xdb\x83\x1e\xdc\x99\xfc/\xf4\x91\xaa)d\x92\xc9\xfb\ncf\xc6\xe7a\xfb\xf8\x940LuZ*GuZ\x8bE;I\xd7|o\x00[\xdaF\x84}\xc8\xc6N\xb8\x9dQ:`t\xc0\xe8\x81\xfb\x7f\x0f\x06?\x8b\x93dv#\xb9\x0e\xe6(\x95\xecQW\x95\xa7\x02\xaf?M\x15~\xa0\x9b\'\xcb\\\xd4%Z\xbdC\xaaGD\x87l\xcfJ\xcfE\x92\\v\xc5N\x1cV\xf7\x14#\xdfyJV\xfc\xddzE\xb7\xd4E\x0en\x9c\xcaw\xf0\x98\xab&n\x85\xe7&\xc6\xc6\xc6\xc6L\xff\x00\x07&L\x99\xf2\xd0\xe2\xc8M\xa1O\'\xee~\xc6|~\xc6M\xf0v\x8ey0T\x88\xe5\xa9\xb2\x93\xe3c\x08\xa8W\x84\x17\xabG\xaaG\xa9\x89\xea\x11\xde\x8e\xf8\x8a\xaa;\x11\xd8\x8d\xd1\xd8\x8d\xd1\xd8\x8d\xd1\xba7Gb;\x11\xda\x8e\xe4w#\xb5\x1d\x88\xdd\x1b\xa3tv#\xb1\x1d\xa8\xedGr;\x8e\xe3\xb8\xee;N\xd4v#\xb1\x1b\xa3tnv#\xb1\x1d\x88\xecGa\xd8\x8e\xd3\xb8\xef=J\x1d\xd0\xef\x12%\xc8E\x12\xe4\xd1>U\x92\xe4\xeb2W\xd72;n$:s\x99\xe8\xc5d\x8fF\x8fH\x85j\x8fN\x8e\x84t\xa3\xa0t\x0e\x83\xa0\xf4\xe8vqd\xf8\xbar\'\xc4$O\x8f\x9c\x08\xba\xf4\x8a\\\xa5je.p\xa5\xcb\xc2D/\xa3"5\xd3;\x11\x95\xe3&M\x8d\x8d\x8d\x8c\x99\xfc\x19\x1c\x8d\x8d\x8c\xfb\xb2)\xa661L\x8dC|\x996\x1b2d\xc9\xb9\xb9\'\x92\xa2\'\x98\x8a\xf6\xe2\x9a\x97-}\x13\xef\xb7\xd1#\xf5\x05\xe1\xfe\xc3vC\xea;\x9c\xff\x00\xb1WG\xfb=X\x91\xfa\xb7\x02\xfa\xc2\x91\xfe\xe1H_USd\xbe\xad\xa6\x85\xf5u6\x7f\xb6\xd2!\xf5e)\x1f\xed4\xcf\xf6\xa8\x0b\xea5%/\xa8dG\x9e\x9c\x87\xcdT\x175T\x8f3P\x87.\xc8r-\x8a\xf5\xb3\xd5\xb3\xd5H\xf5r=T\xcfS3\xd4L\xef\x99\xdd3\xbaGl\x8e\xe9\x1d\xd3;\xe4z\x89\x1e\xa6G\xa8\x91\xdf#\xbeC\xb8\x99\xea\xa6F\xeaG|\x8e\xe9\x1d\xb2\x1c\x9b\x1eG\x13C\xad\x1aD\xd2\'\\N\xb8\x9dH\xeb45\xf6\xe3\xceL\xf9\xc7\xb1\xc52T#"v1d\xec\x07j\xe2\'V\x99O\x90\xa9\x02\x8f,S\xe4\xa3"\x17\x91dk\xa6v\x1b\x192d\xc9\x93ccc&D\xcc\x8eD\xa6v\x1d\x87`\xa6){rFy\x1b\x1b\x14\x8d\x84\xf3\xe38\x1b\x1b2d\xc8\xd8\xfeIG$\x96\xa4\x9eI\xa1e?\xdc\xf9\x13\xcai\xe6t\xb2J\x90\xa2SEH\x1a`q!\xfb\xe3)\xc3\x0e\x830C\xe0l\xd8R7!q(\x90\xbc!s\x91U\xc9\x9f\x18\xf6g\xc6L\xf8\xcf\x9c\x89\x8d\x12BB^3\xed\xc1\x8f92d\xc9\x91\x997;\x0e\xc3s>\xcc\x99\xf7\xe0pL\x95\xbcY+A\xdb`\xebq#Rp!{(\x90\xe4\x08_&F\xe91W\x15Sscc&E#a1\xc8\x9c\xf0W\xbaPQ\xbcR\x15t*\xc8\x8dL\x91\x91\x17\xecd\x7fq\x89\xe0LR\xc1\x9c\x92\x1b\x1b2llg\xc3\'\x1c\x95)\x98\x1c|c\xc7\xee$N\x8eIR"\xb5z\xec\xba\xce\xb3L\x14\x97\xc6\x99\x14uq\xf9^S\xc0\xbcd\x84\xda!]\xa2\x17\x04j\x8af}\xf93\xe3>2db29\x1b\nFL\x992d\xc8\xc7!\xd4;N\xe3\xb8\xed\x1dQ\xd5\x1dS\xb8\x8dV)\x9b\x19\xfc\x18\xf3\x8f\x18\x1cM\x07E2V\xe3\xa0\xd1\xac\x91\x1a\xb2\x89\x1b\xc6\x88_\x11\xbb\x15\xc8\xab\x9d\xc7`\xa6n)\x12\x919\x17\x94\xf7:\xb0=\x91\xdd8\x96\xf7-\x94\xaa\xe4\x8b\xf1\x93c&D\xc6?1\xf1(\x92^\x19\x9f9\x19(\x93\xa64k\xecO&\x07\x01\xd3 \x8dMGL\xa7\x1c5\x11\xc4\x84MM28\x18#\xe3\x02\x10\x88\xd4h\x85r5rnnlld\xcf\x8c\x992g\xc6}\x8f\xc6M\xce\xc3\xb0\xdcu\x07P\xec\x1b\x1f\xc9\xa1\xd4*GP\xe9\x1d\'A\xd0*B\x81\xaf\x8c\x89\xfb2d\xc9\x9ff\x0cy\xc1\xa8\xe0:Ht\x87O\x07\xeaB\xad(\x91\xbbdn\xc8\xdc\xe4\x8dqU\x15A\xcc\x94\x89\xfc\x98\x1a5L\xa7\x04R)\xb3a\xbfb\xf0\xd1\x8f\x11bc$\x89/\x1f\xb1\x93&\xc2\x90\xc6\xb2J\x99\xa8\xe2c\xccdd\xc1\x83>0jD\xc0\x90\x8c\x1a\x9a\x1a\x89\x180/b\xa8\xd0\xab\x9d\xc7p\xaa\x8a\xa0\x98\x8c~6I\x9b\ng`\xea\x1b\xb1HLB\x88\xa9\x9dgY\xd4u\x9dGY\xa1\xa9\x83\x06<\xe7\xd9\x93&\xc6\xc6\x7f\x06\x0c\r\x0e\x07X\xe9\x8e8"\xf0B\xae\x08\xd7\x15qU\xc8\xdeL\x0e&\xa6\x08"\x99\x16llm\xe5\x0b\xc3\xf1\x91H\xd8c$3&M\x8c\x89\xf9\x9a\x18\xfeF\xbc\xc5\x88\xd4\xd4\xd4HH\xd4\xc1\x83\x1ep`\xc7\x95\xe1\xfb2l*\x8c\x84\xc8H\x8c\x84\xfc`\xc1\x8fs\x18\xd0\xe2hhu\x9a\x1a\n"DL\x99\xf7\xe0\xc1\x81\xaf\x180`\xc7\xbb&L\xfe\x0c\x180`q\x1a3\x83\xb7\x07y\x1b\x927"\xb8\x15T\xcd\x93\xf0\x88\xb3sss\x7f9\xf1\x93#\xf1\x93|\x1b\xe4c%\xeeF2jJ\x98\xe2`q\x12\x14H\xfc\x08\xc1\xa9\xa8\x90\x91\x83\x06\x0c~lxR\xc1\n\xa4*\nb\x98\xa4g\xc6\x0c{pjhhhhu\x9df\x86?+\xf6\xe0\xc1\x83\x06=\x992llld\xcf\xbb\x04\xa2J$\x90\xcc\x8ab\xaa*\xc2\xaeF\xe0W\x02\xaew\x9d\xc7q\xdag\xc6L\x993\xedL\xc8\xc6`\xd4\xd4\xd7\xdb\xa8\xe08\x1a\x9a\t`\xc0\x85\xe3\x1f\x8f\x1eq\xed\xc1\x83\x06\x04Fm\x11\xacF\xa8\xaa\x11\x98\xa4)\x19\xfcy3\xf8rg\xd8\xc6\xcd\x8c\x99\xf3\x93>p4c\xd9\x93&L\x99\xf7\xb8\x92\xa6J\x90\xe9\x1a\x18\xf1\xb1\xb9\xda*\xc2\xaew\x9e\xa0\xf5\x1e\xf4\xfd\x8d\x1921\xaff<\xa3\x06\xa6\x86\x86\xa3\x89\x81\x08^\xcc\x993\xef\xc1\x83\x06\x0c\x181\xe3\x06\x0c\x18\xf2\xa5\x825\x08\xd6\x15ALS763\xf9\xf2d\xc9\x93&L\x9b\r\x8d\x8c\xc9\x93&\xc6\xc6L\x9b\x1b\x193\xec\xc7\xe5\xc0\xe0:c\xa4:C\x80\xd7\x9d\x8d\xcd\xce\xcf\xc9\x8f\xc6\x8c\x180`q\x1c}\x99\xf1\x9fb~W\xb7\x1e0`\xc1\x83\x06\x0c\x181\xec\xc8\xaa4*\xc2\xaa*\x82\x99\xb1\xb1\xb1\x9ff\x0c\x18\xf7d\xc9\x93>2g\xcb\xf6d\xc9\x9f\x192d\xcf\xf1\xb08\x8e\x99*C\xa68\x8d\r\x18\xfc\x19\xf6\xe3\xf1\xe7\xdb\x81\xc7\xd8\x9f\xe1\xcf\xf1T\x85P\x8daU\x15ALR\x14\x8d\x8c\xfb0c\xdb\x93&L\x992g\xf0\xe4\xc9\x93&L\xf8\xc9\x9f\xe348\x92\xa6:gY\x93>\xf5\xfc\x0c\xfb\x99\x8f\x08F\x7f\x9d\x91T\x15R5\x08\xd4\x14\xc5!HR2g\xce\x0c\x18\xfcY3\xed\xc1\x83\x06\xcc\x180`\xc1\x83\x06=\xf9\xfe|X\x98\x98\x9f\xf4X\xfe\x1e\x0c\x18\xf1\x83\x06\x0c\x1a\x980`\xc1\x8f81\xe3"\x90\xa6)\x8ab\x99\xb1\x93c&}\xb80`\xc1\x81\xfe\x0c\x993\xfc\\\x99\xf3\x9f\x11b\x91\x19\t\x89\xff\x00?\x1e0`\xc7\xf11\xe7\x06\x0c\x180`\xc7\x8c{\xf2l)\x8ab\x98\xa6)\x8aF\xc6L\x99\xf7\xb3\x1e\xdc\x7f\'>rd\xc8\x98\x99\x16&dO\xc6\x7f\x06?\x8b\x83\x1e1\xfc\xac\x180`\xc1\x83\x06?\x06E!LS\x14\xc51HR2d\xc9\x9fv\x0c\x18\xf6`\xc7\xf0rd\xc9\x9ffF\xc6\xcc\x8aBb\x91\x19\nBb~3\xfc5\xf8\xb0c\xf9\xf81\xf92ln)\x8ab\x98\xa6n)\x193\xef\xc1\x83\x06\x0c\x180`\xc1\x8f\xca\xfcd\xc9\x93&L\x8d\x8eF\xde"\xc4E\x88Bb\x17\xe3\x7f\x9d\x7ff\x98\x99\x93&D\xc4\xcc\xfeL\x7f\t\xfb3\xe1\x99$\xcc\x9f\xff\xc4\x00.\x11\x00\x02\x02\x01\x03\x02\x07\x00\x02\x02\x03\x01\x01\x01\x00\x00\x00\x01\x02\x11\x12\x03\x10\x13 0\x04\x14!1@PQ"A\x05`#ap2q\x80\xff\xda\x00\x08\x01\x03\x01\x01?\x01\x9f\xb9\x04{!\x9a\x84\x84\x85\xd0\xbb\x08C$\xfa\xd8\x892o\xd0\x91!\xed\x03H\x81\x1d\x99\xa9\xb7\x88$?\xb3\xa1i\xd9\x1d\x12:$t\xd2=\x87\xb3)\xb2>\x1eR!\xe1"\xbd\xc5\x18\xc3\xd8\xbd\xdfR\x11\xa8\xea/i<`\xd9\xa7\xebr\x10\x85\xb2\xd9\xfa\xb2\x03\xdalb\x16\xcfe\xd8B\x19.\xcc\x99\xa8Hc\xda\x06\x99\x02;3So\x11\xecH\x7fd\xa3dt\x88\xe9\x0bN\xbaql\x8e\x8d\x91\xd2H\xf4^\xc3}\xb4\x84\x8f\x10\xfd\x12\xdb\xc6K\x1d\x13O\xd2"\x10\x84"\xcc\xed\x9ar\x1c\xc9L\x9c\xcc\x85####!H\xc8\xc8\xb2\xf6\xa2\x84\x8fa\xb1\xbf^\xcc\xcdA\x92\x18\x88\x1ad\x08\xec\xcdM\xb5\xfd\x89\x0f\xecR4\xe0CL\xf6\xe8PlZb\x8aE\xfew\x96\xde!\xdc\xabo\xf22\xf5P\xd9\x08]\x10\xd4\xf5!\xaa=RZ\xc4\xb5\x051L\xcc\xccs\x16\xa0\xb5\x0eAL\xe43\x16\xa0\xb5\x0531\xea\x0fPs\x14\xcc\xc533!Hlr\'#R^\x83ccb i\x90\x13\xd9\x9a\x9bk{\x12\x1f\xd8\xc1\x1aQ\xde\x85\x01D\xaa/\xf3{\xed\xa1\x0bi<\xa4\xd8\xbd\xcf\x13,\xf5\xde\xc8B\x10\xb6\x8cD2H\xc0P001\x1cY\x83\x14YL\xa9\x1e\xa7\xa8\x9b\x14\x98\xa6\xcc\xd8\xe6\xc736)\x99\x8ag!\xc8-A\xea\x0fP\x9e\xa19\x0eCcbd\x19\xa7"2##"S5&&j{\x12%\xf6:h\xd3E\t\t\x1e\xc5\xfc\x04!\x1a\xaf\x186"\xf1M\x8b\xd6M\xee\x84!l\xb4\x8c\x0c\x0e3\x88\xe28\xce3\x8c\xe38\xcc\x0c\x0c\x0c\x0c\x0c\x0c\x0cL\x07\x01\xe9\x9cG\x1a8\xcc\x0c\x0c\x0cG\x11\xc0\x94\ti\xb1\xe93\x85\x9c,\xe2b\x83#d[#&fJd\xa4A\x92\xf6$\x89}\x82 DE\xa4_E\xed}\xc4!\x1e.U\n\xdb\xc4\xcf\r\x16C\xdb\xa9m\x9cNX\x9c\xd19\xd1\xce\x8f0\x8f2\x8f2\x8f2\x8f4y\xa3\xcd\x1eh\xf3G\x99<\xc9\xe6O0\x8f0\x8f0\x8ets#\x9a\',NX\x9c\xa8\xe4\x89\x9cL\xe2e\x12\xd1\xe8b\x8cQ\x81\x81\xc6\x8e3\x03\x12\xb6h\x94\x04\xa8\x9b$K\xec"@_\x11\x08B<\\\xbf\x92[\x7f\x90\x95E@[\xad\x90\xb6z\xe3\xd79\xd9\xce\xcefs3\x95\x9c\xac\xe5g#9\x0eFr3\x91\x9c\xac\xe5g+9\x99\xcc\xcevs\xb3\x9d\x9c\xec\xf3\x0c\xf3\x0c\xf3\x07\x98<\xc1\xe6\x0f0y\x83\xcc\x1e`\xf3\x07\x989\xces\x99\x1c\xc8\xe5G"9\x11\x99\x90\xe4I\x8d\x8f\xec \x88\x08~\xfdW\xddDE\xb6\xb4\xb2\xd4l^\xac\xf1\x92\xcfZ\xbaP\xbe\r\x96Ye\x96Ye\x99\x19\x19\x19\x19\x19\x19\x16Ye\x96YfFFFFFL\xc8\xc8lo\xec`@\x8f\xc3B""o\x15{G\xd3\xd4\x93\xcbQ\xbf\xa3\xae\xab,\xb2\xcb,\xb2\xcb,\xc8\xc8\xc8\xbf\xb1DQ\x11z\t\xfc\x0b\xd9\x11"#\xc4\xca\xb4\xf6\xd5\x96\x1aM\x90\xf6\xec\xbf\x8dE\n&\x06#\x88\xd7\xfa\x14H\xa1\x1f\xd7j\xfb(\x88\xb6\xf1r\xf6\x8e\xde>X\xe9(\x8b\xad|Z(\xc4Q\x14,\x8e\x83d|)?\n\xd18Q%\xfe\x85\x12"%\xde\xbe\x84$DKo\x10\xefQ\x88\xf1\xf2\xbdE\x0f\x93E\x14P\xa2(2:\r\x90\xf0\xa4|:^\xe5F$\xf5\xd4Hk)\x9e#I5h\x92\x1a\xff\x00A\x80\x84\x7f}\x86\xfbQ"DC$\xed\xd9\x1fsVY\xeb7\xf1\xe8\xc4\xc4\xc4\xc1\x91\xd1l\x87\x85d|2^\xe5B\x04\xb5\xa2\x87\xe2/\xd8\x94\xdb$Bx2/8\x9a\xf0\xc5\x8d\r}\x05\xf7l\xb2\xfe\x02"""\xebo\xae\xfa"D\x88\x8dgPc\x1b\xc6\x0eD}m\xfcd\x88\xc4P\xb2:\r\x90\xf0\xa4|*B\xd3\x84Ij(\x93\xd7\x7f\xd0\xe7\'\xd0\xc6xmK\xf4\xa61\x8cc<>\xa7\xf4{\xa1\xa2K\xea\xaf\xbb{!t\xa6&Y}V7\xd0\x88\x91\x10\x84j?\xe23RXi\xb9\x1a+\xf8\xdf\xc9[#Jf\x9c\xac\x90\xfa\xd8\xc7\xb3"\xf1viN\xd1$45\xf5\xb7\xdc[>\x8b\x132,\xbe\xca"DB\xdbU\xed\xe3\xa5\x8e\x95\x11U\x14\xbeR\x11\x17F\x8c\xcftMu1\x8c{1\x9a\x13\xfe\x8ft45\xf7\x0bw\xd5e\x96&Ye\xee\xc4D\x88\x84-\xb5=^\xde9\xe5\xa9\x18|\xc4#JTi\xbbD\x90\xfaX\xc6=\xd9\x17N\xcd)\xda\x18\xd0\xd7}\xfdB\x17b\xb7\xc8L\xbe\x94D\x88\xb6[K\xd7i\xbc\xfcC\x7f\x9f5\x11t\xcd\t\x9e\xe4\xd7K\xdd\xee\xc6hO\xfa\x13\xb1\x8f\xec\xaf\xa5v\xe8\xad\xb22\x13/dDB\x10\x862N\x95\x9a?\xca\xe5\xfb\xf3P\x8d\x19\x10v\x89\xa2^\x9d,c\xdd\x8cN\x99\xa5;C\x18\xfe\xd9wZ\x1a\xda\xc4\xc4\xc8\xb2"\x10\x84K\xd8g\x8b\x9e:L\xd2U\x05\xf3P\x88\xba4&3R=\x0c{=\xde\xfa3\xa7D]\xec\xfe\xd5w\xa8\xa3\x11\xad\x93"\xc8\xb1\x08[M\xed\xe3\xdd\xe3\x0f\xa0F\x8c\xa8\x8b\xb2h\x92\xa7\xd0\xc7\xd6\xbd\r9\xde\xef\xaa\xfa\x1f\xd5!|\x06\x87\x12\x88\x90dX\x98\x9e\xd3{k\xbc\xfcE~|\xe4!\x11f\x8c\xc6jGv\xc7\xb3\xeb\xd1\x9d22\xd9\xf4\xbe\xdd\xfd\x05\xf4!|\x16\x86Y\x06)\x11\x90\x98\x9f\xa9!\xb2\x0f=IO\xe7\xa1\x08\xd2\x951;D\xd1\xa9\xe8_C\xeabf\x94\xedl\xfa_\xd2\xdfil\xbe\x03%\xb4P\x84\xc8\xcc\x8b\xf4\x19\xad, \xd9\xa0\xaa\x1f@\x84E\x9aR\xb1\x9a\x91\x1f\xa7m\x9aR\xa6E\xec\xfe\x96\xfb\xcbe\xf0\x18\xc4\x84\x8a\xda>\xe7\xf5\xb7\x8d\x95i\xd7\xe9\x18\xd2\xa3\x12\x8cLLLLLLLLLL~\x1a\x10\x8d9Qv\x89\xa3V;_e\x89\xd34\xa7\xb3\xfb\x0b\xd9\x0be\xf0\xa8[\xc4\xfe\x86k\x7f\xc9\xaf\x08\x18\x98\x18\x98\x18\x18\x18\x18\x18\x98\x18\x18\x98\x18|4!\x11f\x9c\xad\x0c\xd4\x88\xd5>\xb7\xbb\xdbJ~\xa4^\xef\xec\x17\xc2l\xb1\x0b\xa2\x1e\xe3\x19\xe1\xd7/\x88\x9c\xbf\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c>"\x11\x13NT{\x93F\xac\x7f\xbe\xb7\xd0\xc5\xe8i\xcf\xd0\xbd\x9f\xd9.\xed\xedcb"\x85\xd1\r\xa4\xe9Y\xfe2\x17\x0c\xdf\xf6```q\x9cf\x06\x06\x06\x06\x06\x06\x06\x06\x1f\x11\x08DY\xa7"F\xa2$\xa9\xf5=\xd8\xf6\xd2\x95\x11{?\xb5]\x8b,\xb2\xc6\xcfr(]1\xf6\xdb\xc4\xca\xb4\xd9\xe0\xf4\xf0\xd2K\xaa\x8cLJ(\xa2\x8a+\xe2\xa1\t\x90{MY\xab\x1e\xee\x9c\xad\t\xfd-\xf7\x97f\xfa,l\xf7"\xba\xff\x00\xa1\x9a\xab=H@\xd3\xf6\xfaD",\x83\x194IS\xe9}zr\xa6E\xfd\x9a\xeeX\xd8\xbdE\x11u-\x99\xa0\xb3\xf1W\xf8/n\xab,\xbe\xcd\x15\xde\xae\x84!2\x0fi#V=\xdd9\x0bg\xd0\xfe\x8a\xc6\xcb/\xa9v\xac\xb2\xcb\xb1!.\xb4D\xbd\xbf\xc7G\'-O\xdf\x81E\x14QE\x14bbbbbbbQ[\xd1[\xa1\x11bc&\x89\xaa}\xc8:d^\xcf\xe7Y}\x86\xcb\x13\x17J\xec^\xd6Y\xee$%\xd9\x8e\xda\x8e\xa0\xd9\xe0c\x8e\x9a\xf8TQ\x81\xc6````````bQBB\x88\xe2QB\x10\x842F\xa2\xee\xe9\xc8_K{2L\xc8\x8b#\xd2\xbbi\tv\xff\x00\xad\xb5\x7f\x95G\xf4\xd2U\x1f\x83e\x89\x91B\x85\x8fH\xe30\x16\x99\xc4q\x9cc\x80\xd0\xd1\x89\x18\x91\x88\xe08\x0e&%\x08C$I\r\r\x14QEmE\x14QD}\x08\xb2\xfeU\xf7\x19\xa8\xcc\x8d6G\xa5v\xd7b\xf7[6Ag\xac\xbf\xe8^\xdf\x02\xcb2#"3!1z\x8fh\xa2\x8a0F\xa4R$\x8c\x05\xa6b\x91\xeb\xb3c\xe8\xb1\x8ccC\x1e\xd5\xb5m[Q[&&_z\xcb/k,\xbf\x83#Pl\xd1#\xdaE\xee\xbb\xb1\xd9\x9e\re7/\x83\x91fB\x91\x19\x9as!?B\xefh\x96drP\xe5f(kor\x86\xc9\xfb\x19\x99\x89\x96^\xec\x96\xd4bbbbbb``bbc\xdell\xc8\xb2\xf7\xb2\xcb,\xb2\xcb,\xbe\xd4\x8dA\xb3E\x90\xee\xd0\xba\x17f;7^\xa7\x82\x8dF\xfe,Y\xa6\xc82"El\xc6"\xc6\xc9L\x84\xae#e\x93\x97\xf1e6$\xc8\xf4\xb1\xa2\x8a+z\xde\x8a\xd9\xf7\x98\xfbVY\x91\x90\xa4\'\xda\x91\xa8H\xd1!\xd0\xba\xd2\xec.\xc2\xf6\xda^\xc6\x8a\xc6;\xd9h\xb2\xd1e\x96Ye\x96Ye\xf4\xa2\x0c\x84\x88\xc8\x8b,}2cL\xd2x\xfa3\x13\x12~\xbe\x88\xe30\x14J(\xad\x98\xca(\xa2\x8a(\xa2\xb7\xae\xab,o{\xe9e\x15\xd5]HB\xec\xb3Q\x0e&\x9a!\xd0\xbah\xaf\x80\xb6bW(\xa3*\x95\x1edZ\xf8;C\xf1M\xb2Z\xd9\x1ekP\xa2\x8cLE\x11D\xad\xec\xc8\xc8R\x16\xa5\x1c\xa7!\x91}4QE\x15\xbb\xef1\xfcZ\xda\x8a(\xa1.\x9b\xeahq1\xdd|\x88\xec\xbd\xc8\x98\xfa\x1a\xbacC\x8f{\x12\xb6B\xd9\xf5\xde\xcbtQ[\xd9e\x96Ye\xf7\x99E\x14Q\x89\x89E\x14QEv\xdf]\x96YfFFE\x97\xd5\x7f!{m\x1fcM\x7fd\x99\xeeOJ\xfd\x87\x06\x86\x87\x1e\xe5\x14QB,\xb2\xf6\xa2\xbaR\x12\x12\x16\xf6Y\x91\x91\x91\x99\x99\x99\x99\x91\x91\x91e\x97\xb5\x96Ye\x96^\xf8\x98\x98\x94bQ\x89\x89\x89F%\x15\xf1\xac\xb2\xcb\xe9_\x1a\xba!\xe8\x8dI\x11\xf6,\xc1H\x9e\x81(48\x8e%v\xa8\xa2\x8a\xec\xd1BB\xda\xcb,\xb1\xb1\xbf\x8c\x84\xcb222,\xb3"\xcb,\xbe\x8b/k\xe9\xa2\x8a(\xa2\x8a\xeb\xae\x8a+\xe5e\xeas\x1a\x8f1jR\xa1M\xd9\x1dAM1\xe9\xc6d\xfc7\xe1-&\x87\x11\xc4q+\xab\x88ZG\x10\xf4\x8e3\x8c\xe38\xcc\x0c\x0cLG\xe8ddY\x91\x91e\xf4\xd1E\x14QE\x14Q]\x17\xd8\xb3####"\xcb,\xb2\xcb,\xb2\xfb\xd7\xf1\xaf\xb4\xba\xef\xd4h\xb3#"\xd6\xde\xa8Z\x8d\x0b\\z\xb1\x90\xd4X\xf4\xc7\xa6=3\x13\x12\x8a(be\xefE\x18\x98\x98\n\x08zd\xe28\x94$QE\x14Q\x89\x89\x89\x8b111000010$\x89z\x16^\xd6Y{^\xf6^\xf6^\xf6_\xc2}U\xdb\xaf\x8d\x17\xee\xc6\xfd:rb\xd4\x14\x93)3\x13\xf9#6\x8c\xcfFb=1\xe9\x1cL\xe3c\xde\x8a\xde\xcb,R2$\xaczg\x11\xc6``\x8c\x11\x8a1F(\xc5\x14QE\x15\xd7"^\xfb\xd9e\xf4_\xc2\xbe\xc5w\xef\xa9\x16-\xab\xb1}\x95\xff\x00\xc9>\xc2\x93\x16\xa1\x99\xe8\xccQ\x89\xea^\xd4\x8a\x12$\x85\xf2/\xb1"kw\xf4U\xd5],\xae\xe2\x19]\xd9?M\xa5\xea\xfby\nb\xd43-t\xb7\xb2\xe9\xaf\x94\xd14=\x9fi\xfc\x1b\xde\xc7\xbd\xf5z\x96\x8a\xe8\xb6/Oq\xed{^\xde\x83\x13,\xb3\xdc\xb1\xb2\xcfN\xbb2\xa1H_\xcat9Qw\xdf\xc9\x99\x99\x99\x8cktQE|k\xea\xd5^\x9fAe~l\xa8l\xbd\x94\x8a\xbfa\xba,\xbd\x95\x8a\x9f\xbf]\xed\xeaz\x9e\xbd>\x85\x9e\xbf\x85\xbf\xc2\xd9l\xb6[~\xc5HQ\x11h~\xbbB\xa2O\xdfk/\xa6\xbb\x99\x17\xb5\x8aB\x91VP\x90\xd7f\xcb,\xbd\xef\xa5\xa3\xd4\xb3"\xf6\x9a\xfe#\xfa\x17\xea\xb6^\x9b\xd3\x12g\xa9L\xa2\x8a(\xa2\xba\xad\xb2\x99O\xb1\x7f\n\xc4\xcb\xde\xbbOf{\xec\x90\x93[{vo\xaa\xbb>\x85l\xfd\x8dOI|ze\x14QE\x14Q_\x1a\xbe}\x96^\xd4bQ]2[4Q\x88\x93D}v\xa3\xd4\xf5\xec\xd9\x95\x19\'\xdd\xb2\xcduR\xed\xd9e\x97\xdc\xbd\xf1\x7f\x862\xfc1\x97\xe1\x84\xbf\x0c\'\xf8q\xcf\xf0\xe3\x99\xc7#\x8aG\x14\x8e\x17\xfap\xbf\xd3\x87\xfe\xce\x1f\xfb8W\xe9\xc2\xbfN(\x9cP0\x81\x86\x99\x8e\x98\xde\x9a\xf6C\xaf\xcf\xa4\xb3#"\xcb\xe9\xc8^\xa3\xde\xd8\xa5B\x97M\x97\xd3{\xfb\x16\xcc\xcbO\xb5E\x1a\x91\xb3\x8d\x0fLzr\xfe\x8e-C\x87P\xe0\xd4<\xbc\xcf-3\xcbH\xf2\xcf\xf4\xf2\xdf\xf6yo\xfb<\xb2\xfd<\xbc\x7fN\x08~\x9c\x1a\x7f\xa7\x16\x91\xc7\xa4c\xa2\x7f\xc2^\x89\x96\x91\x9e\x99\xc9\x03\x96\'29\x91\xcc\x8ec\x98\xe6g+9Y\xc9#9\x99L\xb9\x9f\xcc\xff\x00\x90\xadC\rC\t\x9cs8\xa4qH\xe2\x7f\xa3I\x7fe\xfe\x1f\xfe\xf7o\xe7Ye\x99\x1cw\xecb\xd0\xa4\xd1\x95\x98\xa3\xdbj\xafT_U\xf6uu\xff\x00\xa8\x1c\xf3\xfd<\xc6\xa7\xe9\xe65\x7fO1\xa9\xfasj~\x9c\xb3\xfd<2\x9e\xa3\xb6\xfd:\xa4\xachhhi\x8dHkP\xff\x00\x90\xff\x00\x90\xadC\x1dS\rS\x8bT\xe1\xd485\x0f/\xa8yy\x9e^g\x96\x99\xe5\xa4yfyg\xfayc\x81\x1c18bqD\xe2\x89\xc5\x1f\xc3\x8e?\x87\x1a\xfc0_\x86%v=\x0fB\xd0\xf5"K[\xf0ro\xbd{V\xd6_\xd0\xca\x051\x1e\xe5l\xd0\xb6{\xbd\xaf\xa6O\xd2\xcdMW/E\xd7\xa7\xa7\xc8\xc8\xa8\xc5R\xeb\xa3\x14`\x8c\x11\x821E#\xd0\xfe%#\xd0\xb4\\X\xe8\xc9\x19D\xc9\x19D\xc9\x19#$d\x8c\x91\x94KFE\x99\x19\x19\x19\x19\x19\x16^\xde\xbbY\x91\xc8f\xcb\xef(\xb6bW\xd2\xd1\xea{\x8e%\tv_T\xa5J\xcdMW>\xb8\xc77Db\xa2\xa8\xb6\xbd\x85\xab\xfaf\x8c\xd1h\xb2\xcb/k-\x99\x17\xd1l\xc8\xc8\xccr,\xb3$Z2-\x19#$d\x8c\xa2d\x8c\xd1\x9cL\xe2f\x8c\xd1\x9a\xda\xcc\xd1\xc8r1\xc9\xbe\xfe-\x8bM\x8bI\x18\xa49\x17\xf1W\xc1em\xef\xd0\xfa\x1fC\xda\xf6\x9c\x94MMG>\xc6\x9b\x84Q\x9cL\x91i\x9a\x92J>\x86l\xe4g,\x8eY\x1c\xd29\xa6sH\xe6\x91\xcd#\x96G,\x8eI\x1c\x923g#3fl\xc9\x993&Yl\xb2\xcb/\xb7}\xdb/d\x9b\x16\x9b\x16\x9a\x14R\xe8\x91\xeb\xf3k\xbc\xfb/v\x8b\'\xaa\x97\xb1)\xb9vl\xb3#&_\xd6\xd9{Sb\xd3\x93\x16\x8b\x16\x8a0E\x14QE\x14P\xe1g\x18\xe0`W\xce\xa2\x8a\xeb\xbd\xdfi\x9a\x8d\x8c\xa2\x8a(\xaf\x87[QEmE\x14QE\x14Q[Wn\x99L\xc5\x983\x8aG\x97\x91\xe5\x98\xbc)\xe5Q\xe5\xe2q\xc5\x14Wz\x8cG\x03\x8c\xc0\xa7\xf0\x17R\xda\x8a\xea\xae\x9b\xd9\xf6^\xce\x16=$q\x1cG\x11\xc4\x8e\x14=\x13\x80\xe18N\x03\x81\x9c\x0c\xe08Y\xc0\xce\x13\x80\xe08N\x13\x89\x1cG\x128\x91\xc4\x8e$p\xa3\x85\x1c18bqD\xe2\x89\xc5\x13\x8a\'\x1cN8\x9cQ8\x91\xc4\x8e$q#\x89\x1cH\xe2G\n8\x91\xc6\x8c\x11\x820\x89\x8cJG\xa1e\x96Ye\x97\xf0o\xa2\x8cG\x03\x03\x02\xbe\x12b\xe8\xaf\x85}\xca\xf8VY{\xdf\xc3\xae\xfd\x14QE|\x1a1\x1c\x0e3\x03\x12\xbb\xad\x96Y\x90\x9f\xc8}\xe7\xde\xb2\xcb/\xe2\xd1]\xab,\xb2\xf6\xa2\x8a+\xe0\xd1F&\x06\x06\x06%\x15\xb5\x15\xb5\x14QE\x14Q]\x14Q}\xc7\xd6\xfb\xaf\xae\xbb6Ye\x97\xf1\xebj\xee\xd1_\x0e\x8a1\x1c\x0c\x0cLLJ(\xa2\x8a(\xa2\xba\x10\x97\xdaYe\xfc\n(\xaf\x81e\xf4\xd1E\x14W\xc2\xa2\x8a(\xa2\x8cJ(\xa2\x8a(\xad\x92\xee_\xfa+\xed\xdfr\x8a+\xbfE\x14Q\x89E\x14QE}k\xf9\x96_\xd0\xd6\xf4Wr\x8a(\xa2\x8a1(\xc4\xc4\xc4\xc4\xaf\xba\xb2\xfe\x82\xbe\x86\x8a\xde\x8a(\xa2\xbe\xe2\xbemmE\x15\xd8\xaf\xa1\xaf\xf4\xcb/\xe2\xd7\xfeS\x7f\xff\x00B\xaf\xf6;/\xff\x00\\_\xeaW\xfe\xa5\x7f\xf8\xbd\xff\x00\xe4\x9f\xff\xc4\x000\x11\x00\x02\x02\x01\x03\x03\x03\x04\x02\x01\x05\x01\x01\x01\x00\x00\x00\x01\x02\x11\x12\x03\x10\x13 !01@Q\x04\x14"PAa`\x05#2Rqp\x81B\xff\xda\x00\x08\x01\x02\x01\x01?\x01\xd3\xf45_boh\x08c\xddn\xc7\xd0\x84\xb6\x89\x05\xbb\xdd\xec\xc8\x9a~\xa4\x11\xa6\x88\x08\x99\xaeL{\xe9\x91\x10\x85\xfb;\x1c\xc9j\x0ec}Ni\x13\xfa\x8f\xe1\x15=OR:q\x8f\xa6\xeb{\xe9\xd4\xee\xd2\x11\x05l\xfa\xbf\xc7\r%\xfc!\xf5G\xb25Y-\xa2\xb6}\x0bv>\x85\xba"1\x8fv=\xa1\xeai.\xe4\r4Ci\x9a\xe4\xc7\xbe\x99\x01\x08^\xf2\xbd\xb3\x90\xe49\x0e]NtK[\xf8F2\x97\xfc\x85\x14\xbd\n\xf1\xb6z\xcfo\xa2\xd3\xe4\xd7\x8a>\xaaYj\xc8c\xe9p\xa4j\xc4p\x16\x99\x1d#\x8czg\x19\x81\x80\xb4\xcc\x0c\x07\x01\xc4\xc4\xa2\x85\xd8E\t\x11]\x861\xee\xfdG\xb4\x11\xa3\xeaG\xb1\x02;L\xd7$=\xf4\x91\x1d\x90\xbfb\xc91\xcb\xa9\xc8z\x97\xe8`\xe5\xff\x00!E/M\xab\xcb\xa7\xdf\xbe\xdf\xe9\x91\xa7-G\xfc!\xf7\xee1\xf4\xcfO\xb1\xa9\xa4p\x11\xd0\x16\x89\xc4=\x13\x84\xe18N\x11\xe8\x9c#\xd1\x1e\x89\xc2=\x13\x88\xe2\x16\x91\x1d!i\x0bO\xb0\xf4\xc7\xa6=3\x03\x02q\xa2\x9b\x14\x08@\xd1\x87r0!\x021(\x99\xacM\ro\xa6Gd/\xd8\xc8\x93\x1fC\x95\x16\xe5\xff\x00\x13\x8b\xfe\xc5W\xa1]5\xe1d\xdd"\n\x92[}:\xe3\xfa6\xfev{>\x89\xc8\x96\xc9\xa3$d\x8c\xcc\x91\x922FHrE\xa1\xb8\x9f\x88\x94Lba\x13\x04(!i\xa1i\xa3\x01\xe9\x8fLzg\x11\xc4OH\xe0#\xa2CD\xd2\xd2\xee-21\x14J&\x8dX\x13\x89(\x94(\xf74\xf4\xca\x18\x85\xfb\x19\x12\xdd\xba?)\xfa\x0bG\xfe\xc5|x\xeb\xa5\x8c\x97v\x90\x84k~\x1aZzc\x1e\xcf\xa1\xc8\xf5(\xa2\x8a(\xa1\xc4\xc4\xc4\xa2\x8cL\x0c\x0c\x0cLLJ\x11\x91\x91}\r2\x98\xbb\x11f\x9c\xa8Z\x88Z\x88Z\xa8\xe4C\x92\'L\x9cQ=3\x88\x86\x99\x08!\xa2B\x17\xbc\xafh\xc9\r\x0f\xb1\xf9K\xd0\x8e\x92^\xa5\x15\xb3\x1f\x86\xb6\xad\xd8\xc8\xfeS\x11\xf4\xba|\x9a\xaa\'\xd5K-V1\x8c}\x16_U\x14QE\x14QE\x15\xb5uY{_NB\x91\x98\xb5\x0eC\x90\xc8od\x84&JC\x10\xbf`\xc7\xb7\xab\xeac\xf1\xad\x98\xc93Ez\xb1\x1f\xe9\x90\xbd\\\xbe\tK&\xe5\xbb\xd9\xefF&&&&&&&&&&\x06\x06&\x06\x06\x06\x06\x06\x06\x06\x06\x06\x06\x06\x06\x06\x06\x06\x06\x06\x06&%\x14WM\x96d_\xec\xd8\xf6\x8f\xce\xd5\xbb\x1f\x92\x86H\x90\xfbw4\x95Em\xf4K\x0f\xa6\x94\xfev{=\x9f\x8a\x8a(\xa2\x8a(\xa3\x13\x13\x13\x13\x13\x13\x03\x03\x03\x03\x03\x03\x03\x13\x12\x8a(\xa2\x8a(\xa2\x8a(\xa2\x8a+\xf6r\xd9\xfa\tv\xdd\x8cc\xf1\xad\x98\xc9\x12\xf8\x16\xcda\xf4\xd0\x87\xcfK\x18\xfd\xdd\xa3"\xca(\xa2\xb7\xa2\x8a(\xa2\x8a(\xa2\x8a+\xf6Oi\x14$P\xc6?3$H\x8a\xb9\xa1\x1aP\xcej\'\xd5?\xce\xbe:_\xba\xc8\xccz\x83\x99\x90\xa4\'\xfe\x04\xc7\xb2\xee\xca\xdd\xec\xfa\xab\xc0\xc9\x0c\xd1V\xdb\x11\xfe\x9b\x0c\xb5\xd3\xf85%\x94\xdc\xba\xdf\xb4\xb3!\xc8\xc8\xc8r\x1c\xcc\x8c\x85",O\xfc\x05\xef\x15\xbb\x18\xc7\xe5c$3E~;\x7f\xa7\xc7\r9\xea\xf8\x1f\xb0\xb3###"\xc7!\xcc\xcbe\x1b!\xa2\xd9-\x17\x1fS\xd1\x91d_\xf8\x0b\xdd.\x861\x8f\xc8\xc62~\x84U-\xa0\xb8\xfe\x92+\xe7\xa9\x8f\xcfc\x91\x91\x91\x91\x90\xe6dw1\xb2:M\x8bDPH\x898\xe4\x8dH\xd1\x06&\'\xfe\x00\xf7\xa2\x8a\xd9\x8fj\xf22C=d\x90\x88\xabt}O\xe3\x8e\x9f\xc7\xb6ll\xb3#-\xa8\x8e\x9bdt~H\xc1.\x88\x88\xd7\xd3=\x18\x98\x9f\xea\xeb\xda1\x88K\xbe\xecc\x1f]u1\x8cdU\xccG\xd1C=h\x9a\xf2\xcbQ\xbfj\xc61\xb3\x11i1i\x8a"\xeaDMH\xe5\x13R\x14&&/\xd8W\x99\xec\x88\xad\xd8\xc6=\xeb\xc4\xc61\x9a^\x97\xb7\xfatqr\xd4\x7f\xc2\xf6\xcc\x90\xc5\xeaB\x84WB\xe8B\x11\xf5\x1acT\xc4\'\xfb*\xf2=\x91\x1d\xd8\xc7\xe3\xad\xd9!\x93\xec\x88*B4V\x1fI\x7f>\x17\xe5d\x90\xd0\xd1\xa7!t\xae\x94"Q\xc9\x1a\xb0"&/\xde\xbd\x90\xb7c\x1fE\x14W[\x18\xc6K\xbd!\x08\xd6\xfc4\xe1\x0f\x0b\xddx\xe42DY\x16_B\xeaLG\xd4C\xf9$\xa9\x88_\xbd{%\xbb\x18\xc7\xe2\xad\xd8\xc6H]\xe7\xb6\x843\xd4\x8cO\xa9\x96Z\xaf\xdb\xb1\x92\xda\x0c\xbe\x95\xb2\xe8D\xd6H\xd5\x80\x84\xcb\xfd\xdb\xd9\tn\xc6?#\xd9\x8cd\xbb\x9a}\xed\xed\xf4\x11\xff\x00s\'\xfc\r\xdb\xbfp\xc612,O\xa9t!\x1a\xf0\xfeI\xaa{\'\xfb\xb7\xb4Q\x18\xf61\xd9\x8cem]\x15\xd1\xfc\x8cc\xd9\xa2N\x8d5HH\xfa\x7f\xc3Bs\xf9\xf6\xeff2HDX\x85\xd0\xb6B\xd9m(\xda5`U\x08_\xba{@\x8c{\x0e\x03\x88\xe21\xef[V\xef\xa1\xec\xf6o\xb9>\xfd\x84"k\r\x08G\xdc=\x98\xc6&A\x8b\xa5\x08[-\xf5\xa1\xfc\x9a\x91\xa1\x0b\xf7Om4.\xc2v4J$\xa24W\x89\x8ff2G\xff\x00\xd2\xdbN94\x8f\xaa\x7f\x9d|{\x861\x8d\rm\x16.\xa4!n\x86\xad\x1a\xd0\x1a\xa1~\xed\x1aK{=I@\x94\x07\x12\x8a\xe9{\xb1\xee\xc9"?\xf2{}\x1coU\x1a\x8f)7\xeeX\xc642,\x83\xeaB\x17N\xac\x7f\x93R;\'\xfb\xa4izt\xde\xce#\x88\xe2bWK\x1e\xcff>\xc4=6\xfao\xc23\x9f\x85\xfb\x16\x861\x92[A\x8b\xbfB\x10\xb6[\xa1\xab5`IV\xcb\xf6\xefh\x90\xf4\xe9{Y\xea`8\x0e\x03\x89[\xb1\xec\xf6d\xfd\x04D\xff\x00\x87\xd3\xa5\xf3\xee\xd8\xc642\x0c\x83\xde\x85\xb2\xd9t\xea\xc7\xf95a\xfb\x97\xb4P\xba\x99G\xa0\xa4]\x8e#\x81(\x8d\x14H{\xb1\x93\xfe\x16\xd1>\xa7\xb60\xf8\xf7l{4I\x08\x83#\xdf\xa5u!\xf75`N5\xb2\xfd\xc4\x17\x7f\x1at)\x96\x99\x85\x92\x80\xe0N\x1d\xc6\xb7\x90\xc9\x7f\xcbm\x08\xe58\xa3ZYM\xbfx\xf6d\x86E\x90}+\xc1\xa8\x8dX\xfe\xe1\x88\x87]\x97\xd3tFG\xa8\xe2N\x04\xa2I\x14Hb\xee\xf6\xfa_\xc6N_\x1e\xf9\x8cd\x90\x882.\xfc\x8c\xd4\x89\xa9\x1a\xfd\xc2"\xbby\xd4\x85+\x1cl\x96\x99-1\xc0\x9cI\xf6L\x8a"\xbb\x90\xfct\x1b\xf9\xf7\xccc$\x86A\x9ao\xcb\xa8\x8dX\x8dW\xed\xe2/\x15\xf5"#C\x8d\x92\xd3\'\x03]R\xa1\x115\x7f\x18F\x1e\xfd\x8cd\x91\x16A\x90w\xd2\xba\xe8\xd4\x89\xa9\x1f\xdb\xc5{\x14"R2.\xc9E3\xea\xbf\xe5[i\xc7*_&\xbb\xb9\xfb\xf61\x8fh3NB\xf2MY\xab\x11\xaa\xfd\xb4\x17\xb1E\x8d\x96FD\xb5*&\xa7ym\xa0\xbf?\xfc%+v^\xd6^\xf6Ye\x96Y~\xc9\x8ff2,\x834\xdf\x91\x93\x89\xa9\x1f\xda\xa2+\xb7\x91\x0c\xae\x86=\xe7\xdc\x97y\x11V\xc8\xba\x84\xa5\xbd\x96Ye\x96Ye\x96Y~\xcd\x8cc\xda\x0c\x84\x84\xef\xc94j\xc0\x92\xa7\xfb\'\xb4P\x97\x96\x8a\xdd\x8c{\xea:[@\xd5\xfct\xa2\x8b,\xb2\xcb/k,\xb2\xcb,\xbfh\xc61\x91d\x19\xa7/#\'\x13V?\xb2b \xbc\x94QE\x141\x8f\xa3]\xfe;Ev>\xa9\xd3\xaf\x82\xcc\x8c\x8c\x8c\x8c\x8c\x8b,\xc8\xc8\xc8\xc8\xc8\xa2\xb6\xaf`\xf6c\xda\x0c\x83\x13\xbf$\xd1\xa9\x12Q\xaf\xd9"\x0b\xc9E\x15\xb3\x1b\xea\xd7}\xebh.\xe9\x1fQ+}\x16Ye\x96^\xd6Y~\xd9\x8cc"\xc83N^Y\xc4\xd5\x88\xff\x00b\x91\x04V\xf5\xd5Et\xb61\x8fvj;\x90\x8fL\x99\xa9\xeb\xfa7\xb3\x1e\xd0d\x19\x17k\xc9%d\xe2N5\xfa\xf7\xb4\x10\x97\x82\x8a+\xa5\xbd\x98\xfa%\xe83Mw\'\xdbL~\xbe\xc2\xcb,\xb2\xcb,\xb2\xcb,\xbd\xdb\xde\xcb\xd9\xec\xc6A\x90d\x1f\x96H\xd5\x88\xff\x00QE\x15\xd2\x88/\rm^MWQ\xda>\x8d\x9a\xfd\x95y\xe8\xb3#########333#"\xcb\xf01\x10dH\xbb\xf2I\x13\x8d\x93\x8d~\xa1"\x86\x87\xd0\x8d5\xed\xf5\xde\xc9vH\xd7w\xbdy\x9e\xd6ddddddddddY{Ye\x97\xb3\x1e\xf0 A\xf9d\x89\xc0\x94}\xb5\xfb\x04E\x18\x8e$\xba"EuWS\xf1\xea\xf7\x96\xcf\xb35\x1f\x7fb\xd6\xcc{Y{Ye\x97\xba,o\xa1n\xc4\x88"$D\xc4\xcb/\xaa\xf7cD\xa0=3\x1e\x9a(\xa2\xb6\xa2\xbd\x95\x18\xf5D\x81\x894K\xa2\x02\xf6\xcc\x97wd\x15\xb1\xbfR^\xbe\xc1D\xc4\xc0\x94\x07\x11\xaf\x15\x96$\xd9\x1d#\x84\xe18\xda1(\xa1""\xd9l\xb6\xb2\xcb\xf044J#\x89\x89\x89\x89\x81\x81\x81\x81\x81\xc6q\x8e\x03\x89E\x14QE\x15\xe1E\x0f\xa6&\x98\x91\xa8Kti\xafo\xa8\xea;i\xff\x00,\x9fh\xfb\x15\xa6q\x9cd\xb4\xc9\xe9\x93\x8fUoF"\x81\xa5\xa4GHZC\xd2\xfeG\xa3d\xb4\th\xd0\xe3[&E\x88[Y\x91e\x96Ye\x96Ye\xee\xd1F"\x80\xa0bb\x8cJ(\xa4R$\x90\xd1E\x14QE\x14QE\x14Q]\x167\xd3\x13LF\xa1=\xd1\xa6\xbd\xbe\xbb\xed[z@\xd5\xf4\xf64V\xd4j#Q\x0f\xad!DQ#\x03F\x16\x85\x1a\x12%X\x99!\xb4j\xb2Ox\x89\x89\x96Ye\x97\xbd\x96YfFFfh\xcd\x19\x16)\x19\xa34r\x1c\x88\xe59Q\xccs\x0ev_]\x14QE\x14?\x12 #P\x98\xf6D=\xbe\xb7\xae\xd3\xedH\xd5\xf5\xda\x8a\x1a(\xad\xa8\xa2\x8a\xda\x8a(\xc7{,\xd45\x11%\xd1e\xec\x84E\t\x9az\xaa\x0c\xe6\x88\xf5\xd2%\xf5\x191\xeb\x0fY\x92\x9d\x8ft&Y\x91\x91\x99\x99\x99\x99\xc8r\x1c\x87!\xcar\x9c\xa7!\x99\x9b3fl\xe4fl\xc9\x992\xf6\xbf\r\xf8\x1f\x89\x10b\x916Lb\xd9H\xccS/\xdaM\xdc\x88\xabtj;\x93%\xdd\xedE\x14`bb``q\x98\x1cg\x19\xc6q\x18\r\x96ddND\xd8\xd1F&%o\x91\x19\x9c\x87)\xcar\x8fT\xe49\x0c\xcc\x8c\x8b\x16\xd9\xd0\xf5Nc\x98\xe69NS\x94\xe59\x0c\xcc\xcc\xd9\x91\x90\x9e\xf4V\xd4Q\x89\x89\x89Eoe\x96_M\x96Yccc{Ye\x96ddd)\x11\x98\xa69\x12{-\xec\xb1L\xe49\x0c\xd1\x91e\xf9g\xd9m\xa4\xbb\xd9\'\xb4ac\xd28\xbb\x1cLZl\x96\x93g\x14\x88\xe8\xb6}\xb4\x85\xa1$?\xa5l\xfbf}\xbb\x92\xa6}\xa0\xbe\x99\xa3\xec\xd0\xe49\x8ec\xd4%\xa89\x17\xd1E\x14Q]\x16Ye\x97\xbeFC\x90\xe4Y~T-\xec\xb2\xcb\xf6w\xb3\xf0\xd9bb\x91\x90\xf6^\x1b2339\x0eC$_\x83]\xfe;^0c,\xd3\x91b\x91\xdb\xa6\xfc\x0ec\x90\xdb\x18\xcb\xd9\x15\xbb\xe8}\x17\xd7F#\x88\xd1E\x14W\x86\xb6L\xb2\xfa,\xb2\xcb,\xb2\xcb/k,\xb2\xcb,\xbe\xba(\xa2\x8cJ\x1cG\x12\xba\x97\x86\x8a\xe9\xb6fr\x1c\x87"2]\x1a\xef\xbdm\xa8\xfb$1!\x11\x99vX\xa4^\xd6Y}y\x19\x17\xb3(\xc4\xa3\xb9e\xf4\xb1\xf5$P\xa0-1i\x1cc\xd3\x1e\x91\xc4q\x9cc\xd3\x1cJ+j(\xa2\xb6\xa2\xb7E\xae\xbb,\xb2\xcb\xf6tQ\x81\xc6`bWDQ\x89\x8fR\xd9\xf8\xb2g#G7\xc97\x93\xbd\xb5=FG|\xa8\x8c\xcb,R2\xf1\xd9}Ok,\xb2\xc6\xcb\xe8\xb113!LZ\x88Z\xa8\xe5C\xd5G)\xc8r\x1c\x86c~\xd2\x8a+|L\x0c\x0cE\x03\x03\x03\x12\x8cLLJ(\xc0\xc0\xc0\xc4\xc4\xc4\xc0\xc4\xa2\x8c\x07\xa68\x14\xf6]\x84\xf6\xa3\x13\x12\xbc\x95\xd4\x8a\xbe\xe7\x19\x08\xe2q\xf70\x1e\x98\xa3[Ye\x8aB\x91\x91e\x97\xbefffffffFFE\x97\xb5\x14QEuYe\x96Ye\xefe\x88\xa2\x8cLJ(\xa2\x8a(\xa2\x8a1(\xa2\x8a(\xad\xebdQ\x89[QE\x15\xd1EtV\xf7\xb5\x15\xd1\x8a8\xc7\xa6`\xcah\xb3"\xce\xcc\xc0p1\xda\xbc\xb5PlL\xa2\xbah\xa2\xb7\xb3##!H\xc8\xbe\xab,\xb2\xcb,\xb12\xcb/\xc7e\x96Ye\x96Y\x0e\xe4t\xce3\x03\x13\x12\x8a\xf1WEt\xa6)oe\x99\x1e\xa5\x1e\x85\x97\xb5\x15\xb5\xedF\'\xa1e\xedEl\x9e\xd48\xa1\xe9\x8fH\xc5\xa2\xda\x14\xc52\xd31Lzc\x8b+\xc7\xa9\xdbI/\x91.\xe5tQ[Ygb\x8a+|\x8c\xcc\xba/\xc3fFE\x96Ye\x97\xe6\xd3#\xbb\xf6\xf6)t\xd8\x99\xea8\xef\x96\xd5c[de\xb6&,\xf422;\x18\x95\xd1CC\xd3LzHzm\x1d\xd0\xb5\x18\xb5E4\xcaLzc\xd3h\xaf\x07\xd4\xfa\xa8\x90\xf0QEme\x9d\xb7\xa2\xb7\x7f\xa0\x81\x1d\xe8\xc4k\xdb\xa6E\xa7\xea=?\x83\x12\x8a,R)Hq\xde\xc5"\x930+e!I\x1f\x8b\x1c\n;\x89\xb12\xd7U\x0cq\xb1\xe8\xa2Z-z\x0e2\x88\xa4\xd0\xb5X\xb5\xbeL\xe0\xc6\xa0J\x91{=\xb4\x95\xcd\x1a\xd2\xb96G\xb1~*1+{,\xbf\xd1\xc5\x9al{&1\xfb\x98j4\'\x19\x8e\rm\x138\x8e}\xbb\x11\xd4g\xe3!\xc7\x12\xcb2\xa1M\x97\x19z\x8e\x1f\x05\x7fG\xff\x00\x87r\xe4d\xbf\x91\xe2&\x84\xff\x00\xa1\x7f\xe1\xdc\xa3?\xe0\xbd\xad|\x99G\xe4\xce\x0b\xf9\x16\xacNX\x8fZ\x06\xa4\xa3/DW\\\x13\x93\xa2Z2D\x7f\xdb\x83\x9b)\xc8\xa1m\xdc\xb2\xfcTQ_\xa4\xb3J{\xa6_\xbb\xb2\x1a\xdf\xc30R\xee\x8e\xd1\x16(N/\xd4\xbd$=M:\xecC^\xbdO\xc6~\x87\x1c\x8e)\x1c\x7f\xd9\x84\x7f\xece\x18.\xd2\x16\xb4d\xbf#=#\x97K\xe0\xe6\xd3\xff\x00\xa9\xcf\x1f\xfa\x9fq\xfd\x1fs/\x83\xee&}\xc4\xfeNi\xfc\x9c\x93\xf9;\x9f\x91\xdc\xc2G\x18\xd3\x12l\xc7\xfb+\xfb(qE\x11\xd2RWg\x0c\x7f\xecN\x11J\xd3\xdfNj/\xb9\xf7\x105\xf59;#M|\x8e#\x81Mu_\x8e\xba+\xcd^\xc7O\xd7\xf41\x93\x8b\xb4}\xc7\xf4}\xc7\xf4}\xcc\xbe\x06\xed\xde\xfd\xcf\xcd\x98\xc8\xc2G\x1c\x8e6q\xff\x00g\x1f\xf6q\xa3\x8d\x18D\xc6&+\xe0\xa4GJ\xd5\x9c_\xd9(\xe9\xc7\xd5\x9d\x9f\xfcP\xa2\xca\xdd\xc6\xc5\xa6`\x8aCHi\x14\x8cbR)\x14\xbc4bQ[\xdf\xb1^\xea\x8a!\xea/oL\xc6_\x07\x1c\xbe\x0e)\x1c28Y\xc2\xfeN\x1f\xec\xe1G\x14N8\x9cq\xf81\x8f\xc1K\xe3\xd8\xdc\xbf\x86S~\xacPK\xad\xb3#"\xfct>\xaa1\x1cJ\xda\xcb/\xa2\xcb\xde\xcb/k\xf0\xd1]\x18\xd8\xd3^[\x13"\xef\xae\x8cY\x84\xbe\x0e)\xfc\x1c38$p3\xed\xff\x00\xb3\x81|\x9c\x118bq@\xe3\x87\xc1\x84~\n]6Yh\xc9\x19#$g\x138\x99\xc4\xce&H\xc9\x19#####"\xd9o\xe0\xb9\x1f\x91R1\x91\x91S\x90\xa1\xe2c\xec_\x9a\xbc\x14Q\x89\x89E\x141\xae\x9a\xe8\xf4.\xf7\xecQEtQ\xff\x00\xa2g\xa8\xe0\x98\xf4\xdf\xf0Ux\xd1\xa2\xfb\x18\xdf\xa1\x84\xc8\xaf\x91a\xf0g\xa6r\xc5\x1c\xc8\xe7G9\xcf\xfd\x1c\xff\x00\xd1\xce\xfe\x0ei|\x1c\xb2\xf89&g\xa8e\xaa^\xa9\xfe\xe9\x8e\xb1\x86\xa9\xc5\xaap\xea\x1c\x13>\xdeG\xdb\xc8\xfbv}\xbf\xf6}\xb9\xf6\xeb\xe4\xfbtp#\x86\'\x0cN(\x1cq0\x82*\x07\xfbg\xfbfZfzg.\x99\xcd\x03\x9e\x074O\xb8\x89\xcb9z#\x16\xff\x00\xe4\xc4\x92\xf2\xb2\xb7\xb2\xfc\x94Q^\n(\xa3\x13\x13\x02\xfc\x9e\x87f`bb\x7f\xe9T$?^\xc2\xf5\x1a!\xa5\xff\x00c\x8a\x07\xdb\xe9\xfc\x1fo\xa7\xf0pi\xfc\x1c:\x7f\x07\x16\x9f\xc1\xaf\x84\x15%\xb5\x14V\xdaR\xa12\xc8\xb18\x9f\x81P*\x05@\xfc\x0b\xd32\xd394\xfeN]?\x93\x9bO\xe4\xe7\xd3\xf9>\xe3O\xe4\xfb\x8d?\x93\xeet\xfeO\xb9\xd3>\xea\x07\xdd@\xfb\xa8\x1fv\xbe\x0f\xbc\xfe\x8f\xbc>\xf1\x9fw#\xee\xe4}\xe4\x8f\xbb\x7f\'\xdd?\x91\xfdL\xbeO\xb8\xfe\xcew\xf2s\xfc\x9c\xe8\xe6\x89\xc8s\x1c\xc71\xccr7\xe8~o\xf8#\xa77\xea-\x15\xfc\x8a)x\xec\xb2\xcc\x8c\x84\xca1+\xa6\xcb\xf2W\xb6B+dP\xd7a+(\xaa\xf4\x1cl\x82\xefB\x8a\xf5\xeb\xd4\xd4\xc1\x12\x9b\x93\xb7\xb7q=\xecZ\x8c\xe69\xcevs\xb3\x98\xe7\x91\xcer\x9c\x8c\xe4\x91\xc9#\x9aC\xd4l\xe4\x91\x9c\x99\x9c\x8c\xe4g#9\x19\xc8\xcaFR.Gs\xb9\xdc\xeeQF%\x14V\xf4\x8a)\xb1i6/\xa7\x17\xd3\xa1i$b\x97\x8a\xcc\x871\xeaQ\xca\'{Q\x89]\x14Q_\xa4]\xcf\xe4\xad\x9a "\x8fM\xbf\x82?\xd9\x18\xa5\xd7)P\xec\xe2\x84\xff\x00\x82\x7fJ\xd7x\x98\xc9z\x94\xca\xf0V\xd5\xbd\x14QE\x14S1f,\xc5\x98\xb3\x06`\xcc%\xf0a#\t|\x1cR\xf88\xa5\xf0-\x19\x9c38&pL\xa1E\x8bJLZ\x12b\xfaah$-4\x8a^<\x87\xa8=S\x96\xc7#!\xfeB\x8e\xd6ddYe\xf4\xd1Ey/\xd8V\xe9\xed\xe9\xb2{1z\x91\xda\x93;\x08K\xad\xba\x1b\x10\xa4\x87\xa9^\x82\xa9\xfa\x8bN\'\x0cN\x18\x9c\x11>\xde\'\xdb\xc4\xfbx\x9fo\x13\xed\xe2}\xbcN\x08\x9c\x10>\xde\'\x04N\x18\x9c18bqD\xe3\x89\xc7\x13\x08\x98D\xc6&(\xc5\x18\xa3\x14R)\x15\xd7\xc7\x13\x04R\xf1\xd9\x92\x1c\xc71\xea\x0eL\xa6\xcc,Zf\x04\x93B\xecd_E\x96dYe\xf5Q_\xa2E\x0b\xb0\x9e\xdf\xf8F5\xe0\xab1F\x08\xc1\x18#\x05\xfa\xcb2FFg \xe6f9\x99\x99\x19\xa33\x90\xe59\x8eaj\xc5\xfa\x99\xc1\x8e1f\rz\x1f\x922/\xa6\xcb,\xb2\xcb,\xbfg}4W\x8d2\x84\xb6]\x8c\x8c\x8c\x8c\x8b,\xb2\xcb,\xb2\xcb,\xb2\xcb,\xb3####$d\x8c\x91\x922FH\xcd\x19\xa334f\x8c\xd1\x9a3FFFFH\xc9\x19#$d\x8c\x8c\x8c\x8c\xd1\x9a9\x11\xc8\x8eTs#\x9d\x1fp\x8f\xb8G\xdc#\x9c\xe79\x99\xcar3336d\xcbe\x96Ye\x96d\xc5\xa9$-vG\\\xcd3\xf1f\'tdY{\xd9e\x96Ye\x96Y~\xc6\xb6^*\xdd13%\xfc\x8ap\xf8.\x05\xc0\xb8\r\xc0\xb8\x8b\x1f\x92\x97\xc9\x8f\xf6c\xfd\x9f\xfe\x95\xfd\x95\xfd\x95\xfd\x95\xfd\x95\xfd\x95\xfd\x8cC,\xb2\xc7&=Fs3\x9d\x9c\xec\xe7g;9\xa4sH\xe5\x91\xcb#\x96G,\x8eY\x1c\xd29\xa4sH\xe6\x91\xcc\xcey\x1c\xf29\xd9\xcd#\x9aG4\x8ei\x1c\xb29$g#&[\xde\xbd\xbd\xb1M\xa1j\x8bT\xcd3\xb6\xf6Y}6Ye\x96_U{\x05\xe1L\xbb\xd92\xfa,R/f\'\xb2{\xa6K\xaa\x87\x14\xc7\xa68\x15\xedo\xd9QEy\xf2\xa1j\x0bP\xcc\xc9\x17\xb5\x96Ye\xede\x96^\xf7\xb4a\x90\xf4\xd9\x81\x88\xd7M\xef\xe9\xe7\xb1?\x02e\xefe\x89\x8c\xb2\xfc\x08hq\x1c\x0cJ\xf7\xb4bbbbQF&%\r{\x1b,\xcc\xcc\xcc\xb2\xf6\xb2\xcb,\xb2\xcb,\xb13Nx\x9c\x86H\xa8\xb2qC\xde\xcb\xf0=\x97\x85\t\xf8o\xa12\xcb/k,L\xb2\xcb,\xbd\xe8q1(\xa2\xbd\x95\x15\xbd\x15\xbd\x96Ye\x99\x19\x19\x19\x16_\xb5\xb3!H\xc8\xb2\xcb,\xb2\xcb,\xb1H\xc8\xc8\xccs\x1b\xde\xcb\xf1\xa1xS/\xa6\xba\xef\xaa\xcb,\xb2\xf7\xbd\xfdLLLLJ\xf3\xad\xebj\xd9\xf4^\xd6Y~\xf6\xcb2/\xa2\xcb2222\x1c\x8b/\xcb[\xaf\n\x17]\xf4\xdf\xb3\xa1\xa1\x8f\xcc\x99e\x96Yc\xfd\x85\x99\x16Ye\xed~:\xf2&_\x82\xcb/\xda^\xcd\r\x15\xfb\n\xf1Ye\x96Ye\xfe\x8d?\xd0P\xe2Q_\xbb\xb2\xcb,\xb2\xcb/\xa6\x8a\xf7W\xb5\xfb+\xe8\xbf\x13CE~\xf6\xcb/\xa2\xcb\xf7\xf7\xec\xaf\xc1}tQE\x15\xfb\xdb,\xbf\xd0\xdf\xe8\xe8\xa2\x8a\xfd\xfd\xff\x00\x82QE\x14W\xff\x00\x03\xa2\x8a(\xaf\xfe\x05E\x14Q_\xe6\xf7\xe4\xa2\x8a(\xaf\xf3\x0b\xf1_\x82\xb6\xa2\x8a\xff\x003\xb2\xcb\xf3QE\x14W\xec+\xaa\xbf\xc3(\xa2\x8a+\xf5\x8b\xa2\x8a+\xfc\n\xfc\xf5\xb5\x14W\xe9k\xff\x00\x81\xaf\x07\xff\xc4\x00K\x10\x00\x01\x02\x04\x00\t\x08\x06\x07\x04\x08\x07\x00\x00\x00\x00\x00\x01\x02\x03\x11!1\x04\x10\x12"2@PQ\x91 03Aaq\x81\xa1\x134BR`\x92\x05#b\x82\xd1\xe1\xf05\x93\xa2\xc1\x14CSpr\x83\xb1\xd2Ds\xa3\xb0\xb2\xe2\xf1\xff\xda\x00\x08\x01\x01\x00\x06?\x02\xd8\x0b\xcaNR\t\xf0\x05\xe4f\xd4\xcew6\x9b\x93\x14\x08}\xb3\x1a\xdd\xc9\xb1\xd7\x94\x9c\xa4\x13o\\\xcd*\xbc\xeb\xdf\xe1\x89W\xdcAv:\xf2\x90NJ\t\xb7.P\xaa\xf3\xed\xa5\xf1D\x8b\xbdg=\x83~u9H&\xda\xb9Bj\xba\x82 \xd6\xeeA\xd2\xbfP\xd4M\xa8\x82m{\x97(V\xba\x93\x13\x148~\xf3\xab\xdc1\xbc\xc5\x8bb\xb1b\xc5\x8b\x16,X\xb1b\xd8\xac[\x1d\x8b\x16,X\xb1b\xdc\xcd\x8b\x16\xe5\xa0\x9b_v\xaa\xf7\xeeLH\x9e\xe2s6,X\xb1b\xc5\x8bc\xb1b\xc5\x8b\x16,X\xb1b\xc5\x8b\x16,X\xb1b\xc5\x8b\x16,X\xb1b\xc5\x8b\x16-\x8e\xc5\xb6\xb25\x17WWo\\Qbv\xec\xbb\x16,X\xb1b\xd8\xed\xb5\xd7\xb3Vj\x10\xdb\xd89w \xde\xef\x8a\x94z\xf6\xea\xd0\xd3\xb7\x13Y\xef9\x10c~*~\xaf\x95\xee\xe2\x86\xde\xa6\xd5~*Q\x1b\xbfW{\xf7\xe2\x8a\xfe\xa4\xa2l;\x97\xf8\x1d\x1b\xbbWbx\x8a\xa2\xbf\xadv\x05\xcb\x974\xb1\'\xc0\xaaD^\xddY\xa8"n\x157\xd0bk\xd7.i\x14*\xe5\xc4\x8b\xd4%~\x05z\xf6\n\xba\xb3{1Agl\xc4M\xda\xdd\xcb\x973J\xbb\x94\x8dU\x13\xe0G&\xfaj\xef\x7f\x86\'/S(.\xb0\xa5\xcb\x94*\xeee\x14m~\x04c5v\xf6\xd7\x13\xe3{\xd5Med**\xf3\xa8\xd5\x13\xe0%\x1d\xd9\xab"\rn\xe4\x1d\xc0oT\xf5\x95\x15S\x9dG\r\xaf\xc0J=\xdb\xd7Vbb\x84\xcd\xee!\xb7\xb3ZQy\xdc\x99\x89\xf0\x0cE\xd5\xde\xfd\xd8\x9a\x9dMAu\xa5\x17\x9dG\r\xaf\xc0\n#7\xae\xaf?yqE\x8a\xbb\xc9\xec\x94j\xa8\x95\xdb\xead\xee\xd5\xe1\xb7\xb0s\xb7!>\xb5\xd7W\x9dG\t]\xbe\xa4E\xed\xd5{Hi\xdb\x89\xad\xf7\x9c\x84&\xf8\xec\xb4j\xa8\x95\xdb\xcf^\xc1WV\xca\xf7S\x14&\xee%\xbb]^y\x14mD\xdb\xaa\xdd\xfa\xbb\x9f\xbf\x14H\x9dID\x15vb4B{rZ\xb4\x88i\xe2+\x96\xc8>#\xa8\xe5\xd8\nK\x9cE\x1b]\xba\xba\xb3\x117\x88\x9b\x85Oz\x92!\xb7\xadv\x02\xf3\xc8\xd1*Om(\xba\xb3~\xcdq`\xec\xed\xca\x18\xcfu6\n\xf3\xa8\xa3k\xb6\xd7W\x89\x13\xc3\x12\xbb\xa9\xb2O\xe6;`\xa8\xab\xce\xc8J\x88\xbbfZ\xbb7\xba\xb8\xa2G_jk\xb0\xd7\x9dE\x12\xa2m\x95\xd5\x9a\xdd\xea1\x9b\x90z\xf5\xd9;\xc4N\xb7l5\x17\x9dD\xea\x12\xbbeuh{\x91qA\x87\xef8\x84\xce\xcd\x86\xa2\x8b\xce"\x89Q+\xb5\xd7W|]\xc9\x89\x89\xee\xa0\xbd\x9b\x11E%\xce\xa0\x9bZZ\x8a\xf2Z\x82\xac\xb4\x97\x14x\xcb\xbf\xf2\x15w\xecE\x14\x9f:\x95\x13\xe0\xc4\xdeAo\xd9\x98\xf7\xfb\xa91\xceZ\xb9i=\x8a\xa2\x8b\xce\xa6\xe1\x04\xda\xab\xabCgZ\xb8\x90\xd6&\x93\xde\x8dB\x044\xf6\xab\xb1\x94Qy\xd4\xa8\x9bQutw\xba\x98\xa0CN\xa4\x98\x90\xd3\xd8l\xb62\x8a).q\x04\xaf\xc1q\x1f\xbe\x98\xa3D\xf7i\xfa\xf1#?{\xb6:\x8b\xcf \x9bIIj\xec\xed\xa8\xae[!\x84a\x0bH\x92U\xf1]\x92\xa2\xf3\xa8%D\xdaK\xab5;F\xb7rHzu\xbb7\x89\x0e\x1a\x7fX\xef/\xd2l\x95\x14^u\x10J\xed\x15\x17Vgeq`\xd0\x92\xea\xb9F\x0f\x01=\x86O\xf5\xc3e(\xbc\xea(\x95\x13h.\xae\xf7\xf6b^\xbc\x84D\xfedu\xdc\xb9<6R\x8b\xcfHJ\xec\xf5%\xab\xcfz\xe2\xc2\xb0\xa5\xedp\xaa\xb7]\x94\xa2\xf3\xd3\x12\xa5\xcb\xec\xe5\xd5\xa4Cn\xe4"*iJI\xde+R\x8a\xfc\xdf\xd7\x9e\xc2\x92b\xad;P\x92\xad\xec\xa5\xcb\x97\xc7B\xc5\xb9v\xe6P\xbf*\xf8\xae\\\xb9}\x84\xba\xb46\xf6\xe2\x83\x07\xfbG\xd7\xb8\xc10t\xdd\x94\xbf\xae;\x06\xe4\xe7\xa4-I\x113\xb3\x99\x9c\\\xd2)\xa9X\xb1m\x9c\xba\xba\xbb\xddLPa\xfb\x8d\x9f\x11\xe8\x96b#v\x05\xc5DZ\x8cY\xf5o\'\x8b\x08_\xb3\x93\xfc\xb5;\x16\xe7lX\xb1m\x80\xba\xc3\x9d\xbdqaQ\xbe\xd2\xf9P\x89\x13\xder\xae\xc0\x92b\xc8t\xda\x8bgn-B\x89\xe26\x0c4\x9b\x1bUv\xf5,X\xa1^U\x8brlX\xb1b\xc5\x8b\x16,X\xb1b\xc5\x8b\x16-\x8e\xdb\x1e\xc5\xb5\x18i\xd91\xef\xf7RdW{Nl\xa7\xbf\xf52\xb4.QJ\xa1lV\xc5nMS\x14\xe5\xa8I\n\xa1cD\x93m\xb9I9i\xb8\xb7&\xe5\x0b\x16,X\xb1b\xc5\xb9\x16-\xb5\xacX\xb1n}\x8d\xde\xa4\x84\x86\x9aQ\x1c\x8d \xc1N\xf2X\xe5\xc8\xb5\t)|S\x9c\x8b\x92qr\xf4-\xa8X\xb7.\x9f\x06[\x1d\x8bs\x08\xbe\xedq@oS3\xd7\xf9\x19D\xfe\x01\xbe\xd7\xb6*\x14*\xd2\xd8\xe2?\xc3\x14G\xf8\x08\xdcI"\xbb&\xe5\xfe\x10\xb1V\x9b\x8c\xc7\xf1\x11\x8b|M\xe2)9SeX\xb1b\xdb&\xfa\x8dJk9)wPLR\'\x8e\x98\xab\xb0lX\xb1b\xc5\xb6e\xf6N\x0b\t;^\xbe\x1f\xfd2W\xe1\x1a\xebw$T\xb9R\x851\xd4\xa9~q\xfe\xec&#|n*\xfc%^]y5/\xc9\xa9t\xc5Ly\xd8\xe6I\t\x94RjT\xce)"\xc5\x0c\xd2\xa8g\x17/\xcca\x18WS\xdc\xaa\x84\xf6\xdd\xb5z\x13i\'P\xa6:\x94;\n\xac\x94\xbe<\xd2\xb6/%\xc7c4\xad\x89*\xc8\xd2/\x8aP\xeeg\n\xd3+\xa8\x94\xcb\x92E3\x96H*MQ\x05_H\xb2Cz\x08\x8fnOi\x9a\xf4,\x8aU\xb2>\xad\xe53\x8a\xc1U+\rP\xb7"3\xa7%\xc9\x92)\t\x9a3\x95\x04O\x84\xeaR\x8aU\xaa\xe6o4\x91\x14\xd2B\x8e\'\x95$3\xde\x8a\x86S\x1d\'\x15f[7\x99\xaa\x93\xdd\x8a\xe5T\xa2IO\xab\x94F\x8a\xc7=\x18\xe4\xdeO\xd3\xb2GM>\xe2\xafU4\x1d\xc0W`\xedTkz\x9d\xd6}d\x04giv!\x95\xe9\x98Mc\'\x13)W)Dc3\x11+A#z%\x89\t}\xa6\x99\x08\xd7*\xee\x91\xf5p\xde\x85!=|\x0fW\x7f\x01\xd0\xe1\xc0\x96WZ\x88\xb0\x9e\x91>\xca\x8d\xcar6,\xed:H\x9f\xa5D^\xf1\xde\x9a?\xa5gR\x15B\xd2\xc5cEM\x07p3\x9b.\xf1\xd1\x15e$\x98\x88\x8f\xe2`X\x03V~\x91\xf9oD\xf7S\xf5\xe47\'\xa8\xceM\xad=~NI\xa0\xb1peTzu\r\x83\x84\xe0\xea\x8b\xef(\x8a\xd63\'\xbcF#\xdb\r;\nE\xcb)a\x99NOG<\xe4=&\x0c\xefC\x17\xb2\xc6F\x1b\t]\x0f\xfbD\x1c\xe6\xbdU\x1bzX\xa2=|\x0c\xd81\x17\xc0\xcd\xc1b\xaf\x81\r\x91~\x8d~K\x96\xb17\t\xfd\x01\xad\x8d\t=\x87(\xc9\xc3\x84\xc7\xaagv\x1d$&\x95\xc2\x9a\x9d\xc8ga\xab\xe0\x86v\x19\x11L\xe8\xd1W\xc4\xf6\xd7\xc4\xe8\xd5|J`\xd3\xe2S\x02\xfe\x03%\x982\xb5\xbb\x91\xb2&\x90\x1a\x8b\xe0Y\xad\xf13\xdd3&HV7\xf0\x8e\x89\x17\x08Tcj\xab\x92f\xbf\t\x89\xdd\x01\xff\x00\xed3pl%\xdd\xe9#\xd4\xe2/\xeb\xbc\xf4k\x80=\x9fi\xd6\xff\x00S\xa1g\xca$&\xfd\x1f\x17\x08UnVT(t>\xaf\xe8\\)\xde\x12\xfeD6E\xfa6.\x0c\xd7\xf5\xb9\xd6\xf24UJBQ\xccHWB\x8c\xc9\xfb\xc4\\*&|UL\x96\xcdlI\xc9\\t+\xb4\xe5\xb0]\x0e*Qw\rsp\xf7z\x04^\x8eW+\x85r\xd8\x94\xc9_eU\xbc\xba1\xcb\xdc\x87B\xff\x00\x94\xe8\\t^hY\xa9\xf7\x8a\xbd\x88V+x\x15\x8d\xfc%b8\xbb\xd7\xc4\xd1^\'G\xe6tM\xe0R\x13>R\x88\x89\xc8\xaa\xa1X\xacN\xf7\x15\xc2\xa0\'\xf9\x88z\xe4\x0f\xde!\xeb\x90~c\xd7!\x9e\xb6\xde\nz\xd7\xf08\xe9\xdc\xbf\xe5\xa9\xa5\x15{\x98ha\x0b\xf7S\xf1)\x03\x08\xfe\x1f\xc4\xa6\x0b\x17\xc5P\xa6\x08\xef\x9c\xf5?\xfa\x9f\x91L\t?{\xf9\x14\xc1!\xf8\xbc\xa6\x0f\x83\xf9\xfe\'E\x82\xa7\xddw\xfb\x8f\xf8v\xf73\xf3:xi\xf7\x10\xf5\xa6\'\xdci\xeb\xa9\xc1\xa7\xed\x0f\xfcL\x988\\X\xab\xf6\x04X\x98dh-\xff\x00\x1dD\xf4\x98V\x11\x11~\xd4U:\xd7\xc7h[\x1c\x94\xa2b\xa3\xa4o*_\x93Z)E.IJ\xd5\n\xaeII)^kp\xec\x9c\xf6/Q\xf5\x8d{|&g=|\xcd\xcb\x97.\\\xb9~v\xe6\x91u\xc7b\xda\xbd\xb1\xf5\xa1E*W\x15\xff\x00\xb8:r/\xfd\xc4_\x9e\xaf\xfd\x8c\xaf\xff\xc4\x00*\x10\x00\x03\x00\x02\x01\x04\x01\x03\x04\x03\x01\x01\x00\x00\x00\x00\x00\x01\x11\x10!1 AQaq0\x81\x91\xa1\xb1\xc1\xd1@P\xf0\xe1\xf1\xff\xda\x00\x08\x01\x01\x00\x01?!Q\xaa\x12A"\x1bx8\x8b\xa1\x07\xc1\xcc\xd8!E8\x0b\xa2\xe6\xac\x05X\x1e\xfa;\t\xa1\xdb\x8b\xe2~ \xda\xcaQ\x86\xc7f%\xe1\x88%\xa3\x8b\x14>\xc3hq\xc1t\\\\^\xb7\x97\xfeZ\xca\x11\x01c\xd8{\x8e K\xb8\xd0C\x1d\xcb\x10\xeb\x92\xb7\xe9\x0c\x9ag\xec=\xac\xf8\x1b\x1b\x04A\x07\xdc\xe4 %e\xdb\xde\xd1\x13\xcd\xd6\x7f\x85\xff\x00\xd1%\x82\xe2A1B~\xf0\x96\xd1\xa4*\x15D\x07\xd1\xc0LoX>E\x82\x0b\x81as7=\x1c]\x10M0\xa6\x82"T,\xd9\xcd\x9c<\x0f\n\x04\xd2\x17gc\x9b\x13\x1a \xfa\xc1p7\xd0\xf0\xde/\xfa{0\xa3\xdc\xf6\x8d\xe2o\x91\xa0\x98\x98\xca\x87LH\xe9f\xd0y\\\xfd\x0f\x13tl\xac=\xb16Q\x8dP\x9bh$/\x9eHO\xd3\xe7\xf9\x1a\x9f\xb0\x82\n(\x98 \x13\xa1\nlJ\\\x89\xafd>B\x05\xaa\xd8\x8f$NE\x89\x1b\xe4M\xe4G\x91\x04\x88\x1aR\x05\x0b\x11E\xb0\xbb\xc8\xe9\xb3\x89\xd9\x0b\x81\xc0Cl\xd3\xce+s\r\xf0\\\x1c?f=\x8e#\xe30>\x90\xc2y\xb9o\xa5\xff\x00\xa5\x88\x90\xd6\xcd\xf2\x16\x84B\xc4\xa1\xe4\xba4\x1cG\xf5\xccP<\xc3\r\xe3\xb0\xb1\xa1-\nD\xe4\xd0v\x99\xbd\x1f\x04\xde\xef{<~\x83\xd8\x82bA\x081\xecFy\x17\xb9\xad\xec\xa3\xe4\xd9\xc9\xc3\xb3O"\xa5\xc9\xa7\x91\xed\xc9\xe5\x1aw\x13\xe4U\xe4\x99\xc9\xaf\x91#\xe4O\x917\x91w\x91q\xba#\x9b\x15y7r((\x9b\x12w\x0b\xee%\x1e\xc2.D\xf0\x11\x1c\x17\xe4SC\x90\xe8H\x89\x08\xf2T\xf9\n|\x86]\xf8\x1a\x83hq=t2\xf4^\x87\xfe\x8a\x93X\x96\xf7\x8e\xe8w\x91\x17\xbc\x0bF\xc7r\xb0\xac\x0cQ\x86\xf0\xb3T!\xa67s\x90\xe3op\x9bv\x91r\xc7S\xe4%Z&\xb5\xff\x00~F \x82\x8a&\x12\xd8\x91\n\x03\xff\x00\x03\xf0\x1c\xa1\x04 \x9f\x03#\xf6\x1eS\xfd\xbf\x91\xe9\xebC\x1e\x08 \x84\xcc\x8a*J\x12\x14\x08\x90/A7\x8e\xa0\xa1\x93&L 4\r\x0ca\x18$2M\x10\r_\xfe\x07\x86\xe0X\xa5\x97\xbf\xf2)\x9b\xbf\xa8\xd1S\x0b\xff\x00\x02\x847\x07\xb5\xc1\xaf\x81 i\x88z\x13\x16\x1b/\xfa\xdd\x03i\x9cX\xe9Cc\xfb\x98\xf10\xdd\x191\xb2\x8d\x8d\x9c\x14\xa3pa1k\xc5M\xf0j\xc8\xcaDxF\xd5\xf8\xd0\xddm\xf9\x18\xc4\xc1\x04 \xbf\x02|\x0b\xc0/\x10\x89(^\x01x\x85\xe0\x17\x80\xf5\x1e\xa3\xd4z\x8fP\xfcG\xac\xf4\x0f\xc5\x83\xd0z\x89\'\x13_\x04x#\xc0\xfdO\x89\xf1\x1f\xa9\xe9>#\x06\x81\x01\xa0j\x1a\x04\x84x-\xd8Kab\xd1\x1cH.\x86\xff\x00\xd5SA\xa1\x93rG\xb0a\x86\xde;dE)F\xca<\xb44\x1c\xc4\xdd\x17\xbfr\xdc|\x1abK\xfc\xec\xf7\x08\xcf\x9ei\x86>\x80\x84\xc0\x82HHA\x04D]`I$\x92I\x04\x13\xf40\xb5$jA\x03Q\xa7\x81\xaf\x81\xaf\x81\xa7\x81\xf8\x06\x9e\x04\xf8\x1a\xf8\x1a\xf8\xc1,I\xf4o\xfaK\x87\xc6-L\xaa\xf0\xa9k\xef\x80\xf6\x1cn\x80\x99rR\x94\xa3\xe3\xa0\x11\x0e!\x9c\x8dA\xbbx\x11\xa2\xe7\xed\x1c\xff\x00\x07j\xb5\x861\xe4B`\x84!\x08_Yb\x13\r\xa4AH40\xc3\x0c2\xc3,\xb2\xcb\x0c2\xf1\xa0\xb2\xbf\xe9o\xd0x\x8d\x87)g\xdeE\xd3V\x18\xe2"\x89\x94\xa3\xc5\xc3\r\xbc!L\r\x8b\x84lC\xbb9\xf9\x9c_\xb3;~\x16X\xf0k\xa0B\x10\xb2\xb1:\xe1\x08LTj<\xc1\x1b\x94\xc3\xc3\x1fK\x1a!\x08L\'\xd1\x7f\xea\x1fY\x841\xed\xc7\xa8\xd9k\x1f6\xa2e\x1b/H\xb0j65\xc8IR\xd8\xb9E\x8e\xfc\xb6\xdf\x1c\xfe\xa3\xd6yc\x1a\x1e\x13\x04!\x0b\x08_B\x13&Xx\x89\xee\x14\xf24\x0eB\x17O\x12w\x0cxc\x1fC\xff\x00\x05\x97\xfc\xe8.\x9d\x04\x9177\x7f\x0b\x85\xce\x981J7\xd0.\xf1F\xc7\x87\xd0\xf2\xff\x00\xd9\xf09\x8df\xe8\xe0{\x01\xd3g"\xdcW\x8b\r0O\r\x85.\t\x94ln\xe0\x87\x1c+\x9f\x1b\xe0\xdb\xb4\xed\xfe\x17\xeb\xfa\x1f\x0e\x9d\x0cc\x18\xf0B\x17J\xeb\xb4rg\x7f\x12\xbb\xc1kTV\xff\x00x\x8a\x97\xe9\x166_#\x9d\xff\x00\x0b\x0f\x90\xb4\x12\x87\xef\x8f\x94:)\xbaR3I~\x93\xfa\xcf\xfd3Ds\x13\xa6\xd4\xd9]\r\x8e~g3A\xb0Zt\x8e\xc2.\x86\xc4\xeenf\xb6M\xb5\xe9\x87\x87D\x9f<\xdf\xe3\x12\xb9xc\xe8\x16\x0b\xe9\xaf\x94pQ\x1b\x12\xfe\x0b\xfc=\xb2\xed\n\xb6\xed\x1e\xd2\x89H6Y\x14c\xab\xb4\xbb\x88\x01)\xde:\x0c}\x17\x14\xbf\xe0_\xf3o\xd0m\x1a\xc837g\xb1\x86\xf6jm\x96\x89\xe0\xcb\x1b\x1d\xc4&S\x96\x0c-\x1e\r\xa3\x84\xc6\xcc"\x8fq_\x03b}\x0f\x0cc\xc2\x16K\xe8\x9c\x98\xce\x9dm\x8dTb\xc4\xa3\xd4\xdc5\xb1\xfe1\xb9\xad\x89P\x949\x93h\xe7%\xd1\xac6\xe6\xf4^\x8b\xfe\xc7\xb1\xa8\xd4\xc9\x87\x9c-\x06\xf1\xa2\xce\x8b\x04\xeb\xc1\xec\\\x88o\x0b\x83\x9ac\xef\xb6z\x18G?\x95C^\xf4ION\x13\xe8c\x18\xf0\xb0B\x11:\x9e\n\x08]\t4\x06\r\x88\xe3\xa0\xd7\x82l]x\xe8\xd3\x94iw\x14\x02\xd5\xbc\xb3x\xa5/\xfa[\xd1~\xb3z\xc1\xa4\xaf\xf0\x9b75\xa8\xe8Ox\xf0\xc1\x0fb\xe8\xe1\xd1\xae\x1b\xd1M\x857\xc1M\x1d\xa4\xe9\xc4O\x1dF\xbc\xa5\xff\x00\xb0\xf5\x0e\xc2\xc5\xe8c\x1e\x16\x08B\xfa\x08&\x1d\x03n\xb16%\xbf\x18!\xc0_\x82w\x13x|\r|\x16F\xad\xb8\x1c\xc4\xb2O\r\xa3e\xcd\xc5/\xd1\xb8\xb9\xbf\xe4R\x97\xae\xf4\nQ\x94\xa2xR\xb3p\x85\xa2\xb4~a\x07\x0f\xd8#\xb4\xe6\xd3\xe1i\x0e\x8f\'D.\xa6<,\x10\x85\xf4\x1b\x10\xb2g9\x1b\xd1GG!\x06L]\x81\xa1E9\x9a\x0bQ\xcc\x7f\x061\x03]\'\x8d;\x9b\xd1K\x9b\x9aQ\xbc\xdf\xf4\xf0D\x93\xc5\xa6\xd7\x11#\x13\xa4\xd1\x07\x16\x16Xv-\x84Y\t\xd4R\xaf\x9e\nh4e\x19B^Y\xe4\xc5g\xb4\xc7<\x9b\xf7\x02XB/S\xc2\x10\x84.\xb63lw\x8a\xa7\xac\x0b!\xbb\xc8\xd7\xe4Z=\x8f\xcah\x10\xe7\xbc6\x14b\xde\xcc\x8b\xf4\x14\xad\xef\x1e\x9c\xdc\xdc^\x8aR\x8f/\xfdH\xd6\xcao\x03\xdd\xbf\x03\xde]"\x8cP\xe7\x02\x8fa\xe0LN\x9d\xa2p\xd8cQ\xb1\xaaE\x9f`\xff\x002\x12!}\x18_\xbf\xf0{\x85`\x84.\x97\x8a$!\x08_A0R\xf9\xccD\xf4=\x87\xe1\x14hhJ\xbd\x14\xc4\xac\xe0z\x969\r\x80G\xacU!\xaci\xdc\xdc\\\xd2\xe2\xf4\xbf\xf2o\xd7m\x1bq\xed \xd0\xf2\xdd\x1e\x8fL\xd3\x83I\xb8\x9e\x8a6\\ xl&9\xbb\x17\x07\x01\xb8\x11*.\xba\xee\x08z\x97g\xb6\xe2\x7f\xa7\xea|R\xc1e\x17\xa1\xe5!\x08B\xfa\r\x08&+&h\xd0\xe4-\xae\xe8Q\xec\x83Xh/\xa1q\xdd\x1d\xd1M|1&+kyg\xd1K\x8a^\xab\xf5n/\xf9O\xa3\x9c\xe7\x1e\xd4{c\xd6zF\xa7\xc1~\xc1\xd9*\x8a\t\x04\xaci\xb1\t\xec\xa3\x14O>\xc5\x8cm\x8cZ)|\xbc\x10\xd6[g\xc0\x96\xbfs\xd8\xccX]O(B\x17\xd1cB\x0b\x8a\xe7(h\xbe\xc5\xd8\x9e\xfd\x8d\xef\xd0\xe8\x82\x9e\x1d\x84\xb8\xb3A\xa8\xb2?\x83\x90\x90\x13\xbe\x1a,\xd2\xf5\xdf\xf0)JR\xfdz^\xab\x88,\x1b\xa6\x97\x89\xea=%\xee/\x08\xa9$P\xf7\tV*\x98\x9d\xc1\xb6=4\xcb\xd9\x1a\xecv\xa8j(\xda\xa1\xe6E6\xc5\x9d\xdd\xfao\xfeB\x11p\xb3zVW\xd2hA0H\xcdoF\xbd\x0b\xc1\xbd\xb2\xaaG\xdcj9\xcc\xd5\x9f\xa8|\x9c0)<\xa7\x8a\x96\xa5pW\xa2\xe2\xf4_\xf0o\xf8\xd7\xabQ\xc84b\x87\xa3\xbfY\xe9=c96/\x93\x1c\xfd\x1a\xf9\x86\x84Q\x94m\x9c\xb1\\\x9a\x04\xe8\x88r\xfa\xa0\x84\xbc$5\x85l\xbd\xd5\xff\x00G\xdc\x87\xfe\xfb!a\x17\x0b\xa5\x94_V\x08 \x85#\x94\xba\xd0s\x1a\\\x1d\x83\x98\xf5\xc5\xa0}\xb1\xb9\x1a\x8d\xfb\x1b\xde\x1a\x8dp\xf5\xd8H\xc8B`N\x8c\xb8\xbf\xeboCk1]\xe8\xb6\xf7\xd7_\xbe\xa1\xfe\x076|h\xf0\x88\xb6@\xd4\xd9\xce<\xa9\xf6\x13-\xbd\x0cS=\xe0@w\xb4|\x7f\xf5\x89\xa8+\x0b\x0b\x17\xa9!t\xaf\xa0\xd0\x82`\x81\x95OF\xbd\x079b\xce\xf2\xeb,\xf8\x18\xe6\xc1\x8a\\h\xaa\x05-7\x87YqJ\\\xdf\xf5\xaf\xa3\x9b\x0c\xd5\xe7%\xf3~\xbc\x1e\x93\xd0sh\xbfa\x88H/\x03\xbc\x1cs)\\$\xc7\x07\xb0y\xe8Y\xb5\xe9\xff\x00}\xb1\xeeBR\xbfw\xfd\xe0\xa9\xf7\x04,\'\x85\xd2\xf0\x91>\xb2\x08!p\xe4)\xa0{\xf7\x07\x0f\xd8\xd6c\xd2l\x1bxXa\xae-\x18\xe4;\xd7l=(\xc0\xa8e/\xfb\x1dF\x86E2N\x04 \xd1L^\xbe\x83\xaaz\x0eq\x8bpi\xfc\x86\xadpk\x1e\xe3\xcc\x1f\x11\xb4KL\xb4j0<4\xb6\xff\x00\x05\xbd/\xc1\xcb\xde\xd8\xb0\xb3zYp\xbe\xb3\x13\x04,\x17L\xd6\xd0{5\x13;\xba@\x98llo\xc6W \xe52\x88\x94\x95\xc8\\\xdc_\xf5\xda\x9e(9\xf0\x81\xe5\x084<*b\xf5e}\'\xa4\xafj\x9d\xc8\x8f\xe4\xd7\xc9\xbcC\xfd\x82\xb4]\x8d\x1eBZ\xfb\x9b\xfaG\xdci~\xac\xf4\xb0\xbf\xef\xd7\x08]\x08\xa5\xc5\xfa\x13\xe910Rf\x8eM\x14Z\x13p\x87\x9az\xc5\xad\x8e0\xda\xf20\xd8\xfb\x94\xf8Q\xe8uH\xcf\x19Ixl\xba/\xf97\xe8\xdf\xa8\xde\x8d\x06\xc2\xe9a\x87\x98B\x0cS\xa2/Y\xcf\xa2{ j\x9b\xa4\xdf:\x10\x0bE7"*Z\x1e\x0b\x13oj\xf4\xbf\xad\x1e_V\xff\x00\xef\xcfB}\x08\xa5\xff\x00\x11\x88&\x0b\xc5\x96\x85\x16\x85\x82\xe3\xa7\x01\xb4{\r\xeccc\xe3\x07\xac\xec_\xcc-N\xect9\xff\x00&\xfd{\xf4^,\x1b\xb17\xb1\x8d\xe0\xba\x1a\x1a\x1a\x11\x07\x89\xd0I19\x1c\xdb\xd1{\xdc\xb5\xb2\xfb\x14\xf8}\x8d\xd2\x8ct\xbc\x0f\xfe\xf9\xc3\xbc\xe9\x7fj\xf7\xfc\x1fb\x91JR\x94\xa5)JR\x97\xfc\x07\x92\x8aYB\x89\x94Z\x1e\x0c\x15\xb9\x86\xf40\xc3!\xa0\xc6\xc6\x18\xc6=*\xe9\x9c\x84\xa8\xb8h\xb2\xbf\xd76\x8ec\x90\xfc\xc2\x8f.\x1d0c\x86\x08\x1e\x1aY\xb2\x0b\xb4)"\x1c\x0f\x02\xd8\xddF\xd8\xad\t\xfe\xcf\xff\x00p\x94;\xff\x00\x0bO\xd0\xa8r\xd4]w\xd7M>_\xe0<\x97\x14)\x1a\xf48\xc0\xe6D\x1e\x13o\x06\xc66R\xe4\xd5\xbf\x06\xbe\xf0\xb5\x1b\xe4\xe0*s\xfe\xc3F)\xb9\xaa\xf00\xd8\xde\x11JR\xe1\x9cH\t\x13\\\x9eH\x95\n\xb8\xd1\xa9k/\xa7\xad\x9cG\x9fa\xbe\xfb\x14\xedZ\xc2\xef\xa2\xa4\x87\xe4\xbf\xfd\xcd\xf3>]\x03\xe4|\x8f\x91\xf2\xc7\xe4|\xba\xa7\xd2xx \xba\xc5\xc6\x0bh7j5\x89\xf9\x18o\x06\xd8\xc3\x1e\x1e\x0c~\xf6\xc5v\x88b\xe0\xb7Z\xff\x00P\xcd\x07)\x17(\xc7\x86\xfe\x83\x14#\x17\x9fxw\x17\x01\xaf\x82E]\x83\xa0hAj\x93\x85\xa2\x7f\xae!\xcf\xf0\xcd\x0b?\xd1\xff\x00\x9a\xfa\x1f\xfe\x7f\xe3v\xc7\x86&u3V\x84\x9a\ry.9\x18\xf9\x1a\x8c|\x0cxc\x19\xb2%<\x91\xce\xf2\rWR\xff\x00P\xda\xc1\xb4\xfbP\xa9\x94\xa3\x17E(\xe0\x80\x95\xdc\xf7\x12\xbb(\xce\xd4jDM\x05\x98\xd6\n\xf3\x13\x10\x9f|\xb3\xaf\x15\xc4\xff\x00A\x8c\xe9\x7f\xcb\xf6\x87\xcc\xf9d\xf9c\xf3\x17\xbfT>x\x84&!\x08Bu1\xf4\x14L\xde\xa7\x02\x9cG0<\x18\xfaF;\x1f"\x84\xc5\x89\x84\xe5\x88\xac\x8b/\x15\xf5\xd8\xf2\xc5\x13\x15\\)\xb4*m.\x063\x90\xc61\xe1\x8ccGI\xb5oh\xe6E\xe6\xcd\x02\xda\xca\xfa\xb7\xfc\xddL\xd6A\x84J\xe8+1B\x92&{\xcd\x0fg.\xca]\x9e\x86q\xe8\x82Dq1\xdcd\x9f{a\\0\xacG\xf4u\xff\x00\xd7x9\xc8\x8a( \x82x\xa2e\xca\xfa\xb7\r\x8d\x94\xa3\xc1E\xc3t\xd7\xa1\xe1\rj\xec\xc6\xe8\xd8\xd8\xd8\xcb\x87\xd0\xc6,O\x1d\xca\xb0\xd9\x7f\xacf\xa3Yx\xa3\xbd\xf2.\x88\x88\xc5\x1e\xe2wg&\xce}\x8f\x0fvFh\x99\x10$jv;\x8cC\xcb\xe0%\x1f\n\x0eQ\xb8,\xf6\x9f\xa7?a\x976R\x7f\x0f\xfe?#\xc8A\x04\xc6\x18L]w\xa0R\x94\xb9)p\xd8\xd8\xd8\xf2-\x10L\x15\np4hM\xb2[Cm\xdd\x0c1\xb1\xe2\xf4\xa7\x18\xf6\x8fk\x15!\x0e\xe3T\xbe\x88\x05\xe8\x14\xa5)J\\_\xf0iK\x96\xd6^\x8f\xc2,0XN8\xa4\x81\x13\xde{\xcfh\xd6\xd0\xe7\xac\xe3\xd1"d\x84\x986&5\x1eSW\xd8Eo\xcc\xfd\xa9\x7f\xe8\x8f6\xdf}\x7f\xcfcxQ11\x86\x1b\x04\xc5\xfe\x1a>\x0c\xb2\xf0\x19Et\x1a\x17Mz\x1b\xf4\x18\xcf\x03c{\x1f\xd1j\x17\x82F\xe1\xad\x7f\x88\xff\x00\x86\x14\xa5)JR\x97\xa8\x14\xb9lxQO\r\xa3k9\xc9\xae\xf3\xa1\xbc \xb8\xa9\'\x8f\xdcr\x14\x1e\xa3\x1b\x83D;\x1ap\xc0\xa5(\x91\xc7\xb8\x84R\xd3\x83\x9fChU\xfc\'\xf06R\x89\x8d\xd0P\x85\xd2\xd8\xc3\x0c8>}.\xfd\x8f\x90\xb1*\xee*\xc2y\x04\xa2,>1bZ\tb\xf4sA\xf3\xd4\xfa,%3\xda5\xa8A\xd8\x9f"W\xdcT+\xc7N\x93\x94H\x94J/\xa2\xc8e\x86\xfd#\xf3#\xc9N\xf8\x15\x97\r\x92\x18\xde[\x12F\xb6@\'\xf4\r(9&{\x0f~]A\xb5\x1a\x8fY,\x92)J\x84F\xee\xb1b\x1f\xef=\xf8\xed\xf8Z\xfb\rj\xb2\xb7\xe5\xe1J&&0\xd8!\x0b7/\x03A>\x88\xb6\xc2lX\xbd\xcf(c\'\x82\xcf$^a\x1d\xc5qx\x97\x04rb\x1c\xe8\xeb\xd0\xdf\x0cn2\xdc\x83D\'@@LcX<\x90\xee\x82\xdfqcAD\xbb\x9e\xd3\xe4O\x91\xa2\xee|\xc5]\xcfaB\xa2+\x02\xc1K\x86\x86\x8a\xc1\x89\x1eH\xe2)\xe7\x15\xc3R\xe1\x91\xb8\xdcP\xa6\xb2i\x92c\xe2XfpC\x07\xb8\xf7\x1e\xd1\xec5\x8e\x02&\x92Y\x0e0a\xbd\x94I\xf76vf?\x93\xf9\xc7\x96\x97\xea\xd1\xe8\ro\xea\x18\xa5\x10\x86\xe8HXO4X\x18l\x1c1\x08\x14\xcb\x10\xc1\xb9\x149|\x8d\xf8\x0bHi\xe4\xee\',\xfb\x88\xbc\xfbj\xd2B\x11\x82\xcd\xcbo!\xb3a\x8a\x889\x1cv0m\x85\xdb\x1bU\xb1\x89TX\xbf\x03\xf0G\xa8h\x16\xbb\x15\xe3\xa28\x11\x90\xa4\xe4s\xb4\x1d\xaa\x17\x16\xc4\x89\rWrND\xf9\x11BG\xc8\x8e\t(l\r\x9d\x8f\xc6\xd1\x85\x15\xd3\xd6\xa3\xf5\xcf\xa0\xe6)Q\xe8=\xc2\xa3\xf4\x1cM\xa5\xb31\xa9\x95M\xc7\xb2\x03\x82$\x8fa\xef-\xdc\xa0\xa9\xc3\xac\xac\xf0\xa4(\xde\x14\xb83\xcc\n;\x9b\x94|\x13m\xfe\xdf\x91\x1a\x93Q\xaf\xfb\xd8b\xe1\x0b\x04\xe8\xcb\x0b\xa1c\x92\xc2\x04\x92( \x91\x92B$\x1a\xdb\x12\xea" \xe4\x8d\x84h\xaf-\xb4.\x9c\xc62\xaa\x18\x87\x82w\xde\xff\x00O\xd8z\x97\x13Ds\x86\xce]\x9d\xe3\xa3-\xa1\x14\xf4\x85TD\x18t\xf6@\x96 H#\x0f\xb1/\xb0\xfcC\xf1\x1e\xa1\x11\xad\x08\x91\xad\xacDy6\x1eI\xa7\x91\xf7\x93aN\xe0{\x82\x8b\xb8\xda\x15\x87e\x16\x83\xcdb\x0b\x14\xc6\x8dfatXcC\xa8\xc1\t\x82\x0b3K\xc4\xdb\x85\xa0\xdc\x9b\x19=\xfe\x8e\t"]\xceS\xdas\xe0=\xce\x1d\x10\xecz:\x1bJ\x17\x06)JS\xc2\xa0\x99,UP\xfcW\xfe\'\xf6\x12\xd5\xff\x00\xa9\xfeK\x84$%\x90L\t\t\x10\x84!0\x84\x1a4\x1aq\x96\x0e\x0fg\x04n\x18\xd3\xb1k\x90\xb5\xdcp\x16A\xf2;\xc9Q\xd9\xdcn\xd6\xbc>\x7f\xb1\xbf.\xff\x00\xd1\xbb\xb1C\xc3n\x0f&\x84\xc6\xeb\x12\x8e\xccX\x86(\xd6.\x8a65\xb64\xb9\x8e\xc6\x9c\xd7\xb0\x98O\xe0\xa7a4\'\x06;\x0f>\x0f\x15\x8avd8\xa7"\x98\xbf\x81\xcb\xdc{\xc3\x17d\xcf\x00\xf0\x05\x1c\x19HPb\x05\xa3\xc2!\xdb\xa7ND\x18\x85\x8d\x14\x12\r\x18\xb6XW`\xae\xc2]\x89\x0b?tl7\x0b\xa1\xe1\xd6\xcb\xb0\xa9\x8b\xc9\xda\x07r$r\xec\xb7q\xae=\xf6z\xc9\xf6\xe8\xc6\'\x05\x1b.\xca\\R\x9a\x17\xfe\xbf\xf4L\xf1\x8aE}\xa3\xff\x00\x03\xf9/\xd6],N&\x13\r\x1a3*\x8a\xe9\xedb\x94\xa7\x12\xcb\xa1\x94\tcT\x1f\xd8Hn\xcan\xdd\xbfC\xc6\xdd\xa3;\xd9Tr\xc3_\xbb\xf2\xfdxc\x9f\xb8\xfc=\xd1\x0c\x9a\xff\x00\x85\xc2&\xd8\xc1\xbbd4%&\x84\x8c\xa3\xd8\x9c\x8f\xad\x1b,Q\xae\xd8}lC|\x08a(\xa8\xa8\xa0\xa3\xa1\x18\x08\xcd\xf9\x8dO\x12\xa0T\x82|\x0b\xc0%\x04;\x0b\xec\x10\x84!d<\x81p\xa5\x10`\x99)\xbe)\x10\x94H\x98B\x88\xa1\x17\x8cX\xe643\xb3\x17\xba(\xb87\xe2\x15\xb11\xc0\xe5\x98\xf5\xf4Z\xb4U\xe2\xe1\x88o\xa1\xbb>\xed\x9fsS\xda\xc7\x9d\x08Ko\xdcY\xf7\x12\xb8G\x96\xd8A\xd6\xcf\x18\xa1v\x11Z\xa45\x906; \xb8&\xe2\t\xf6\xa3\xf0a\xbb 6%P\xa4|#e\xe9\x12\xe2\xe2{\x073\x144\x17\xd3\xa1n\xdd\x18\x90\xcc\x97\xf4\x15&i\xc2*E\xb0\xf5i\x0b-\x17\x03\xf4!\xd8R\xc5\x1b\xe4\xc9a\xc9\x89\xf6\x14\x8e\xc0H-\x14\xa1+\xb0\xa5\xd8K\xb0\x90\x90\'\xc1\x0e\xc7\xa7\n\xcfN\x11\xe0^!\x04\x90%\xf0$\xf0O\x81\xfab\xb3\x13\x13\xe8\x08xDH\xa8E\x04\x84\x92H\x94\x91\xc5\xb8\xa4\xc5\xa2LQ\x9d\x841\xae\xc7`;!k\x0e\xaf\xb0chxC\xedN\xe1\x0f\xd0en\x85\xc0l\xa2c\xd2\rS\xca\x10\x84I\xa4\xb4\x8b6&\xfe\xef\xf4L\xe2r\xe4\x97\xe9\xfc\x16\'\x9f\x02n\x07\x1clkr5\x08E\x1a\x86\x98\x910\x1e\xf4\x0b\x8bC\x9d\xef\x19\x13\xa0\x8f\xd5\x88\xcb\x7f#\xed}\x81\xb9\x97\x88\xcb\xc8v\xc3C\x0c\xbc\xbd\xe1\x0c\x10\x1e\x19\xe0\x10\xec!v\x12X\xeei>1\xdf\xec~#\xb1\x08Mc\xa4\x8c H$\xf0 \xb3\x96L\x91dHR\x12\x16\x0c\xb0\x10A#\xc1z.e\x1eb\xcb\x0f\x05\x18\xa8\x98\xa1\xb9E\r\xc8\xdb\x02\x1a\xa2)\xe4\x1b\x19JQ\xa3\xec6\xf6; \xc7a\x9e\xc3h+\xb0\xec\x16!\xce\xb0\xd3X\xe0y\xa7\x8f\x90Ss\xda\xdf\xc9\xff\x00\xc3\x1e\xb3iH\xbe\x042\x94h\xf2(I\x85\xc2\xd1K\xd6\xc3F\xd8\xccv60\x84"\x1c\x18\xd2 @\xa1\x11\x04\x89Dx;\x9c\x1e\xdb\xd9\x01\x12\x1d!C\x9e\x1a\x1b\x15\xdc$$\xf7\x0b\xc8$\x12\x89\x04\x9d\r\x06\x935\x93\xeeG\xb8\x97\xe4]\x08}\xc4>\xe4\xf9\x12\xf9\x12y\x12\xf9>g\xc8\xb0\x8b/\x1aC\xe5\xd2l\xb0\xc3hd7\x87\x83k\xc9\x02Q)#O$\x92U\x0f\xb2\xcct\x8co%\xc2\x8c0J\xc4.\x1b9B\x0e\x0e\xca;\x08\x97\xe0y\xd1\xcd\x83]\xb2\x1e4\x07\x8a\xc7\x91\x9bJhG\xcb<\x03Qy\x12I6\xc5\x1c\xe4\x95\xe2\x97\xa2\xf5\x87B\x0c\xa1f\xd0\xc3\r\x94L\x81\x19oi.\xe2|\x89\x12\x84\xf7\x08]\xc5w\x08\x07\xc6\n\x8d\x8b\x16\xc5y\x1e\xf0\xe7\x91\x96\x07\r\xc2\x98\x98\xc3\t\x8e\rCRc\xbc\xd2\x16v\xb1\x89%\x89`L*\\W\x93\xd8<\x9a/\xdc\x8f"\xf6=\x82\xf6>c\xf7\x1f\xb0\xc2<\x8c\x1a\xf9\x10\x8f(\xf2\x8fq\x1eD\xae\xe2S\xe4[\xee1\xdcK\xee#\xb8\x86*h\x8e\xd1\x15\xb18J\xfb\xf4f\xd0\xed\x89\rbe\xc5\xe9\xe5\x90\xe3\x965m+\xf47\x9f\xb0\x83f\x9d6\xe1\x0c~\x0bg\xcbW\xf2\xcd\xd0\x88\x94\x82\xf0\xf0^\x12\xa6\xd0\x98M\xec\xef\'\xbb\nYD\xfa\xa1}\xe2\x820Th\xd0\xda\x19\r\xa1\xa0\xf00\xe1\xf0\\\x1f\xc2tc\xc9\xe4\x8e\xc6\xce\xf0\xc0\x1b\x1d\xe8C\xc9.S\x15\x80(\xa4\xad\x08XT\x88%\xecB\xf9)\x0c-\x8b\x1e\t\xf1\x96\x8b\xcdz\x84\xc3 \xfc>\xdc\rey\x18\x84<\xa1\xbd\xc6\xbe\xe6\x9d\xc8\xf7\xc4\x97\xdct\xb4\xc6\xa1N\xe2\x7f%\xa7#\x9d\xcb\x06=\xc3\x1b\xb1\xe2\x1e\xe65>D&\xb6E\xb1\xba(\x8a\xec\xf0g\xdb\x17"\xdfs\xbe\t\x18\xd2d\x08\x90\x85\x10\xa5.D\xce\xe2e\x13+\xbe\t~\xe7\xd8IhK{\x8fq\xc8\xda\xc0\xcf\r\x90\xe1\x14\x81\xf6\xf6\x8b\xf8\x1d\xc4[\xef\x89\x0cI\x85\xe8\x86\xb0*\x1a\'\x13\x17\xe4\xf9\x1f1\xfb\x8f\xdcJ\xc5\r\xc6\x1a\x89\x11\xe3\x01\x1e\x04\xf8\x1ax\x18\r\x83\xd0=,$/Q\xe8\x17\x88\xf4b\x81\x08h\x86\x84y#\xc9\xec=\x87\xb8\x8f$y!\xf7,:\xc12\xe1f\x1a!\x06\xb3\xc2\x86\xb7\x07\x01\xad\x9f\x11\xd3CLG\x81\x97,zxx%B\x1d\xc60\xee\n\xadF=1\x8f\'\x9a\xe3\xda&\x87\xae\xc3\x05\xf2\xd9\xdd\x13\xbc\x86\xf6\x1b\xe2c\xee\xa8\xb9\xba\x15m\x9d\xc0\x97\x93\x8b\x19\xd9\x0eOh\xe6\x8eA\x9d\xccK\xee,\xe5\x82\xe1D\xc5XLC~\xea\x7f\x02\x14\xff\x00\x9d\x87\xde\x06%f\xd1\xa8Ho\xca\x1e\x06\xa9\xf00\xd3g47\xbe\x15>\xe7\xb4Ae6\x87x%\n\xd1e!\xb0\xd8l0r\xe4],O\x11\x86Z\x10@\xc3\xd8?0\xfc\x87\xbcb\x88\x88\xf0M\x06\x83\x10\xf2@,d\xe10N\xee3\xdct\xf90\x11!@\x81\x02q8\x8a\xc3\xf0\x10\x8a i\x8bJ]\x0cHh$kCW\xca+\xc0\xf5\xd8xpX\x8b\xb0\xb6\xb5\x8ch[\xcb\xf6\x13\xec\xe3\xc6\xae\x1a\xc4\x7f\xf0\xc5==\x8a\x1c\nMA\x81\xed\n\xf6r\xe9\x1c+EltI\xad\x1e5\x1e\xedM\xa34@C\x16\xb4\x10\xec\x8ay\x1cj\x1dj\x8ftc\x1a\xc2\x88\xe0\xc5\x92\x85\xc11\x08F\xfdm\xa7\xe5\xfd\x0fL\xa2\x1d\xc64hj\xd1\xeb\x1aa\xaf$Q|A\xf1\x08\tC\xd6\x04\xf9#\xc9\xa0\x98\xde\t\n\x9e\x18y\xa3.\x13C,ca\x83\x07\xe6\x1eH~C\xca\x1f\x90~c\xc8\x18\xd9\xb3x\x80\xaa\xfa1\xb1\x16\xc9\xc2^\x01x\x89 \x88\xd1Q$\x90H\xd5\x96\x1c\t\x8d\x02\x8d!\xce\xdaD\xc0E\xce\x06\x08\xbc\x0bW\xfb\x1a\xed\xfc\x88\xd3\xf7\t^\xd0]\xd9|\x16z\xd9\xc4(1\x08\xf6\x89\x89<\xfd\x84\x1a\xa8\xe3\x11<\x0f\x91\xba\xc8\xa0\x9a\x1b\xdf\xd8Y\xe5\xf094\x86\xbdS\x85\xfb\x04\xbd\xa4\xba\x16\xdbc\x90\x05a\x9bP\xd2\xb5!\x11\xff\x00\xa9D_\xacM\xa4\xaf\x91%Y\xb1d\xbb\xc0q\xb3g)\xc1KI\x05gz\x91\x8b\x02\x1b\x0cl]\x1d\xeaw\xf9W_\xb3\'\xb73\x02\xd9\x06v\xc4<\xe4\xc4C\\\xc2\x1f(l\x9a\xf6dhh,\x9b\xd1\xacoG,)K\x87\x86\x89\x8aR\xe1\x8c\x84 \xc6\xb3\xbc\xa7\x94\xd2%\x9c\x13a\x1aC \xe9^\xcc`1\xf6\x18&,\xb2\xbc\x97\xe7\nR\x94\xb9\xa5\x112d\x0eE\xbbB\x9d\x06\x0e\x90Cm\x8b\xa3R\xa1\xa8>(\xe5I\xcfl*\x9fd\'\xb5|\x1d\xc6\xc6\x17\x83\xbe\xbe\xc2\xeel*\x8d\xbc\x0c_u9\x95G\xbc\x90\xd3\x97\xd8q)\xa0\xddW\xd8\x94QZ~\xce\xf81\xae\xb6U\xd0\xc2G\x05\xeca\xf5\t\xaa\xedK\x80k6\x10 H\xec\x1a\xf9}\xc55\x14=1;16\x10\xb1\x9bvU\xf6\x1c\x8fo\xb5\x11\x92\xe6\xd0\xf9\xb7\'\xe1\x8d\xd0\xd1x?d\xa1b\xfb\x89\xcd\x06\xa6\xf5\x9cOi\xe07\xa5\xfb\x8a\x81H\x9f\xf3\xf2B\x9c\x17\xe7B|\xe5\x93\xa2T2)\xe3l\x86\x81\xc0\xee9\xc0\xca\xb8&Q\xbe\x81p\xde\x14\xbd\x0f\x0f\nO\x02\x1axLA\xd1\xa0\xb8\xd10\x93-\xa56D2=p"\xba\xb2\x8f\xa5\xf6h\xd8\xb1\x04:Qck\xf0\x8fp}\x82,\xf0\x0ci\xa0D\x90\x976\x0eS6\xfd\xf0\xa1\xda\x98\xaf\xd0kO\xb9\x9e\xcfb\x1f\x8c-\'\xe3\x1f>d\xd5\'\xb0r"\xe4\x82M+\xb24*\'\x05S\xf0\x98\x90\x93g\x87\xa4qx\x8dEN\xeec\xad\xfcTK\xb5vD\x97\xbd@\xa2\x9a\x05cM4<\rT\xff\x00\x02\xe9\x88\xdb\xd4?^\xc2\x9f\xa4\xe3\x9eA\xf7\x9a\xd9\xb1\xc2\xc3L\x1a\xbe\xcb\'\x10e\xda\x0f\x92\xf91\xb0\x11O\t\xe81\x8d\x87\x8f0\xe34\xb7\x7f\x82+Z\xee\xb3\x10\xc5>\x11\xc7O\x18\x9b\r\xd9\xee7\x94!:65/T\x12\xf076\x13t\x16\xcb\x91P\x81\t\x0f\xc8p\x11\xc6-\xea\xdd\x1f>\x83\x82i\xa1\x05\xa8\xf9\r\xa3E\xf6& \xe3\x16\xca^\xd1\x0fOC=\x811\xc1y\x15\xd5\x84\x1ac]\x85\xc1\x89o\x02\xfb\x13bP\xbc\x85|3\x90\r\xf1\xa1\xea\xde\xc4\x9fc\xfes\xf9\xc5h^\xc2\nN\xc3\x7f\x07\xeaL\x9c?\xe7\x1f\xc9\xea\x1e;\xf3\xfd\xc75\xf3\xc7\xf3Y\xbf\xe0\xed\x17\xc3\x08\xfe\x9f\xfd\x1d\xe3\xf8I\x1c\xf7\xcfO\xe8\xee\x97\xcb\x9d\x82\xfeX\xe1~\xfd\x1c\x19\xf8A\x0f\xe0\x11F\xdc(\xf6\xd2\xf9g\xe8^\xa3\xf5\xa4\xfe\xf1\xf2?\xef\xf6s\x7fmY\xce\xfd\x86\xd9\xdf?\x86\x7f\x02\x9e\xef\x8f\xe8?h_\xd2q\xff\x00\xf3{\xb3\x83\xf8#G\x95\xf2\x90#\xcc\xf8G\xf3\xd2\xff\x00B|9-w>\xc3\xb2\x9f+\xfe\x0e\xde|\xd8\xed\xfd\xc0p\xaf\xe5?\x91O\xe8\x8f\xbb\xf8\x9f\xd09\xca\xaf\x8f\xea\x1a\xe6>\xdf\xd0Az\xf6D\xff\x00dP{\xda\x83S\xdf\xba\xfcQ\x1f?%\t8}\x1a\xa1\xfd!\x0cB\x97\xe8\\4c\xc4^eYx\x89\x8d\x18\xc3\r\x8b\xf0Q\x06\x99\xb1iRhF\xc3S\xb7\'\x0ft&=\xe8Rr"o\xab\xd8\x9fKh\xb5q\x06\x15\xcd\xe1\x9c\xc6\x9eM\x894\x12.\x11\x8f\xbe\x9a\xf6?S\x16\x9fa(\xf4\xfe\xc5\x04.UQ1\x7fq3S\xe55\xdc\xa8u\xef!l\xfb\xe0\xd9:}1\xbap-\xf66\xf9^\x8fU\x13\xae\xdf\x9c\x17x-\xf0\xcfcCn\xf1\xfc\xe8N\xdb\xfb\x06<3]CQ\xf2\xe8\x84\xf0\x1a\x16}\xc1~\xbfW\xfa\x17\x12\xc5\x7f\x06\x83\x8d\xf8K\xfd\x89\xf0+\xbb_%\xdeO\x85\x7f\x02\xbb/\x92\xe1\xd7\xcb?\xe0\xafO\xe6\xbf\x91\xc8\x83\xb1\xff\x00\x83\xd9\xfcR\xbf\xd8\xe7\xec\x7f\xa4\xe5\xbe\x01\x1f\xc7*\xfd\x87\xb9\x7f\xc3\x0fr\xf1c\x15\xc9\x91\xe5\xfeZx\xb1\xfdd?\xf0\xb3\xc3\xf7Y\xd89c\x84\xff\x00\xbf\x03\x1c\xd7\x1f\xa86v>\xcc\xee\x8a;\x14w8\xbf\xf5?\xa3\xf6 \x88\xf3\x93\xc9\xbf"\xe2\xb3\xf2k\xbe\x1113*\xfeB\xfe\\\xc3\x83Q^EW\x97\x81\xac\xf3\xdd\xeb\xfc!\xb2\xf9\xdb_\xc4\\)\xd9hK\x10\xbe\x9b\\\x04\x06,\'X\x15\xfd\x02e\xc4\xa3SQ,\xce\x98@\xf0\xbcs\xfb\xa3\x9c\x98\x85\x8e\x1b~\xe5X\xf5\xe1\x9a\x06\xd3\x18\xd7\xf2\\\r\x1a_\x9e\xe2\xba\xd7\xa1\xce\xf6wCK\xaa\x9ft\xcbJ\xbf\x02\xda\xe5\xfb,5\xfc\x86\xc3u/e\x9d\xd3\xf41%i\xbb\xa4F\xd0k\xe0Z4\xf6v2L\xe3\xf9\xd0\xe5U\xf5\xdd\x08\xd3\xad\x94Fr\xd9\xdb\x8f\xb9\x0fUm\xfd\xff\x00\xa1Fov\xbf\x91\x9e~\xf3b\xf1\xff\x00,I\xfe\xe6*\xf3}\x8e\x03\xf1E_\xfd\\\x08\xb6\x96\xabb\x7f\xdf\xb0\x85\xc1W\xfd\xf9\xfd\x04B(\xef\xfc\x1d\xbe\xe3\x9d\x95\xe1\xfc\xdc\x0f\xb3\xf9\xfa\x7fu\xa1*\x7f\xe2\xaf\xf25\x94\xbe\xe3\x12\xbe\x1ck\xecd\xd0\xcb\x85\xe7E\xed\x03\xc9C\x08\x9c\xf1X\xe3\x82g\xde/\xe7\x0c$*b\xf3B\xa3\xb1\xc8jV\x85\xe5\xe8I\x9b\xc6\xb3g\x86\xd0\x92\xbbw-\x92\xfdK2_\x7f\xec2\xd5\xdf\xe3\xff\x00\x02\\?\x91\x93_\xc5\xff\x00\xa2#\xd8\xdf\xfc\xa3\xfd\x87\x94\x9f\xd9\xb6\xf6\xc9%\xfc\r\x1au\xf9_\xd0\xb1\xed\xac\x89sSm-\xbd\xbd\x16\xe5\x00\xf3F\xf2\xdb\xfc\xb1/\x80\xf8?`\xbdg\x95\xff\x00(Yp\xbb\xf0\xff\x00D>\xd7cR3\xbb\xfc\x04\xfb\x0bT\xf8\x1a\x84\x93P\xd7\xc5\xfe\xa3I\'\xdc\xcb\xfb;\x98\xf2"]\xbcp\x85E\xf1\xe8\xe0\x0e\xaa\\\xd1\xa2\xee%\t\xee;\xd8*\x90%Le\xa3\\\x0c\xf0.\x87\xa5(\x99JR\x97\x13\xa2\x13\xe8"\r\xec\xd3\xd3\x86S\xe7\xe4\\\x81\xb5\xc2k\xd8\x9b\xaaMy\x13\xc3e\xf7*\xf85\xa3\xf6\x19$m\xe4h\xf6h\xed\xf8\x08Tj\xbc\x8aF\x9c~\r\xc2>\xb8BI\xca\xd1:r\xd6\xc5\xd90e\x8e.\xc1Aiy\xf6/%\xb6\xd49\x11pd\x1b4G\x9f\xfe\r\xe6\xf4Q\xf7\x96\xf9,\xd0QZ\x9c\xfd\x9e\xc8\x97\x0f\x82~\x9f.\xc2\x1c\x9a\xfc\xbf\xf4G%\xf2a\x1bO\x7f"\x86\xeb\xf5\xfeE\xfb~!\xfa\xb9\x1a4_\xa2\xfe\xb6\xc6<\xfe\xf5\x7f\x82\x00\x8e\xd9_\xc8\x84G\xe0\x8f\xf8\x1d\xc5\xe8\x84UKxMI%\xcb\xd0.S\xa9{\xdc\xa9~\xe0\xdf\xb2~F\xb7M\x88\xfd1\x93\xe9\xbf\x08%#Z\xf1\xb9\xa3g\x904\xb8\xf7\xee%l\xf9\xc6\x9e\x04}\xd0\xbdG\x86yXW\xb2\x9f\xc0\xc7\xbf\x0f\t(\xa4\xe6~P\xbb\xaa\x9f\xc1&\xad\tv\xfbCm?XKO\x9f\x83f\x97\xe0W\x95;$\xf88\xee\x90\x98\xf1e\xcf[\x17a%\xf6\x171\x7f:\x0e\xbdD\x0c\xfe\x0c\x1b\xb1?\x07b\xfa4}\xc1\xde\x85\x1bD*\xdaH\xdf\x84;\xd9=\x88\xad\xb1+\x17\x80^\x13\x8c\xc3\x83\x91\xa1\xf4$\xf1K\xd2\x08R\x97\xe8\xb5\xd0\x95\xa4\xedC\x07\x91\xbe\xc6\x8e\x1bO\xc6=\xb0\xdb6\xb84h\xe5\xc7#t\xd6\x8e[\xdb\xc6.\xa9\x06\xa3bY"\x9a\xda\xf7\x12]\xc1\xb1\x97\x81\rW\x0f\xc1"\xd8l\xa7\xf8/\x92\x80\xa5\xff\x00\x837\xa1\x9f\xc8\xb8\x1aK]\x91n\xa0\xecR/\xd2AzI\xb7\xc4\x10\xe5\x8b\xe0\x8fm\xf8-\xd9\x8d\x0b\x82+\x19\xa7\xc4\x11\xd8\x98\x8d=\x84\xf5\xe7\xe4\xaf\x1a\xf8!\xe1\x8bq\xa9i\x9a\xf2\'1V\xd9\xa1\xeb\xc8\xde\xd4\x8a\xf8\x18\x97\x05\xbe\xccm\xa9\x93\'"\x9f\x02\xec\x03\xca\xb1v\x98\xf2\xb1\xa3LC\xbc\xa7.\xcfq\x1e\x12\xefPN\xec)\xd8Q\x9c,\x94&t\xc2\xc5c\xb8\xd9\xdbQ\xdbE\xc0_F\x8d\x1d\xce\xf1\x8br\xe9\xf9\x12\xe2 \r\'\xad\x8c\x92`\xfd\x8c}\xf6!\x10\xcd\x08\xa2\xba\x16\xacR/\xa0\x17\x17\x0b\x10X)JR\xf5\xb1|06\x8d\xaaE\xf5\xf0Dp\x17\x90\x8b\xd0D\x17\x1d\xc9RL#dQ\xd8w\x13@\x955\x1bk\xe0P\x9a^\x89\x81\xf7\xd9\x1e\xe8&\xe5\x94\xbd,s\\\x9d\xa6\xb8\xbc\x87Q\x98\x940\xd4\x96\xbc2\xbem<\nqm\xc6\xd3\xb4\xf4\xff\x00\x03go\xe0J\xed\xfc\x1e\xbf\xe0K\xec\xfc\t\x1d\x9f\x83\xd4\xfc\x0b\x1bK\x01,\x12\xf0\x9e\xb3\xd5\x98=\x01x\x07\xa4zG\xa9\x1e\x84G\x84G\x84E\xe3\x13<\x94\x10\xecBGa)\xf4)\x03Gs\xbd\x1c\xb2\x1c \xfb\x89\xa6\x16\\\x17\xd9\xbbc\nL\xa0\xad\xae"\xc3M\xb3v$\xce\x04w\xc3O\r\xa2\x965\x8dtJ\\4<\xa10\xa2\tt\x97\xe83h\x176\xc5\xa0\x95\x12>l/\xb5\x8a\rJ\x96\x9c\xa8}\xa0\x91\xeb\xf20\x86\xe0\xa8\xd7\x02#\xed\xf9.1U!A\xf5 \xd16\xa9\xe8\x95\xed)\x8cT\xbc\x85\xa4)J_\xab\x08|\xb0\x84\'\xb2{\'\xd7\xa4y!\xdci\xee4\xf7;\x82;@\xe1W\xdc\xa7\xf6\x16\x038\xa6\x97\xd1\xfb\xcb\t\x8f\xbe\x06O\x9cm\xd8-\x03\x9e\xd0\x92_\x92eF\xdf\xd8\xe8\xe1C\x7f\xa1\xc3\xb2\xf9!\xff\x00!\x0e\x95\x9d\xfc\xa0\xc9\x9c\x15\xa7\x9d\x15}\n\xa2\xe2\x10\x84\x10\x88B\x12f\xf4\xb5\xc3\x99\xce\\\xe0&\xf0(Za\x16\x93/\xd8@\xaaC\xf4@4\x17\xca\x1f\xd84I\x91d\x8e\xd8\xa7\xb5\x1e\xd4PX\xf5\xe4\x19\xee/)\xed=\xe7\xb4\xf7\x9e\xd3\xde{\xcfy\xed\'\xdc\xf7\xe1\xf7\x8b\xceW\xbfP@\xf2D\x92I\x04\xf5QS\xd2\x01\x18\xa0h\x86\xa3\xf2\x08\xc2\x1d\xa2)\xc3o\xe1\x1f\xc1#\xf9\xc1\x9c\x12\x07\xc1_\x07)>\xe4<\x04\x1e\xb1\x1e\xc2Wa#\x01\x1e0?B<\r\x9d\x8eL!\xe2x@\xd6\xa4\xfeP\xd7d\x1d\xc8>\xa4W.\x07j\x9d\xd5\t\x85\x8a \x8a\xc0\x82\xcb\xa5\xca.\x1cd\xa4\xc6\x94\xb8\xb8s\x82\x06J\xc2\x18\\F\xe5\xa2\x88\xd0cAR\xd8\xe0J\x08g`3/\xc48\x87}\x85\xe6>\xc3\x0b\x98\x97\xf7\x1a\xe0\xbb\x85=\xd5\xafG\xb7\x1f\r\x11mo\xe0\xdb\xfc"\x9a\x81\xbf\xf1a\xa9\x7fA\xa6\xe5\xf08\xf1\xf8\x17\xe3\xf0\x16@C\x8f\xc8p\xa5\xd9\xa0}\xf0\x1d\xc2\x06y_\xa9\xff\x00\xdd\xc8>\xb1\x8b\xf4\r\xbd\xd6\x15\x02\xc1k\x1d%\x05\xe5DD\x08\xac1@j\xf01\xdcN\xeeS\xb8\xfc\x87v\x88\xfd\xc3\x1f?\x90\x9f-\xfd\xcf\xf9y\x93\xd0z\x87\xe3\x1a\xae\xc8~\x86\xa4HM\x15\x1a\xc2\x0c\xd0\x92\xa2\x0cL3\x90G<\x87\x1b\xa1\xeb\xb58\xdf\xb0<\xe5;\xbb\x0b\xbe\x1c\x08A\xc8\x91\xb2|\xe2\x8a\xcfAd\xa8\xc2\xe5tQ\x14\x13\xe4\xa0\x9d\xe8R\xfd\xe4Qg\x80B!\x8f\x10\x91\xef\x96#\t\x07>\x8a67\x94l\x16\x87DF>M\x9f\x16;d0z\xe4\xd5\xa2\xa2v\xe0\xd2,3\xa8m\t\xe8t\x8d\xb8V\xf8k\xb1\xdei\xe6\x17\xdc(%xy\\.(\\96\x85\x8a\x86\xba\n\x98R\xe1\xa40\xfaL\x904\x1d\x0eF\x12\t\x08\xc5\xbc\xd8\xb1\x83Y\x94hvC\xb6\x8a\xecx\x87xh\xee\xc7\xe2\x9fr\x1e\x0b\xa1\x8b&\x88~\xacU\xc9\xe7\x8cw*-\x82\x94\xa6\xc6\x81\xed\x0b\xc9Q\x12\x82z!v&\x18vSbQ\x89\x9e$\xf6-\x0f\xd0J:\x8b>\xce\xcbX\x13\x1d\xe89\x83Q\xb7\x83qT\x192\xe3\x02Pua\xa1\xa8\x86\xc8\xe0\x99\xcc\x08}\xca\x88bFN\x84\xc4\xc4P\xa2\r\xf8\x10\xe2=9\x0b\xa0\t\x1fF0\xeb\x14;\x8f\xdc\xf9\x88\xb4\xf2 V\n\xc0\xb8\x82\xb2\x97\x08\xb8\x98A\xac\x1a\x18G\x81\xab\xecvc\xc0v\x0f\xc1\xe6\x9e\\\xe7\x85>\xe2\xd8[\xee+\xc8\x91\xb2|\xe0Y-\x93\xc1\xb9\x82/LK\r\x90\xd4.X9\xc1R\xc7Y\xbb\x1211?#\x92\xe68\x1a\x08\xb7\x0b\x8aQN\x9b\x07\x8f\xc4\x90h\xb1\x8a\xf9\x8bB\xc6\xd2:\x1a\xf8\xc5\x0f\r\xa3EHhGA\x0cR1#a0\xa5\xc0\xd4\xf6\x88Ac\x921B\xd1`a\x1a \xc5\x97rF\x8b\x14\x98\xc0H\xe8\xa8\x8c7e`\x9c\x0e\xf0{\x0eF\xb10\xae\x14\xc3.\x0b\xa2\x80\xb1!\xe0\x13\xae\xe7\xbc\xf6\x8byA\xa6j\xc8\x88<\xca\'0LX\x9a\x8d\xc1\x14\x08\x1f\x14}\x87\xb1\xa6F"b\xc1\x8b\x11\x1d\x8d!\x92QX\x8a\xcf\x11Y\x11\xa1\x08\x84!\xc0\x99F\x86\x88L&"\xbc\x94\xe8\xc1\x05\x1e\x0f\xa0\xd1\xb6\x11\xd5\xcf\x80\xa4HE\xc2.n^\ra\x13\x06\x1e\x14\x1a\x1a.\x0b\xaa+\x86B\x0cS\x1c\xcbE\xa65\r]\xcfy\xef=\x87\xb8\xf6\x89<\x9f1{\x9f3F^\x80\xb5(\xd5\x1a\x19\x16+\xc1\x06v\xc9\t\xb1l\x86\xdd\x1f~\x98\t\x04\x98\xa5D\x12\x1289\x16! \x89\x84&\x133\xa44\xc8\xd6-\xd2\xa6\xe1\xa2f\x94\xa5\xc9G\x94\xc4\xca\\\x97-\x8d\xe1F\x94b2\xf0\x18\x83/Q\xa9p\xb0\xd5,Th\xc1\xb2-\x1bBd\'\x13y\x19\xe4g\x91?\x91{\x1f1\x89\xf4q\x81\x8fc\xc0\xa0U\xd09&\x10H\\\x1d\x0c2\xf1t\xc1\x86\x13\x82b\xcc\x82be\x10\xbe\x88i\xd2)a\x81\xa8\xf7\x16\xca \xa8F\xdcBf\x94\xa5/\xd3\x06\xf2f\xc1\x04\xe3/\x02( \xcbF44L&iE\x8a\\1\xaa2\x86-\xe3p\xd4Di\x95\x94\x8b>g\xcc\xb7\x16\x1c\x9ca\x08hh\x83\x0b3\xa2\x10Bdy\xc8\x10BpA1ep&&0\x85\x89\x84\xc8\xfa\xe0Bc\\c\xfc\x9e\xf2\xd8\x91E\x04j\xc3]\x01\xacR\xf5\x05\xc9F\xf0c]"\xe2\xb3)z!\x08Bf\x973,hh S\xc0\xe5\x85\xab\x05\x174\xb9Al\x83D\x1eT\xa5(\x85\x84\xe0\x85\xc3CC\xac\x12\x14O\ntX\x82BxB\t\x89\x9730\x9dm\rb\x8dD\xcff;t\x00\x82\xca`\xc4\xc5/\xd0\rp\xd1\x06\x86\x86\x87\xd2\xcb\x15\xc9z/].\x18\xc6\xc6\xc61\xec@\x96!\x9b\xca\xf0,V\xe11\xa2nYy\x97\x1d\xa5\xe3n\x8fHJM\xcd\xb3\xa0\xcc$r\x7f\xaf\xcd\xc3<\xf6\x14\xdb\x084\xd1 \xdb\xcbi& \x04$/\x98A\x14\xe1P\xf0\x9f1\x00+\xbd\xf2\x9a\xf3c}\xcfut7+\xe0\xc9\xa9&,4\x1f\xef\xa8\xaa\x8c}[\xd9)E\x80\x10\x04\x8e#$\x91$\xfbj!\x05^5\xa2`\xb9cKe\xe4\x14\xe0\x006\x11e\xad\x169Y\x1d\xcfH\xff\x00{JV\ti\x8c_!\x87Pcv\x93\x16\x90\x00\x82K\xfb_\xed{/\\\xb7\xe8\t\xfc[L\xae\xb3\xa2\xd5\xbc_"?\xd9\x00\x17gT6\x1c\x87\xc8\xa4\xe4\xc1\xb2\xd6\xa1\nj$\x12\xb5\x18)\x98O\x9b`\x02(\x97H\xaa\xbf\xb4\x92M\t=\x87\x01\x04\xb6\x81z0\xf1\xbf\xc1\xc44J\x87W%\'\x98\x99\x97m9\x89$i\xf2lbH\xf1\xfa\x18\x0f\xbd\xcc/\x8d2@?\x9d\xbd\xb7\x82I,\xa0\xc0I_#\x0cI-\xe9\xb7$\x0e\x85\xd3\xd6\x83\x8c\x10\xc8\xac\x19\xd2S\xcb\xbc\x9b\xa7\x07\xe8\xeeI\xc5\'P}\xce\xff\x00<\xd2\xcf\x10`W,\xd1T\xd8\x12\xd0\x00\x00\t\x82e\xd9\xde\xb3\x81 \xea\x1b\xf8 \x04\xee\x01\xbf\xd6DW\x89\xa0M\xdb\xc4mW\x89\xa9\xaf\xd9c0\x9f\x926\xc0$i\xb7\xed$\xda\xdbo/\x13f\xfb#r&+\x1f\xe6H\xc5W\xc9\x17\xc5_\xf7\xb7cppt\xde\x0f\x85"QY\x88=\xd9T\x7f\xbb\xe1\x00x\xd1\x1bzY\xe0I6\xcb=\x84\x89Y&\xfa\xa9\x83u~W\xe1\x1d\x86\xf0/\xa7\xea\x02\x1e\x91m\x8aW/\x00s\x9e\xd7c\x9bx\x8e(z\xa2\xa1\xe7\xcf\x8d4\x98\x9fz\xac\xd3%\xfd\xd3I\x07-\xb6Rc\xbat\x84Z\x1a\xc2Y_\x9a\xe28T!c\xa8rp\x80\xc9I\x97\xdfh\x8b\x1b\x7f\xe0\x05\x8e\x00\xa3_O\x8b\xe2\xd4\x1e>\x16\t\xf77\x10\x00\x00Kao\xdd\xb0#\tn.E|\t^2{qs\xb92085\xe6\xd8b\xaf\xfe\xaa\\\xcdy\xe5C\xc9"\x12\x9f\xa8\xabx)\x87\x16\x10\xe8\xbd@\x10[r\x00\x0b\x0f\rm`\xd4\xdd\x92\xec\x19\x8d@\x95{\xb1E\xc6"\x9c\x8f\xdbm<&\xff\x00\xeb\x97r\x16\x04L\x9aX\xd6\xb8h4\x12\xc2\x1a\xef\x80&\xc4\x00Q\x86\xb2R:\xd6\xdb~I\x18\x8c\x97\xb4y\x04\xf6\x9er\x16\xda\xd4\xb0\xca\xed\xb9\xc7l\xd3x>\xf9\xa6Q\x02\r\x81\xc6\xc6\xc9M\x9e\xd9\x91\x9a\xeb\xda\x9acAD\xa7\xb4\x93\xb6\x90\xc0\xb9\xce+\x8c\xbeV\'\xd1xs\x07\x92\xd8\xb2L\x95\x07\xed4\xc7m\xe2\xac\xa9n0(W\xd9\xbb\xef\x88)P\xc2\xda~\xe7\x89\x95q\x01\x82\xf6\xb6\xdf\xfc\x93|\x17\xb7\x9fq\xf6\xed\xeaQ\xac\x1d\x9b\x9bH\x13\x9c\x97\xf8\xe5\xd5g\x16\xc1\xa1\xc6\xc1!$\n\xe3F\xd2&\xfa\xb2\x01\xb2\x81\xb3\x1dBv\xb9\x95&\xc0\x83\x7f\x9f\'\xff\x00\xfb\xfb{r\xfe\x1d=\x05.\xcc\x11f\xda\xff\x00\xed\xe71\xb7\x92\xa7\xa9o\xa9\n\xadL@\xa8\xf0\xa8\xf8\xd8\xf5_\xb2\xa9\x02h<\xcb\xde\x9a\xbb\xc2C\xceM\xdd\xdbm\xff\x00\xff\x00\xfe\xb6\xbe\xab\xfa\x9fY4\x9a_\xdeD\x96\xb1"\xc5\x05\x97\xefZ\xb3(\x9a\xdd\x0be3\x0cA\x8f\xe7\x80M\xf3)\x92\x87\xbe9Cx\x85j6\xea\x7f\xf3J0\xbf\xf5)R&4\x8c\x87\x1e.cpd\x1f\xfa\xbc\xd2\x04\x0f\x945\xfa\xa5-\xf9\xefb?0\x90\xe5Z\x99q\n\x95\xe0\xde]I\xfeEL?u\xf6\x0c\x96\xaa[M\xb9\xdf\xcc*wb\xdb\x02V\x12\xc7\x7f\xa7\xc8\xb3"\xcd\xc3g6))\xcd\xf8H0\x88\xde\xdc\xf5\xe0\xfa\xe0o\x96\xe9\xf6\x94vy\xfd\xf3\xf3\xdf\xd0\xf6M\xbe\x88py\xe9\xd0\x06\xa6\xb0\xe0@|\xe9b\x81\xf93-=\xfc\xe4\x970}\x9dTw\xbd\x10T\x17\xde\x19\x9b\xbe(\xb2\xec\xcf\xcb\xe5\xf0\xc7*\x19\x1f\xcd\x0b\x16\x96\xff\x00\xf3&\xb7Id9N\xf1jR\x8e\x91xx\x8ef\xdb\xff\x00\xb0(u\xd2\xfc\xad<\xd5\xca\xaf\xa0\x02\x15\x9a\xfc&R\xc9\xe5\x86r\x01=@\xa9\x8f1\x96\xdf\x80\x14\xb97\x07\xf3\x9b^LS\xe6!D\xa3V>\x92{=\xd86\xff\x00\xbb\xd3U\x9f\xbc\x03_Zf\x92R3\x0c\x1c\x8b7\xe7a\xc2\xfa\xb4C\x14\xb9\xe3=\xac\x14 K\x95\x13\xd76\xdf\x87\x10t\x96a\xb1\xda\x8e\x81\xba\xd0\x116\xff\x00\xfb\x9f\xaa0j<\x92x\x83*\x7f\x08\xf5\x96\xda\xcf;xr\x93\xc0Md\xda6\x02HEZ\x8cj\xc6Y-Yb\xa1\xd04\xed\xde1\xf6\xb3\xda_\x19\x08\x16\x0f:y\x8e\xb8\xc4S}\xe1\xf6%z\xd6\x0fa\x98U\xae\xf1^\x97Z\x0c\xc9%\x138\xd0\xb2\x00\xb8\xe7\xa4\xe7<\xfd-\xad\xa2\xb6\x0c\x8b\x8a\x1aW\xc6N\xb5\x97#%\xdbK\'\xdc/\xbdW\x942\x06\xc1\xd8\x88\xbeQ\x89\xc7H\x99Kl\x9b\x19\xd7\xfe\xd5\x85\x90\xdfiMh\xbd\xbc\x13\xf6m\xa8\xd1O_M\x187\xd2\xb7\xd9\x94},\x9c\x15N\x0f3\x97N\xee\xed5D\xea\xd1\xd6\xa7n\xa1\x15\x9d\x95\xbe\xcb\x00\xd2$\xf2\x83y\x0f{IG\x1fA\x05<\x1c\xbf\xe3_6W\xf5\x84\xb9\xcf\xaf\x14\xef\x11>V\xa2\xcdL\x91\x0b\x19\xaa\xc2\x02\xbeNFh\xc7\xe3\xac\xb8w\xf6OS1R\xae\x83NSl\x9ey\xf7!\x8eH\x8eS7\x8bL\xa58\xea\xf3_\xe8\xd9\xe6Y:\xa9\xab\x10:\x8dM\xed[>\x93C\xfe\x87\xc6\xdbXna\xc3\x89\x1b.2\x14\x87\x9a\xe6\x9cn\xbfR\xc8+\xfeW\xdcN\x16z\xe5FC>\x844\xd9J\x14\xda\x0fh\xd3+\xab\xa2\xa8\xd1\xa0\xa8\x0bh\xf5\x01\xd6\xe4\x98\n\xddI\xfa\xfd\xd7\xe7\x82/T\xddV\x87\x15\x86\x0e\xb6\xeck1\x94~2^C\xee\xcf\xfc#aG\x10z\xdf-\x92<7\x96QS\x9df\xe7 `c\xfcA\xe9bC@\xf0\\\xc1\x02\xd6?\xb37{\'\xf1\xbc!\xe6\xa8<\x06\xbf\xdb\xe4\xa1iAg\x8b\xbfq\x0b\xbb\x0c\x0ftDp\xabc\x02K\x07\xb8H\x1b\x90\x8c\xc4\x05\xc5\xe3\xf3\xd6H\xd3\x99}\x98\xf2\xbb\x08\x85cT\xe0\xf7\x9fW\xc3\x0f@\xa6@\xaa\xd3\xd94\x03oEjNW\x82R"5t\xd3ls\xd7\x82\xc5\xd7\xa2\xee\xda\xe4\xaa\xbeJ\xe9\xb9!\xa6\x9f\x92\x19C\x97\xf9e\x8d\xa7\xa9\r9c\xde\x82\x9c\xb3R\xc8\xad4\x05\xc8\x02kV\xd7\x0b\x1b\x8e\xf9\xec$\xa8\xe4\xbb\x8e\xbb\xa2\xd3\'}\xe0\xfc\x18\xf3~r\x14\xe8dJT\x87\xa8\x88\x89\x88\x8e\xdfb\xe1\xa3Z\xb4\xa7\x08\xae\xf5\x9c\x1b\t\xe5|T\xb1\xec\xa6(\xb7\x05(7\xaejYQ\'\xbfn\xc9\xbfn\x9c\xdcwt9\xa1\xe3_\xcf\x91\xef\xeb\x8a\x80\xce+\xae\x8a\xc7\xe7\xab\xa0X}\x8e?-\x037\x18\xfd\xdf\x8e\xbd\xef\xeb\xf82\xe2*r\xf0M\x11\xb5\xdf\xbe\xe7\xe1\x8aH\xdd\xbc\xe1\x0e\xc95\x98\xa3\x1d\xbb(wt"\xc3\x13\xad\xfd\xbc\xa8V#\xaf^mH}\x96\x82\xafU\xaf\x85\xf5\xf2\xc1\x81\xf6$.\t\xf8i|\xfc<\xaadtg>x\xcc>\xe6\x96?\xcb\xbd\x83\xac5\x07\xd80\xa8\xddc\x0c\\\xa399\r+y.\x8a\xeb/<\xdem\x0bbz\xbaL*\x8b@\xc2\xfd\xc4\x83\x97\xdc\x9f\x93\xe1\xf6\x8foQ`\x1e\xaf\x84~\x19\xcd\x0c\xdb\xc8\xa0\xa2J\xb4t\x8b)\x0bd\x99:\xd8\xe8\xccd]Go\t\n\x11s\x92\xf0\xd6\xc6\x86\x98_&\xe8\xadG\xd9\xea\xfc6S=\'\xd8\x98\x84\x891\xb1\xd8\xa9\x81\xd7>\xcf\xa7\xe0\x9a\xe4\x0b\xb9-\x83\xfe\xc9\xd5\xe3\x8a&\xc5\xdfR\x89\xe2\x043\t\x95\x18\xbb\xcb\x811\xf2\xa1\xa5\x9b-c_%,~\xc6|*#\x1fq\x88\xca\xea,)\xa1\xcd\x8br\xde\xaaP:\xadd\r\xddaHno\xad:\xf8\xfa\xa1m\x07\xa7\xd37Xr\x8c>/\xb0;\xf0e\xa1\xb4C\x04\x95\x03T\xa1\x8f\x9d\x1b\xfb\x8fRM\xaf\xa7vOmW!\xb7\x1e\xe20\x08\x9e\x8bS\x13\x82\xd6\xc7\x00\x1d\xeb\xb5\xefb\x88OW\x16M\x16\x8e\xc6\xcd\x1e*.r\xdf\x04w\xb2-\x1f\xf8\xbcD\xfd@\xfb\xdfJ\xfe\x80\xf3o\xe3\xe4G\x18\xab?\x9c\xe3=\xf9\xb0\xaa\xe9\x10:\x87!u-\x1fuwAy\xe5\xd8\x14#\x0c\xb3K\x07%\x1c}\x1bo\xff\x00\x9a\xce\xc1\xd9\xcf\x9fL\xeev\x817\xf3\xd7\xfc\xd9\x7f\xff\x00\x82W\x13\xde3\xdd\xe2*\x19\x9d\xe9d\x94\xdck2\xe0 <\xa1XRxX*\xf0s\xdf\x7f\xff\x00\xfb]*\x99\xd8\xed\x07\xcf\xed\xf7\xda\xc9r\xba\x04\xe9\xf9:\x98\x8dqvh\xce\xb3c\xack\xc3g\xfe\x1c\xba%\xc73\xf4\xb6\x0e\x7f_z\x1b\xde}Q\xf9l\xde\xfc\x91j\x07`\xbbl)o\x19 \xa9\x05-\xbeI\n\xc3\xdf\xf6\xf0\xa9Y\xb8\x158\xf7\xb7\x17Y\xaeFy\x0f\x1f\xc13\x03\xc2Y\xd0B[\xa1\xca\xdf\xff\x00\xf6\xd3\xa4\xab\xdd\xda\xe1\x89\xb4Z^b\xb4\x8d\x08\xd3U\xdc\xff\x00\xef\xd3\x05\xa8\xdb,js\x81\xf8^\x8d\xb9|\xff\x00\x17N\xaa}\x96\x93r1sk\xad\xbf\xff\x00\xfc\x03\t(\xc5EQ\x9c\xbb\xbbKu\x17\x1d\xf9\xeb\xaaB\xa6\xfeNB:\xa2\xf2M\xe7N*\x83H\x19y\x10\x1e;\x14\x01^\xbc\x01C\xa9\x80\xc6\xec\xba\x0e?\xe1\rT\xbe\x98\x1d\x85\x0f7\xbc1h\n\xa2\n:\x9c\xb9l\x89\x13!>+\xa1~\x9a\xe9\x89\xf5\x01O\x1dK\x85%\\\xe9C\xab<\\9\x84\xfa\x9b\xa2(W\x97~\x88\xe9\xd8\x07Gyo\x9a\xf8\xf6j=R\xdbi\xf3\xc6\x88s\xab\xcc\xf5\xc5Q\x9b\x90N\xbe\xfe\xa1\xb2\x9e\xbbf\x9b\xd7\xa6}\x870\xf6N\x94\xd9Cv\x98N\xe3\xf3u\xa2D\xc2\x13\x16mu\xdc\x04\xde\xe6\xae\xd4\xb9\x0fA\x94\x16_\xef\xaa\xb7\xe0AJ{eB\xc2\xe7Z\xbc"\xac\x85\xe0\xe0H>$\xe7\xfd\xf7\x96CT\xcd\x993\xed\x87\x89\xb4\xcb\xae\xbdK\'0\x8d#P\xb5$zvh\xd7\xafB\x1fYYO\x0ecD\xcf\x08\xdc-UK\xa6\xbb\xa6\x1fG\xc7>\xbc\xb6\xcf6\xfd\xf8P\xd8\x8e>\xdc\xf9;\xda\xdc\xe6s4\x9e\xd8\x11\xa3j\xf2SI\x0c\xed\x7f\xd1\xc1;\xe8\x940\xb7v\xd0\x9e\xdc\xd4\xca\xee?c\x8e\\c\x82\xc5\xa1&\xf2}=\xb4\x9b2\xf3\xcb\n\x82\xd54\x91\xdfx\xdeVyc\x7f\xb4\x03>%\xb0\x0c\xd4\x7f\x93\xb7!Qb\xb4{\x16\xc8\x94\x94a\xb3\xf4\xf2J\x93S\x98\xf1\xec\xaf\xbc\x8f\xb1\xa5\xa8\x05\xda\x9d\\\xa8e\xfb\x10\x91n\xe9\xefei\x81\x1d-\xa5l\x06bi\xebF\xefX\xd1#c\x9a\xf0\xe6]\x7fXK\xa7\x01\x8fC\xe4j\x88W\xb9\x99\xfe\x8d \x97\x92m\xb7v\xc0Q \x02[\x80\xa9\xd7\xfe\x0bwY\xfc\xb4\x18)\x94\xdc\x02\xe8L\x11\x19\x1ao9\xda\xdb\x85s\xfb6\xaf\xc1\x0c\xd5\x8d\xdb\xa3\x11\'\xdf\x7f\xa4\xf3r\xbd\xd82B\x13d\xd3d\x12\x94Z4\x11\x95^\xeb\xc2\xce\x88\xaa\xd9\xd1:\xc9\x16\x94\x1f\'\xe6\xdc\xa0~\x14<\xdb\xdeUq\xe2\xbf\xde\x04\xf4\x04[\x1b,\xce`\xcbmN\xec\x04U\x9d\x8c\x19\xd6#\x04\xfd\xe0H\xc4,\x03\xb5\xd1\xf1\x1cbS\xbd\xa9\xf3\x9e\xac\x04^\xee\x8f\xdcl\x96\xf2-Hk\x90\xd5\x7f\xfcn\xd3$\xd1\x0eZ\xaf\x94L\xca-\x05|\x0f\x02\x15\xb2\xff\x00V]tY\x149K^b\xe3mm\xcc\x9e,\xe9\x82^\xb2\x81=\xebX\xa1\'\xe3!\xd1\xdaY\x88\xc8\x07\x8d\xcf\xae\x88O7\x0fL\x10ZT\x8fXJ\xcb,/~\x88\xaf[\x96@\xdd\xe6\x19 "\x8b\xb0\xc4\xbc\xa12l\x94\\A\x1b\x19\x8f\x0e4\xa2tJ&\xd2f\xc9;\xdf\x12L]\xdb\xdfm\x9cc\xcc\xc7(\x84~\\bU\xf3{\xc7\x07,\xa1\t"\x10\xaa\xdbq\x0e\xf5\x07Sx\xe7\xae\xc4\xec\x17\t\'\xbc\x15\x03*\xf3C\x91e\xbf\x86\xdd\xf3\x0f\xbe\xbb)vZ\xd0\x18(N@\xd1\xaf\x85N\x99\x04\xab\xfc$\xf3@\xa9N\x06.\xb1\xecq\x9f\x81/\xabP\x946\x04\xe61\xfa\xcf\xe7\x87/\x82}o2\x1dl\xbem\xa0\x04\xac\xae\xb4|A__"\xb1\xc4\xae\xe6\xdd+\x0b_l\xdb\x13\x19<\xa2rS;\x12\x86\x96\xf7\x02\xaa\xd8\xa3\x1av\xff\x00\xde\xce{\x8c\xf6d:\x91\xf6[f\xc4\x08a\x91\xc9%1\xf0Bk\x1c\xbb\x9e6\xc9\x0c\x96/\xcd)\xa4\n\xd0\xa5M\xd1\x99p\xf5l\xf6\xb5\xeb\xa31\x97\x1b\\\xe52I\x7f/\xd5\x00\xf4\xbev\x9e\xed#yIc\xf5]\xed\xcd\xf7\xa2\xe0\xed\xe8u\xcf\xe2f\xe2w5q\xbb2\xc4\xe6\xd3c\xff\xc4\x00)\x11\x00\x03\x01\x00\x02\x02\x02\x01\x03\x05\x01\x01\x01\x00\x00\x00\x00\x01\x11\x10 !1AQa0q\x91\xf0@\x81\xa1\xb1\xd1\xe1\xc1\xf1\xff\xda\x00\x08\x01\x03\x01\x01?\x10\xec\x1dpn\xce\x87g\xa1\x10^{<\x84\'\xe8\xa3}\xf1\xa9\xe3\x95\xa2bg\x9c\xf5\xa4\x96F\xeca\xc4\xfb\xc3A\xba<1\xba<\x8b\xd0\xbdrf"bD\xc5\xa8Z\xb2\xf1|o\xe0|\xd8\xc6<\xb64}g\xd6yQ\xb5\xd1\x14\xc6\x84\x1f\x8a\x8f v\n*\x887cc\xc2;\x12& \xa5\xf0\xe0\xbf\xab\x1d\xb3\xbd\xb1\x86\x18Lb\xe4Qz\xd9\xab<0\x92\x82\x1fC\t\x94y\x04Aa\xe3\x9f2\xf6!\rcq\x0ffPk\xba\xf3\x85\x10\xf0=\x8f\xd1\xd1\x97\x04\x14h\x84\xc9\xa9\x0b&"b\x17\xf4\xedc\xe0\xf6]\xc5\xc5y\x1d.\x90\xfb\x1a\x1a\x17\x88\x86\xf9\x1d\xbb/\x801\xf9\x18\xd9q\xfc\x89Rg\xb2mAOo\xfd\r\x97\xd7\xb6$\x10\xc3\x0c0\xd8\x80H\x86)"\xe7v"\x08\xa5\xf9\x1a\x0b\xb6P,\x12\x0b\xb4!cE\xd3H\x9abhZ\xfc\x10n\x0c7G\x96\xad\xd8\x87\xa8\xddc\xde\xcf\xa3\xb0N\xc5\x1a\x12\xc9\xb3V.K\x9d/\x17\xc5\xf1\x9c\xda,\xca\x08]\xb3\xaf\x01\xf7\x91\xbf\x18\x8b^q\x1a \xdb~F\xc6\xc6\xc6\xf2\xe2Z\xc4\x85\x17H\xe8^\x90\xbc\xc2\xa9\x0b\xae\x86\x18a12\x95\xc1\xf7\x9d]\rl\xfb\t\x8b\xae\xde\xc1B\xafz\x92\x9fq\xd8$k\xb4\x9a\'\xd0\xbb\x1ff$C\x12,Q\xe0Qy\xa8eF\x1cd)vX8\xdd\x8d\x9d\x82v(\xf1"bD\xe4\xb6b\xc4M\x9f\xd1\xde\x0c\x87v\x8cN\xc4{\x16\x88\xf2\x1fQ\xdb\xf27\x06\x1b\x1b<\x8d\x8d\xdd[\xd8A\t\xd1A\xf6\xc5\xa8Q|\x7f?\xe0\x98\xc3\x0c0\xc5\x1c\x85i\x08\xc6\xb1\xb3cWbq8\xdck\xc0\xb4\x1aFj\x9eN\x04\x06M\x0f\xect\x1b\xe4Y[xc\xfb\x90+\x95::\xf1\xae=\xa4U<"\xca\xe5G\xa4\xba;\xb0\x82v4$LK\x9a\xd5\x8b\xf0?\xe8\x1f4\xa9B\x08E8\xb9\xe4\'~\x0f\xd7\x1b\x1b\x1b\x18\xdc\x1b\xa7z\x85\x88A\x04>\x98Cu\x05\xf4T1hLa\x86\x18\xa4\x84Xb\x04\x82B>\x08$\x82D\xa4\x9d\xc6\xa4x:\xe7\xb0\x87\x8b\nGS\xf4\x8e\xc7e\x8d\x1e\xc6\xb1\xc3\xcb\x18\xba\x89b\xe2\xa8\x80\x90\xdc{\x1e\xd8\xd1\xeb`\x83\xf3\x90\x98\xb6d\xe0\xbf\r\xe6\xf9^\x0f\x1f\x15\x16A\xe2Gc\xd8\x1b\xbf\x07^\xc4\xd1F(\xfd1\xb87yR\x89\xe1s\x03\xe6\xff\x00\xd7b\xeb\xb2\xd3\xdb\x12(\x84!1\x0cRc@\xc1\xe3\xb5\x8c\x1eT\x10O\xc9\x1f$|\x89~H\xf9 Y\x1f\'\x1f*{>\xd2\x9e\xcf\xbc\xfb\x07\xf3\r\xa3L6d\xd7\xd0\xd5\x9d\x94H\x84\x88H$CBYBB*\x0cv\x1a!8M\x99?\xab\xf3\xaf\x92v!\xe2\x87\xd2BS\xb1\xb2\x89\x94\xa3e(\xde\xbde)\xd8\xf3\xcf\x81O\x8a\xbf\xd9}\x13/?\xcf\xf8$PB\xc2ci\xbf#FM\x9e\xc6M\x83\xf9\x8f\xb0\xfb\x07\xf3\x17\xf2}\xc7\xdc}\xc7\xd8}\xdf\x8a L&/\xe4O\xf2~\xb3\xf5\x0b\xec/\xb8\xbe\xe2\xfbe(\x89\x02\xf5\t\xde\xca{\x1a2\x05\xae\x8a\xe4\xf4z\xb2q\x99?\xae{\t\x82\x1epj\x88LlO.\x1b)v\x94g\x81\x94\xa3\xe1z;\x82>\xcb\xfdt$\x11w\xe1\xfc\xff\x00\xe0\x84!a\x84\xca66\xc6\xd9YYYYYJVR\x95\xf0\x15\x8b\xfc<\x14P\x9cNX\x9cN\'\x13\x89\x84\xdf"a?\xc8\x9f\xe4_0\x8b\xd27^\xccY9\xf8\xfe\xa9\x89\xf0\x98\x84\x10\xf3\x13\xad\xb2\x8cN\x14o\x1b\x19J]o[<\xb2\x9d\x0b\xd1W\xe2\x86\xbd\x8fM\xfdw\xad\xa1\x08B\xe8L\xa3X\xc7\xcd\xfeK\x9d\x89(3\x02\x18\x85\xc1\x08B\x13.R\x97\x8a\xe2\xb8\xaf\xea\xe9\xe7\'\x08\'{[\x85>\x8aQ\x14\xa3czO)F\xc6\xc6\xc5\xe7*(\xba;\xbf\xbcQ\xa8}\x8b\x12Z\x84!i\x8cc\xd7\xaf\x95\xd8"\x8a\xc0\xff\x00\x048\xf05\xae\xd0\x83\xc0\xd7\x8f\x16\xa6-X\x9e,B\x7f\x97\xc7\t\xc2\x8c\xa5\xfc\x14\xa5/\x0b\xcae\xc7\x97\x17\xb12\xdd\xa4R\xe2\x10\xc6\xc67\xa9\x8f\xbc7F\xca!\xdc.Mt~\x84\xe8J\xe1<\xf5\xfc\xff\x00\xa7\xbdB\x10\x85\x8cle\xc6=z\xf210\x8a+#\xde\x11\xeb\x86\xbf+<\x97\xeb\x1b\xa7D\x18O\xac\x97\x91\x0bP\xb5b\x17\xe4\x9f\x92\xe3e)J_\xcd8L]\x8ax\x9d\x98\xddb\xf1l\xa5\x0b\xde]ccb}\x88(\x9de\xe2\xacc\x1b\xdfb\xd5|CX\x84,c\x18\xf1\xb1\xbeR\x88\xac\xd0L\xf07\x1e\xf2\x8e\xfc%:GJ\x87tC\xcb\xb3\xb9\xdc\x88\x83.\xec!\x0bf!b\x16.S\x12\xfc7\r\xd2\xbc\xa5\xe5F\xdb\xef\x15\x8a\xb8\xde\x14\xf3\xc8\xbd\x1e#{\x1b\xd9JQ3\xce@o\xe0\xa3b(\xd8\xc3c\x16\x10\xf0\xcd\xc2\xfd?q\x11\xfa|y\x9fo\x82\x10\x84"\x8cc\xc61\xe5\xe1B\x83\xba$z!\xaf\xca<\xc6O\x86\x1a\x0fkce*\x17\xa2\xd3"\xb1\x11\x13\xeb(,H\x9b8.\x0bW\xe1\xa5|h\xcaR\x94\xa5)r\xf0\xacL$\x7f\x91{\x17\xac7\x1c\xf0\xc4-ll\xa2\xc41\xb1\x8d\x8d\xe12Q:?R\xf7\x8aI\xec\x82\x88LX\x84,c\x1f\x17\xc1\t\x82\x90\x17\x86\xb0\x9a\xfaG\x90g`\xd4o=\x8d\x8c#\x0cC$\xce\xd7A\td\xc4\xb5\x13\x16O\xc7J\xf8R\xe5/\xe4\xa5\xe0\x9b_\x8c\x99^\x06\xe9!8&&Q\r\x8d\x8d\x94Z\xc6\xc66\\\xa8\x82\x1e\x11H\xbe\x12\x19D\x7f\x9f\xcaOC\xd5\x88B\xc61\x8c|\xd6{\xb4t&V\xd3./\xb1\xd1\xaf\x91\x07\x8co\x83\xb2\x16\x13&\xf2\x84\xd4\x8b\x8f)\x1eB\x13\x82\xc9\xf9\x1b-\xdb\xc1\x94\xa3e)~\nR\x89\xc2\x94\xa5\\n\xa6\xd0\xbeBw\x9a\x1ega\xef\x01\x14lb\x89\x08C\x18\xa3\r\xe9{\xc2\x1d\x11\x7f\xe4\xc4\xa8W\xfa\xfe\x7f\xcd\x84\x10\x84!c\x18\xc6prM3\xc25$^\x12\x0e\x8dQ\xa1\xfd\x8d\x0ck\xb1\xf5\xbf\xb4\xf0*8\xf4\xf1\x84\xd9\xb3\x84\xe2\xdf{n7\x06\xf6\xed)JR\x94\xa5)r\x94\xa5\xe3u1>)\x9eYc\xa8\x8a!<\xa5\x1b\x13\x13\xc5\xca\x97\xa2\xe9\x04\xdb\xd1@.\xdc"\x1e?\x8f\xfec\x1a\x1a\xe4\xb1\xb1\xe3\xe0\xf8\xa3\xa6*\x11"\x87\xa8Hy\x1b\x8c\x9d\xc1\x13\x10}\x8d\xde;\xbc\xcb\xe0\xc9K&Bb!96=ln\xf1\xa3e\xdaR\x94\xa5)J\x8b\xc5\xb8C\xc6BpZ\xf1\xb1\xeb\xe4\x84\xc6\x18\xaa\x9e\x01J\x10\xecy\xe4\x83BM\xbeC\xf2"\x82wI\xb2\x9e\x94\xd4y8>-\xe3\xd6\xf9\xb67\xad\x97\xf2\xde5\x89\xeawnx\x08^\x0e\xec\xa3D \xfa(Ep\x83\x14\xa7c\xb6\x10S\xc4h6\xe0\xf5\x1f\x93\xe2\xbf?\xcf\xf3\x8cx\xc6\x88Aqo\x8b\xd7\xc111\x86.\x16\x16B\xb4\xc7\xf26X61x\xfa\rvz\x82\x8dP\x88\'\xb2\x99.w\x1b\xe0\xc6\xbbq\xe3cwo&\xcaR\x94\xa8\xbc\xa9v\x97\x13\xd4\xf4L~II\xb2\x8c4X&Z\xa8\x9fx\xc5\x12\x89\x95\x8f\x0cj\xa2\xf9>>O\xe7\xfbc\xc7\xadp\x82D\x1b\x1f\x17\x8f\x8d\x13\x13\x1bPj\x07\xa2\xbf\x05\x1b,\x1cn1\xbc7\xc2\x83\xa1\x08|\x05\x1a\x82\x1e>-\xdco\x1b\x85\xc7\x8f\x1b\x1b\xcb\xc6\x97\xf0\xd2\xb2\x95\x17\x85\xe0\xa5\x13\xbaLLG\x8c\xf6M\x94a\x8b\x06\xc2\xc2\x82tA8f\x1e&8\x96\xb1\xe6\xfd\xbf\x9f\xecx\xf8N\x0b\x1b\xca]x\xf1\xe5\xc4\xc4\xc6\x18\x8b\x98\xd4BT\x1b\xbeK\xe8lo\xd8\xe3\x0c6Q\xb2\xeb"3;a1\xf0x\xd9u\xb87x7\xf0\xd2\xf1\xa5\xdb\xa9\xdc!<\xba\x9c\xd8B\x10t2\xf0>\x87\xed>\x8b\xa4A\x0f\xb1\xaa\x8fo\xf9\xfe\xc5\xd2\x83C\xc7\xf8 \xc6\xff\x00\x03\xe4\x84\xc4\xc7*\'/\x91h\xd8\xc6\xef\x0c~F1\xb1\x8c\xd94H\x99\xe5t5\xa7\xde\\u\xaco\x8cx\xf1\x8d\xf1c\xd7\xfd\x05\xe0\x98\x9f2\xeao\x10\xb1\x10\x84!\t\xa9\xd4h\'\x1a\xe5oE\x98\xc9^\x89\xfc\xff\x00\xe61\xeb\x1a!5,|\xa8\xff\x00\x11\x87$\xe9e\x18\xb7\xb2\xccu\r\xfc\x1d\x18c\x14}\r\x8f\xc5\x18\xfa\x19&Q\x0f\x81\xe3N\r\xc5\xcd\xe2\xef\x81\xfe\nR\x97nR\x97\x82\xd5\xc1G\x1a!1\x08LLO\x10\xb8B\r\x13ON\xce\xde\xc7$!\xe5j\x1a\xb3\xa0\xfb\x89\xcf\xe7\xf8\x1e1\xa2d\x19\x08%\xc2\xf3}\xed\xe2\x86\xc3\x91\x10\xab\x16\x12\xaa\x1fA\xb1\xb1\xb1\x87\xe4\xf2?\x91\xbe\xb5\x07D\x15\xa8A\xf9\xc7\x8d^\xb1\xbb\x8f\x1b\xc6\xfa\xd7\xb7\x95(\xdd\xe5x\xd6\'\x8b\xb7S\x82\xd2\xc2b\x13\xc4Q14^\x12\xe7\x83\xb1\xd4]\xe5\xd5vDg\xb3\xb0\xc6>\x97DW\xf3\xd9\x08A\xa2\x10\x84&\xc22\xed/+\x97V!\x86 \xc9\xa3\x16\xa2\xc9\x9d\x90\xa3\x1d\x1b\x1d\x1fN\x8cc\xc6\x87XT}\x88<}c\xc6\xf2\xe3x\xdc\x1b\xbc\x1b\x83|\xa9xQ\xb2\x94\xa5./\x1a\x8f#(\x9e&.\xf0\x9e\x13\xe2\xb5\t\xeas*\x1a\x1d\xd1\\\xfa\x10\xa6\x88N\x063\xe4\xb4\xfe~\xc4t\xf4\x86,\xa2\x8a+\x15\xc4ko\xe5X\x86\x18r\xc1\x02\xa8\x83\xa5\x1f\xa1\xe0e\x18\xdfx\xfc\xeb\xbc(\x93\x13\xaaa\x8d\xc1\xbd~1\xe3x\xdc\x1fo\x1e\xb7\xaf\x83yJQ\xfe\x14\xe0\x99v\xc2\xa7\xab\x80\xb8\xa5(\x9d\xc4\xf1btC\x11F\xe0\xdd\x1a\xa2\x08A\x9d\x18\xfaD7b\xbf@\xbb\xfe~\xc7\x88\xf9 \xc7}/\x1f\xa3\x1b\xd7\xc6\xf0X\xb8\x18n\xc8\x1d@\xb5\x16]\x96\x066?\xb1\x8fLxd \xba\x1fhz\xb9n\xd87\x8d\xf1on\xbd\xa3|\xa9r\xf2\xa2wS\xd4\xf1D\xc4\xca\'q12\x89\xc1:\'D\'\x0bF6\x89\x9d\xcbbt$JA(n\xc6\x1f\xe9R\x7f?g\x96_\x0f\x7f\x83\xc1\xfet!!\x0c0\xc5\x05HU2}1\xf9\x1fcc\xecxc\x1b\xc3\x8bv\x125\xd0\xf0\xf1\xb9\x97\x1e?\x1a\xf1\xbdz\xd9\xe3\x1b\x1b\xbco:^)\xeaz\x9c\x16)V\xa6,B\xca\\Z8\xce\xd9\x05\x84\xb1\x0b\xdd\x1b\xec[\x9b\xd2\xa3[\xec\xb7\xfc\xfe\xf4\x828G\x9b=4\x8d)r\x94\xa5)K\xc9\x08B\xc3\x0f\x85\x14bR\xa9\xa2\x92\x18\xd0\xc63\xc3\x1e\x94\xadR\xbac|\x18\xc6\xe6\xbcn\x0f\xbcz\xf5\xf1\x7f\xd0&\\\xa7\xbcLO\xd6\xb7\xacLLLO*\xd1\x878\xa5Dp\xb12`\xfaG\xeaN\x8e\x8b\xf0\x97\xfe\xff\x00\x9c\xe8]\x90\x94\x92\x06\x83\xe1\xf0@\xf2\xeb\xe4\xb8!b\x16\x18\x91\x16\'WC\x07\xc461\xbe\xc661\x8c\x7f\x03\xf216\x98\xb4\x14C\xec|.Q\xbb\x8f_|^Q\xb1\xbe\x0f\x8bp\xb0\xa5\x1b\x98N\xae\x0b\x82b\xc4-!4.\xca!14QF\xcb\x06;\x05\xdb\x14\x84&\'Dy\x17H1k\xf0\xdd\x7f\xa7\xf2\x89\x17\x85\x13<\xe3\x1a!\x08,c\xe1?\x04\xc4\x88B\x08C\r\x85Wb\xde\x8b\xdd\xa7\xe6\x8d\x8cn\x86\xca?\xacy\xd4\x15E\x1b\x1e\xbc~8\xbdl\xa5\xd67\xcd\xb8Z^tL[K\xb4Bb:,X\x8a!1<\xa3\x83\xf9`\x8f\xcb$\x82\x17bb\xd3\xf8\x1b\xb8^\xf4_\xf3\xfcl\xe8\x88\xa2e\xa5\x10\xb4Q\x8f\x83&\x90\x8c\x8c\x8c\x8c\x84!\x08$$$!\x08H\xc6\x1be\xba\x8b\x1e\xe1\xb1\x8d\xf5\xd0\xd8\xd9}\x8d\x8d\xe3\xc4\xe7e\xd0\xc3\x13\x83q{y7\xeb\xf07\xf9Q,T\'\xa9\xe4\x08\\(\x8a\\0\xe4`\xc6C\xb1\x08Byp\x9d\x8f\xb0\xdfe\x03\xdb\xfd\x7f\xfb?\xb1JQ2\xd1\x08\xbcf\xc0\xbf/\xe3\xba\x88$$ \xc3S\x89Y\nTc)F\xfb\x1b\x18\xc63\xce=\xca\x9ep\xc7\x8dQ\xa9\x8f\x1a\x1e2\x1d\x94\xa3cz\xf5\x8d\xf0o\x10R\xd4u\xc1\xb8E\x93\x9b\xddL\\+.\'\x0f\x91p\xcd\x1f8\x81!\x08X\x98\x98\x99\xe0^\xa9\xfatO\xbf\x8b\xfb\xf7\x89\x96\x88Nb||\x08x\x90\x90\x82\xb1W%\',o\x87\x03\xca\x9a\r\xc6PA`\xfd\r\xd0\xb4\xba\x1aho\xb1\x8chk\xe4\x87\x8e\t\x94C\x0cz\xd8\xf8\xbcx\xc7\x8fX\xd9JQ\xbd\xaf\x95.=\x0eD\xf3*\xc6\xc4.\xca\x85\xc5\xd9\xe3)KF\xca6S5\xd0\x8e\xf1\t\xeab\x17A\xf8\x16\x15\xecBBx\x85\x8b\x16\'\xc2c\x10C\xb8S\x11\x81\x0e\x85D\x83Q\x99\x8bE0\xc2\xb8\xa1nAF\x19\x0cy\x8b\xb3\xe9\x19n7)\rvQEb\xc6lTE\xe3c\xe3G\xd8\xd61\x8f\x1f\x06C\xa1\xd3\xc6.\xde0Hj\x9e$\x06\xc5\x99\xe1\xc16x\x94N\t\xe5.\\HAt.\xc4!\xbe\x84\xc4\xe7\x07a\xb9\xd6Q~\x93\xf9\xff\x00\xc1""\xeab\xc5\xc1qy\xf4\x13E\x8ac$\xa4!*#\xc0\x94J\xd8\x98-\xe8m\x13\x14\xf2CX\xab\xc1\x1a\x14v\xc4!\x06\xe8F(\xaa"B/c_\x03\x12\xe2b!\xa6\x1a\x1a\x9c)\x8a>\x1e1\xc66tTQ\xb4F|\x8b\xa5z)R.\xd2\xef\xbc\\|\x07\xe8\xec\x1a\xb1\xba\x17\x82\xf7\xb4Ce\xf6y>\xd8lN\x88$\xb1\x08Bx\x84\xe6\xa3\xccn\xb1\xbb\x85_\xf4\xfe\x7f\x8cB\x10\x84%qj\xc5\xacYCP\xc1\xe5\x83\x05F\xcfA\x85}\x8d\x9d\x88^\x06\xd2\x1a\xbe\xc4\x8b\xd8\x9b\x0f,\xf4u\x12\xbf"E\xd3\x14yJt\xce\xc3B\x04z\xe6H \x91\xa9\x08HHX\xd4\xf0Q\xbc\xf7\x9d\xe3d\xc6\xfa)\xb18\x98\xa5eq\xf7\xc3\x93\xa5\x85\xecO/E:)\xe0x\x92g`\xd5\x08\xf0\xc4%\xda(\x9d\xe1a\xdf\x91v \x82b<\xea\x94\xa2(\x98\x8e\xbd\x94B6\xf4\x8e\xed\xef\x10\x84#\xc0\xb1j[JR\xe2\xc3\xb8\xe8\x1a\x89b\x9450\x82c\xe4\x14\xba\x10\x87\x93{\xd0\xc4\xdf\xec.\x93\x16b6$\xc8%\x06!a\xc3\x13bB\xe3\x0c\xb2\xc3\np\xf8\t\x89\tr\x0e\xf8\xc0\x80\x82~\x84\xd5\xca\'\xf2[\x94L\xa5\x13LH\x87\xbcN\x89\xccN\xe2~\x8f\xd0N\x9d\xd8\xdfc\xd6\x90\xbd\xcb\n\xa1\xacb_g\x90\x98\xd8\xbb\x1d}\r#\x1b\xd5\xbd\x89\xa3\x0f\xcdB(\x86\xda\xa8\xd1\xfa\x1c=\xb4\x11\xdav$uD\x13\x88,\xf2)\x1d\x94E\x05\xb4+;\x1d\x8e\xc5\x13lBX\x8c0\xd0\x88\x88a\xb1\xb1\x8d\xb66\xc7J2\xe3c\x1e\x8f^<\x9ciJ\\k!\x03CB\x06\xa4\x88\x17E(\x98\x90Z\x87\xd9c\xb0H%\x05\xa4\xcf8\xb58.\xf2\x89\xa2\xfcb\xcf"\xf2\'\xa9\x96\x1f6U\xe4[bw\x10\xd3\xa0\xf4\xea;`\xe1\xa6\xbc\xe3O\x1fd\x7f\x81 \x90I\x08\x84BHH1\xb6V*!6P\x9b\x1d\x89\xb1R\x99BD(\x8b\x86\x90a\xa0\xc4\xa1\x86\xc6\xc6\xc6\xc6\xc7\x93^1\x10\xc3\r\xf6x\xb6+F\xc3P\x9a\x8e\xc8\xc9\x8f\x0f.R\x8f\x05\xa9\x04\x11a\xbb\x9e\x04\xca(\x13\xd4\xc4\xc4\xc5\x89\x94LN\t\x89\x8b&$-\xa3\xc1N\x94S\xb1\x12\x14\xf7\x0faR#M\x10\x84\xc8B\x13&\xf6(\x18Q\x98dLA\x04\x99\x18\xd0\x87a\x0c3\xc1\xd0e\x86\x19h\x86#\x83z\xa1\x8d\xa4v\x18a\x86\xe3\x14\x8bD\x84\x98a\x96\x1a\x8d\x06\x83D2\xc3\x0e\x08Lx\xfe\xc7\x06>\x14\xef\x8b\xc4\xd8\xb1nP\x99F\xca?E\x82\xc5\x9f\xaf\n\'r\x89\xbcE\x16\xc4\xfca4\x94\xcb\xd4\x12\x02\x17\x93\xde.\xc2O\xb1\xeb\xb5\x88\x8c["\rs\x98e\xb1DggbLI\x891&$\xce\xe2*\x11!8A#\xf8\x0f`\xdb\x1d\x1d\xa4dddcC;\x93)YJ7J\x91\xe5\xefCA#Q\xa0\xd0hH\xd4h5cQ\x96(\xda* a\xb3\xe07\x064G\x8e\xc5c\xb8\xe0\xe8J%\xf0JB\x10h\x89\xd9\xdbd\xa3\xc1t&V\'\xe8Q\x14\\P\x98\x85\xd0\x84\xc4\xc4\xc4\xe9E\x94N\xb85\xea$K\xa1\xcc\xa3\x1b\x81\x16\x8f4"\x1c\xbb\xc2\xb5yX\xb5\r\x88\xc9\xc2\xd1Q\xaa\x104\x1a\x10~\x81}DQn\x84c\xa0\x98N&\x13\t\xc6\xc5d!\x06\x18lQZ=MB\x8d\x8cQ\xb6VW\x8c\xa2l=t<\xec\xb1\xd0\xd8~\xa5\x15O=\x8d\xb3\xb3\xf5\'\x1f=ko\xd6\x14;\xf2\xc9\xd1a[/be\x15G\x913\xc6O\x81\x93(\x9d\xecL\xa2\t\x8b\x13\x13\x13?R\xc1=&&"\xb11T\x9e\xe1\xba\x13\xa1_\x910K\xe1\x89\xe4\xcf^D\x1d\xa3\xc7\x8b~\x07\x8c\x1c\xfa\x18f\x8a\x16\x10$m2&$%\x89D\xa2A\xa1mtL\xec\x16TQN\x8b,\xaf\x82\x8b\xc9\xb9e\x94Q\xdb\x1d\xc6\x12\x90\xd5\xa3c\xc3\xe8Q\x86\\:)Q\xf6\xc3e\x1c\x0e\x0bG\xb5\x96\x9e\xb3\xc7+\x0f\x1d\x8b\x10\xa5:\xf4D?\x81\xda\xca\x8f\xb2_"B\xe0\xa8\xf3\xd1\'\x91V\'\n\xb1A12\x8b\x13\x10\x98\x9d\xc4\xc4\xef\x8cNhJz\x10\x9b^\x04\xa3\xa0\xf2\x98\x9b\xfa\x1a\x05\xe6\t_\x92\x83F!\x9f\x00\xf0\xbb\r\t\x167Dh\xb0L\x84\x16DW\xdd\x14\x10J%\x10,)hH\xd5\r\x06\x83\xf8\r\x10jkp\xf1\xda\x8fU(\xd8\xd8x\xc7\xc9\xa6\xc8G\x97\xd0\xf52=e\x1b\xc6\xd0\xde\x18\x9f\xa3\xa2\xf4D\xc6\xa0\xfa\x13.D4\xca\xca\xce\xc7\xa3\xc9\xf5\x97\xe0\xf91\xfcH\x94\x9d\xf5\xba\xca\'\x89\xfc\t\xc1 \x98\x84\xcb\x89\x89\x8d\xc4uO\xb1\xa7G\x93\xb4\x84\xc4\xcf8\x9b^\x04\x8f\x94J\xcb\xe6\x1b\xbc\x1d|1%\x13/"i\x89\x84\x96X\'b\x19\x06\x88Abe\xc8B\x08B\x1fg\x8co\xe3Xo_c\xf3\x8d\tP\xdf8\xf4\x97bT\x83CKa\xd0\xfe\xb5\xbco[\x1f\x14\xca5\x90i\r/#:c\xe8$N\x84&8\xce\x83\xeb\x13\x82J\x88\xbc\xa1{\x89$Q\x1eF\xd0\x95T\xf0/=\x9dz\x17]\x8d\xf4x\xd6<\x15\x12\x16!1?\x92\xa1\t\xb14Q18&x\x9f$\x8d/\x82\x01\xaf\x81\xacB\xe0\xbb+LL\x86\xaf8$bp\xa3"\xcb\x0b\x0b\xa2\x07\xd9\x08%\x88Bd!\x08N-\xa3\xa6>\x86\xb6\xe3\xcb\x94B\x10\xff\x00\x18\x82=\x0b[\xf8\xdb\x87\xc5\x95\x94\xbc>\x8a4x\xc2i\xf6>\xb1S\xa1\xb2\x94U\xe9\x91\xf9\'BDH\xe8t\x0b\xa5:\xa7\xf6\x12\x10\'\xf7\x08^\x18\x9a\xf9!\xf4)\xe0P\xa0\xbc\xd6|/e%J>\xc4\x87q\xc6GS\xb3\xa1t\x85\x81\x1eD\xc5I1\x07.\xc41\xd2=\x17e\xd94\xf6\x13\x82/y2\xb4$\t\x85\xf7?P\x97\x08\x84\xc4Lb\x04\x84\xc8\x88LOb"\x1e\xbc\xa3\x1b\xa3cx\xb7\x83%Hcc\xec\x87\x8f%(\xd8\xdd\xd67r\xe7\x81\xfdc\x1e\xdc\xbc\x19\xe3\xc0\xbdX\xfaP\xba\xf6":\xc5\xaf\x05\n{!\xd3 Y\xd5\xa17\xc0\xdb\xe0]\xf5\x08/#S\xc1\x8d\xf6\xd5\xe8\xf3\xec\xe8\xab\xd8\x9a\xf2_\xa1\n\xfe\x05\xf4\xc7gY\xdb\xea\x93\xec\xfd\x06(\x1a\xa4t\x1f \xf8\x10\x9di\xf6\t\xb6\xbd0g\xb6B\x12\t\x11|\x8d~\xfc\x8c\x9d!8X\x91\x8b\xb2A&A\x86\xb8z\xe5\x06]\x8d\xb2\xc6!Lp\xe8l\x8a\x1db]\x89\x13:!\xda.:l\xd9F-\x1b\x18\xd0\xc3\xc8l/\x91\x0cl\xb3\x1ey\xd0\xfaCe\x1b\xd6Q\xbcz\xde>\xb81\xbf\xc0\xd0\x9cu\x0e\xbe\xc2tGf7}\x12\x94\xf4!\xda\x1a\x7f"\xf6\x14|)?#B\x08DD_\x19J\xfeD\xdb\x9e\xb1\xf9\x9a\x12\x7f"\xeb\xd9K\x0fYf\x13(\x9c)YX\x9b++\x15\xf9\xe6\x9bB1\xf2K*dC\r>\t\x10\x84 \x91\xc1/\x92\x10\x90\x94{}d5P\xc3}BA\x0f\xc0\xa0\xca\xc6\xde*gC \xd6!\x07\xd9\x07J6tF>\x84b\xde\xc2Qe\x1b\xece\xe1F\xee7\x8d\xc1\xbe\xb2\x8f\x83\xa7\xd0Q\\\x04"\x08""\xf8:Y\xd9^\xf8\xcb\xc1\x97=\x0b\xa3\xaf$\x13+\x1c+\xf6\xcb\xd9\xe7\x17c\xe8\xeal!1B"p\xa2)E\xabh\x98J\xfc\x89\x18\xa3\x1a\xbdhI\x90\x84cm#\x18\xdfb\xf8\x89\xda\x1bT;Z\xc6\xba\xa3V\xad\x1aB\xfc\x08\xf3\xa1\xa1\xb2\xb2\xd3\xc7x\xf6\x08\x14\xa5*\xf0=h\x83CGh\xa4:\xe8\xed1\xb9\x9ex\xb7\xf6U\xf2G\xc8\xd0\x81\xa3\xf4T_\xacyX\xe9\xfa\x9eJ\x8a\xbeH~\xf2?\x87\xfb\x1fk\xf6g\xdc\xfd\x8f\xb9\xfb\x1fc\xfc\x7f\xd1\x7f"\xff\x00\xa5?\xf4\x8f\xab\xfc\x9fZ\xfd\xff\x00\xf0\xfd\x1f\xe7\xf6>\xe4}#\xf8\x17\xfe\x97\xef\xfc\x7f\xf4\xfe\x10\xfe\x08|\xad\xfe?\xe1\xf7\xbf\xe7\xf6#\xef\xfc\x9e\xff\x00\xfe\x9f\xc2\xbf\xfa4.\xff\x00\xdb=\x803xD$\x976&,O\x82\x17\x05\xabS\xc9\xc1ehN&;\tH**\x13GC\xef\x1b\x19 \x89\x0f\xf4;k\xa65\xae\x86\x065\xe0M5\xd1J\xa9_\xa3\xa1\x03tcp\x7fG\xd8\xf3\xe0j\xe1(K\xec^#\x1e\xb7O$\x1a\xc7\x18\xd3\x0b\xea\xc6\xaf"}3\xf8\xc8\x7f\x1f\xfa/\xeb\xfc\xa3\xe6_\xe4\xaf\xc7\xee&|\x7f?\xb1\xf7">\x85{S\xf9\xc3\xe7?w\xfc\x1f{\xfc\x7f\xc3\xe4o\xdcj\xf2\xff\x00\xc9\x1f\xff\x00I\xfc~\xe4_H\x8f\x84\x89\xf8K\xf6\xff\x00\xc1\xfck\xf6>\x9f\xf0|(\x89\xf5\x9fP\xd3\xe0\xfa#\xe8>\xb1\xb7\xd1o\x07\xf2\x85\xbf\xfc/\xf2_\xb0\x93|\x9e\xee\xcf\xd4\xcb\xe6?\x8d-\xed\r^P\xf9\xf0\xfe\x01\xd7\xdb\x0b\xae\x8f/\x17\x04\x8fy\xf4x \\\'\xb3\xc9upZ\x98\x99xY\xc2\x94\xacY\xac\xaf\xbfa\xf4\xda\xc0U\xed\x0e\x15\tUHIN\xfb#\xb0E\xeck\xd6?\xa1;\xd3\x1ah\xbfcw\xb2\x8d|\x15>\x86\xe0\x9d]\x8e4\xc4\xd1\xdbo\xee1$\x96V\xdfr\xde\xe3o\xbf\xee0\xbf\x1f~Y\x08N\xce\xbc\x9e\xcf@\xf9\xca3\xbc\xf4\x84|\x0c\xf8l\x8b\xf3\xfb\x93\xef\xfeO\xd5\xff\x00\'\xd0\xcbze=2\xfe\x8b\xf9G\xd0\\Y\x17\xf6|\xecW\t\xbd\xb2^\xf2_q7\xe1\x9fN)\x04\x9f\x03\xe8\x11\xf0\x85\xfaF\xbe\x90\x97\xd8\x93\xf9\x1d\xf2\xd9\xdf\xc8\xa3\xf6u\xf25\xec0\xd7\xe5\x89\xfb(\xfd\x0f(\xf5\xfe\x04Q\xbc:\xc4\xdf"o\xde\x10]\x97\xa8y\'5\x93\x8d\xc8R\xe3\xcb\xab\x12tJ\xa8\xfc\x10!\xe0W|\x8d/\x01\x94\xa2\x10\xfb:t3\xb7e\x8cb\xc7\x0f\x1d\x15F\xe0\xdd:\xf0\xca\xd7\x81\t\xbc\x0fY\xaf\xf67\xf1\xb7_\x1f\x08S\xebH\xfd\x0b\xf2u\x90i1\xa9a\x83\xafc\x06\xa2\x04S\xe8\xc7\xe7C\xfa\t^E\xd0\x11<\r\x1e\x84\x0f\xf5r~X/^\x93\xf8\x8a>\xd0\xfdLlH\xc3?A\xbf\xb2\xcacb\x8aP\xdb~G\x1eX\xd5{\x1bz\x1b=\x8d\x9f\x9f\xc9Jx\x04Z\xf21\'\x82l!D\xca\xc4\xef5\xd8\xb9%\xafn\xa7\xc1\xb7\xa14}\x93\xc4z\xcb\x10\xd5\xd6\xa7gcm\r\x89\xfa>\xa3~\xcb\xec\xb4\xe8o\xb1\x1688\x89\xf47\xb7_\x00\xa4\x88~x\x97]\x04\xc5K\xfb-\xe1\x93\xf2I\x03\xf8\r\xb1\xb0\xca\x86/\xc0\xc7\xf4\xc8xcox\xed\xd3b\x08!{)\xe5\x9f\x03#\xd8\xfdL\xa7\xb2^\xcf\x9d\x9f`\xd7\xec\x7f)\xf6\x0ed|<\xd5\xca\xa5Lk\xed\x8d\x03OC\xf4\x1e\xd0m\xbf\xc6\xfa)\xdb\x17\x84\x86\xfc\x8byg\xa4\x84.\x90\xeb!\t\xce\xb12\xe5\xd5\xd3\x17}\x97\x82q\x84\xd7\xc7\xca\x8cF\xbc\r\xc7cG\x81\x89\xed\r\x97\xa3\xd8\xbe\xc6\x85\xd0\xe4)\t\xf48+\xb4M[\x1a}\x0f[\xd5\xdb\x87\x91v7xc\x7f\xb3\xdec\x8d\x9fi\x1fg\xd8%\xfb>\xd3\xee\x1b\xfd\x9fx\xfeC\xee)\xec\xfb\x8f\xb8\x7f8\xfeS\xef>\xd3\xef\x1e\x05\xfc\xe5\x7f%\xb2\xca++++\xe6\x99"\xbf\xc7H>\x85\xf8<"\x18\xf2{L\xf0H\xfd\x0e\xc5\xe3\xb1\xa0\xe8\x8de/\x08NI\xbf|(\x9bBt\xf1\xe0\xb9x!?\x04)F\'\x864Q\xf64y\x1f\xc0\xfa<\n8\xd1\xf0\r\x12\xecK\x03\x8e\xc6\xf6\x8cCbo\xd1E\x15\x83v\xa3\xc7\xc1\x8f\x87|\xbc\x94\xb8\xd8\xde\xc4B~zT_Zv\xc5\xe2#\xd2\x1e\xd3\x11\xf2%\xf8G\xc3\x8f\xad\xf4\xa5\xd0\xd46K\xc0\xdd\x11\xa2\x97Q\x11\x08A.K\x13\x82tZ\x98\x9c\x13\xba\xd7\x015\x8c\xb1\x88Q\x8c\\\xf2:\x98\xd9\xe4cCpaN\x91l\xa1\xb1E\x0cL}\xe5\x1bgz\xf8\xbb\xb0\xa22\x8a(\x8c\xa2\x8a(\xb2\xcb,\xa22\x88\xc8\xc8\xf3\xb2\t221\xa7\xeb+\x08\x97\xacN\xf4&\xfa\x13\xfc\x9f+\x12|\xb2>\x85\xe2!*\xf0\x88"\x12:"\x19K\x88\xa5)\xd3\x1a1\xab\x120\xd9\rDe)q<\x84!\t\xc1\'\x15\x84\xae\x90\x84\xc9\x8a1\x8f\t\x89|\x0co\x1b\x19F1(\xd0\x8f,\r\x08c\\\xba\xb1b\x8b)\x8f\t\xb6\xc5\x8a\xbd\x8f\xec%\xf9\x1a\xfc\x8f\xe6\x12|\x90\xf6}\x87\xdc}\xe7\xde_\xdeI\x1b\xbfA\xf4\x0c\xdb\xbd\x0f\x07\xed\x18>\x00hZ\x89o\xaf\x80H\xf4$\xfa:z\xc5\x96Qe\xe5\xbb\x8d\x10u\r\xb2\x95\x89\x8d:vR\xb16R\xf8\xc5)\t\x1d\x88c-\x91h\x8d\x12\xf1S\xd9\x11\x11\x10\x91\x058!\x08a1*A\xa0\xd4\xe0\xcb\xeb/\xbc\xa2\x7f#\x83\x18\xdd\x1b)\xf6\x1b\xc6\x87\xd0\xd8\xd9\xd8\xe9\n1\xf04x\x1a\x1e?\xadc\x1fc\xe9B\x1d\xa3\xa0\x82\xf9\x14O\x1dk\xc8^,\x98\xb1\x8d\'\x88\xc9\x89\x89\xd2\xf2\xb8\x86\x8ca\x87\x84\xd8\x88$?\x02B\x196\x8e\x11<\xc3\x10\xc7\x03dQd\x99\xd1\xd0\x92\xc8DLx\xc4\xc4\xc8E\x04\xb144\x99\x08A\x8f\xa1\xf7\x94\xb9z\xc6>\xb1\xf1b{\xe3{\xd7\xe4\x98\xf5\x07\xd8\xfb&A\x8f\x1f\x04\xf0\x9b0R\x94\xba\xfa\xe39Lk\x08I\xe3\xf0^\x02\x19\xd1\xf5\xc34B\x10\x84<\x16\xf6\'u\x10\x82\\\x12\xc6\x83\xa1\xd8\xdd\r\x96\xc9Qa\x10\xf4.\xd8\x8c\xa6B\xa2%\x15l\xd9F<\xa2\xc65\xc0\xfa\xd61\x08K\xe3\x1b)Od\xe3\xd8\x82\xef\xb1\x90i\rQ\xc0\xd0\xd1\x18\xd12\x0fD{b\x94\xf3\x8d\x10\x83\\\xe6FBd \xbeD\x18h\x99u\x08B\xc80\xcc!\x084L\x82\xc5\x93\xb1#\xd1\xd1E\xd9\xdb-\x041\x9b/\x0c\xfd7[\xd4\t\x10HL!}\x0cx\xc9\xb7\xb86\x9a\x18\xc62\x8f\x83CC\x1fx\xc6^-\xf6Q\xba!\xa1\xaa2\x10j\r\x12\x10k\xd9\xe3 \xd1\xda\xf0.\xc7\x94\xc5.Q\x11g\xd1\xd0\xf8B\x08\x17)r\x10D\xc3D(\xa1\xa8N\xa8\x91>I\xb0\x82BW\x90/\x8a,\'\x1b\xcb\x8d\xc46>\x17\x1e1\x90\xa9\x0fz\xc7\xc2\xc2\x9f\xae\xb1\x8b^2\r\x10\x98\xfa?Q\xeb\xd9\x96\x0b\x04\xff\x00\xa2Y9\xad\x94BB\x13\x9d\xc4\xf8B\x10\x84\'\x14!!\x10\x84X\xac\x98a~\x00\xc7\xb2g\x82\x9eu\xeb\xc4\xfe\x07\xc1\xf61\xed\xd7\xc9\xe3E\xb2\xd7bD\xf2Y\xd4\xef\x8fq\x0f\x83\xc3b\x1f\x1ao\x84\'\x16\x89\x85\xcf\xa2\xfc\xa8\x94%\x0cc\x86\xb3\xb1f\xa5\xde6>F6\xab\xc4u!-\xecB\x0c\xaaQ\xfa\x08Cq\r\xc1\xbe\xcd\xdf\xd8]\x10\xd2\x15/\xf8\x1a\xff\x00\x8f\xf7\x10hcX\x88\xa1t6 \xe8$\xe6.\x8d\x08l1\xf3b:\x893\xce+\xa3\xa6\r\x86jM\x10\x94i\xc1\x0f`\x9b\x17Xy\t\x1e\x85\xdd\x10\x92\x13bh\xd1\x9a\xe0\xb1~Zu\xc5\x8dap5\xe8Y\x9f\x0c\xf8%\x9e\xf4\x0e\xbe\r\xa4(9\xbe\xd6=\xf6\x88_\x10B\t\ta\xe5uD\xc6\xe0\x86w\xbfK\xfb\x12\x87\xa8-\xfd\xb7\xff\x00\ne\xe1\xcf\xd8A\x06\x87\x8d\x8d\x16\x8fc\xdb\x18\x97EN\x8f\xa8{\r\xcbl`\xd9!\xe88x\xdcn\'\x1e\x06LL\xc7\xc1\x88#\x11\xe0\x95!\xb8\xeeM\xe8\xdc"j&\x86\xb8\xae\xb1D\xe9\xdd\xe3!)\xe4\x16!v?\xbc\x17C\xc5\xc5\xc3\xc3y|a8\xb5HN\r\rp\x9c\'\x19\xc5\xa6Y\xb6\xc9\x887\x04\xa1\xcd\x16\x9d\xe6\xfd\r\n\rQ\x04\x88$%\xe7\x17x\xecE\x8a\x15\xae\xc6\xce\xcfv!]+\xfb?\xfe1\x9d7\x91\x04\x18\xc7\x85\xa1Q\xfd\r\xbd\x11]\x0f\xeb\x89\x97\xbfBz/\xa9\x05\xd1^\x87z\x19:+\xd1\xf4\r\xe7E\xfa\x1b\xe8l\x1d\xa5\x08\x87A\x83\xbdb\xd91\x8d\xa1\xa1\x18\xee\xe0\xfa<\xb2\xa1\xdd\x0eB\xd0A\x1e\xd0\xc5IvA&v\x13B\xc8\xb9\xb2\xfc\xf7\x8c\xcc\xe1\x06\xb8N\rpl\x1b\x13\r\x88\xec=d\x13\xb7X\x9d\x10a\xa2\t\x10A,1\xe24F\x9e\x86\xbc\x1d\x059\xa3\xeb\xc1!\x9eI\xe7\xfb\xf9\x1a\x10b\x0ck\n\x1966\x85\x05!\x93A\x95\x04\xa1g;\x03X&\xe1\xe6\x9e\xa1a0P\x9ci%\x16\xefE^\x1fI>\xd13&\x9c"\xc9\x1dJ\xb6P}i\xa1\xd4\xa6\xe0\xee\x85\xb7\x05(H\xc6\xc5t\\<\xb67\xc1\xfc70\x8ch\xd9K\xc6\x13\x83\xd6_\t\x866\x10j\x0bAR(\x85\xb6\xac\x84\xa0hhhcTHHHZ\x18\xe8\x9b\x12\x12&\x8d\x06?<\xbf\xfd\x13BV\x90\xb1|*\xff\x00\xdf\x9by\x18\x83C!A\x87{c\r\'d\xbcZ\x89d\xae\xcd\t$w\xb1\xd7\x91C6g]\x89\x10\x90Q\xd0\xcd\n\x16\xd8\xe8\xa9\xb2\xe8\xd9\x1a,\x17QHA\t\xd9\xf0\x9c\xb3x~\x81\xa1m\x0b\xd8J\x16D\xfa\x13c\x0e,?\x9e\x94|n`jpy\x83Y\x83S\x9baBR\x91\xbe\x86\xdbf(Q\r\x86\xbc\x88 \xd1\x08B\t\x10\x83\to\x04\x868\xed;O\xd8\xaf\xecB\x9d\xe5\xa1\x10x\xd7\xfd\xe0\x10hx\xae\xc4v\xb6m\x92\x91\x8d<\x16\x15<\xeeJa \x90\x99YJ(\xaf\x06\xd0\xe2\x16\x84\xd5\x82E\xa2:\x14\xb3\xc3EC\xaf#VQ\x8d& EI\x88cV&+\x83\xe6\xf9\xb2p\x84\xc5)N\xc9\xce\x1de\xaf\x83\x84[:\x12\x12 \xd0\xd6G\xb1\xa1,$#\xeb\x10J\x8846\x87%\xa2\x9fk\xfe\x84\xf2^~\x91\xff\x00\xf3\xfe\x96\xcf.\x8d\x0f\x06 \xf0\x8an\rF+\x1a\xc7\x04b\xd3\x08\xf8_\xec\xb1\xb9e\x146^\x0b/\xd1e\xf9)\x11\xf96\x8d\x95\x94\'\x11l\xd8\x90\x90\x94\x16_;\xf8)\x9e\xc9\x98R\xe2\t\xe1\xa7\xc5\xf5\x87\x814\xc2B\rhy\x18\xc8B\x10\x9c\x12\xa2k\x83h\xecJ4&\x9f\xaf\xec]\x17\xbf\xa2\xff\x00~\xa3\xc1\xe0\xd6\ra,$\x84\x8d\t!$\xc4\x82A!\x02L\xf3\x84\x10Nx\xca\xb8\xe5\x89\x1e7\x89\xa1$\x10<,<\x84\x84\xa0\x85\x8b\xce\xfe\x11\x933-Q\xa9\xc2\xe1\xac\xbc\x1ff\xa2H$$ \x98h2\r\x10\x98\x84&\x10K\x1b\xa3\x8fT\xf9\xd6)]\x12\xa5\x1e\xcf\xfb\xff\x00\xa8c\xe8c\xc12\xa5\x13\xc7gB\x16\x16\x11p\xb3\xd6V\xc8$DD4\x10C!\x8c0\xc4&\x0cW\x10|\x19A\x04^o\x13\x0f0\x99\x98\x84\xcc!\x08N4\xbc&fe\xc3\xc6lJ\xa1\xa7\x00AF<\xc1\x92\x90\x87D\x11qq\xa8\x84\xbdlQ\xcb\xbc\xb4)\xaa\xf4\x89\x7f\xbfH>\xf0\xc6 \xc6\x89\x84!\t2\x0b\x08\x82BD!\x08BR\x10H\xa9\r\x10\xe7\x03\x05\xe7\x86\xe3\x1a\x18\xc6\xb3\x07\x96\x88B\x13\xff\x00\xc1\x84\xe12\xca=\x91\xe5\xe1\xe4HH\xac\x12\x1a\x10x\xb1\xa2\t\x10B\x10d\xa2B\xe8z\x18q\xeb\x87\xec8\x82\\uO\xfd\xfc\x1fu<1\xa1\x8f\x89\x08B\x16;\x10\x84\xb2\x88$CCD5\xc2\xdca+\x03f\xc4\xe8\xb6\x1a\x0cc\x18\xd7\xc3?\x1b9\xc2ffb\x8dz\xcc\xcd\xf6$nlD\xc124B\t\x10\x84\xcc\x12(\xda\xc5\xa1*\x92G\xefbZ,\x1e\xa2\xff\x00~\xc3\xeb\x0cc\x18\xd7\x02\x16P\x85\xc1f\x11\x03T2\xdc\x7fq+\x03vm\x8d\xd0\xe9V1\xb2\xf2\xe1\xb4x\xbc\x1e_\xc5\x7f\x0119\xc2\x0f\x8c\xcb\\_*Sf\xa1\'\x86\xb0\xf5\xc1\x08A"\r\x10d\x12\xa2Phx<\xc7\xba\x12\x17\xa1\x1e\xd0{\xfe\xfd\x90\xf0\xc7\xd8\xf28\x88\xbe\x03^\x8b\xc2f\xc476Ap>\xc3CX$$H\xf0\xc6\x88.\x87\x95\x04\x16\xb7\xb3b\x15\xd1\xbf\xc7\xf4V\xf6\xfb\xe0\xc641\xf7\x84,!\x08B\xe0\xba\x10\xea{\x0b!0\x89b\x8d\xa1aa\xa8\xda6T8\x9b\xce+\x9aR\xf1\xbf$\xc4\xe4\x89\xf8\x19\x84\x9cZ\xc5\xcb\xc6\n,TK\x8ah\x94j\x10H\x83D\x1f\xd0\x96O\x08\xc1\xa8\xc9\xa8J\xcb\xcf\xdb\xff\x00\x1f\xe4\xcb\xe3,c\xc3\xc9\x08B\x12\xc2\xca\xc2U\x92\x83 \xe0\xe3plLLh\xc4\xca&&0\xc6\xacz\xb4o\x18\x1b\xc0\xd9q~\'\xce\x13\x92\xe4\x88LLLL2\x13\x87d\xcb\xc3W\x83\xc1\x08$HJ\rk\n\xc1\x89\x13\x80\x83XJ\xe1\xe1\xa6\x0f\xa3w\xb1\x89\xa1k\x17\xe8\xd5\xff\x00~\xb7\t\xdc1\x8f\x0f%\x82\x10\x85\xae\x0b*(\xa4\x1c5\x9d\x07\x18\x85\x82g\xe6!\x0b\x03hC\xd0u\x86\xf21o\x1b\xce\xf3X\\\x17(O\x9e\x130k\x13\rQ\xa9\xc0\x91\xb1\t\t\t\xc2J\xc8$$H4M\r\r\x0bjA\xa1\xf0A\xa0\xbf\xa0\xbf\xb1"\xc1\xe5\xa1\xbfS_\xef\xd6\x8b\t\xe5\xe1\x8f\xac!\x08B\xe2\x84St(\x9a,xR1\x08\xfc\x841k\xc1\x0b\xa1hr\xaa7l\xd1\xc6@X\\R\xfc\xcb\x84\xe3\t\xf8I\x99\x86\x86\xb0\xd5&\x0fb\xd3u\x12\x13\x1b\xde=\x86%\xb2a\x10\x83C\xc4BD1\xe0\xc9\x8d\x06\x0b_o\xf4!U\xd4\x8d\xff\x00\xbf\x91\xae\x7f,b\xc5\xe0\xc7\x84!\x08XYXcgqMY&WL\xd0_\x07HLL\xbb\xd6\t\x8b\xa1`\xc6\xd8\x1fAS\x0f\x04\xab,\xd2\x19\x84\xa0\xb7\x90\x9b\x14\x0c\x82D\xd0\xc64H5\x82D<\x13T\xab\x19*=lJ\x0cK\xe5\xa4h\xafH\x96S\xe4\xc7\x89\xa1\x08B\x10\xb0\xb8 \x82\x10<\xc2\xd3\xc4\xf4O\xc1\xe0\xe8BxV\x0bb\x18\xd525\x14A\x84\xef\xe0W\t\xf0/\x82b\x13\x133\x8c\xcc\xc3C\x1a\x13f\xe1\xa2AOf\x8f\x90\x83"7\x1b!1\x9fcB\x10k;\xd10\xa6\xc4\xac\xfd\x04\xa9\xb6\xf8\xdf\xfb\xf8>\xdd|/\x06\xe7\x04\xb0\x84!\x08B\x16 \xd0\x82\x08Q\t\t\x16[\x16\xd1\t\x04#\xd3"\xc2b/\xa6\x1e\xb8\x17\x94\xca\x17\x04\xb9\xa4/\xc2B\x10jfe\xeb\x0f\x0bX\x92\xb1\xd8\xd0\xdc\x13\xf9"l\xb1\x13\xd2V-D2x4$6c\xc1\x1e\xa4S\x17\xb7\xbd\x89Vj\xfe\x14_\xef\xd8\xb8\\)y\x10\x84\xb0\x84!bp\x10A\x0f \xb4\xf0\x84\xa3v\xb18!3165\x05tv\xdcX\x85\xd9=\xb3\xdf\xf7\xec\x8f8C\xe2\xc6\x0e\x95d\x83D\xa4)\xbb\x04\xb4#\x8e\x10\xe9\xf0\xff\x00\x7fXX\xf1\xc9\x8f\x08H\x84\x12\xc2\x10\xb9 \x9e\x06\x8a\x12\x1a8t\x8e\x90\x90\x82BB\t\x08J\x90X"F\x8d\x0e\x94|E\xf0,\xa2|0\x9f#\xc4|ff\x18\x91\xa1\x8e\x8aQ2\xe5\xa2\x0c\x1e!\xa3\xa1"Q,\x96\x91\x0c\x16\x84\x84\x16\xb1,\xfa\x11\xf9\xc9\x7fc\xf3\x1b\xfa\xe5K\x97\x94\x84B\x13(\\&PhA(\x91\xd3y\xae!*A&$&\x84$-\x1ep\x84US\xbd\x8dG\xb1k\xe1Y\x9c\xd78"\x13\xe6\x98\x84\xe0\xf2\xce\xc2\x0b\xe7/\x16\x17%\xcb\x13\x8f\x0b\x13Aof\xed\x1fH\xd4(\x11\xb3\xb9\xb8\xc9\xb1\x13?\x01\xd7\xd8\xd4\x18\xb1\xd0\xf8\xbc$$B\x10\x82B\x10\xb184$\x10R]\r\x0f\xbc\x90\xfa\x12\x82[\x10\x90\x8f\x02\xc2\x10\x95C\xbd\x0cl\'x,.\x0b\x9a\xe6\x96\'\xc7>\x16\xb8<>\xce\xc5%\x93\x1e[)J\\2\xc1\xe9\xec\x88\x96\xce\x1851( Q\x0f\xb5<\xff\x00\x7f\'\x9c<\'\x99\x87\x87\x84\x84\xb11\t\x94\x89\xc5\x89\x82\x14\xd0\x91\x90x\xd0\x84-\x08B\x10\x8df\xaa\xa2\xaa\x94ap\\W(,\xac\xa4-s\x84\'9\xcd\xab\xc1\x8cBn\x89\x112\xf2\xc6t \x99h\xdeuf\xc2\xa0\x96$\xb4\xb1F\t\xa1"lO\xaaU\xfe\xbf\xe7\x842b\xf0xxHD&&P\xb8\xf5\x86\x844\x17\xce\x08=\x9b\xce\x81\x04\xb0\x90\x82YK\x08u\xa3\xbc\x8b$\x17%\x89\x99\xc2ee\x08\x9f\x83\x9c\xa6\x18\xc8u\x1d\x10\x84\xc3\\\xe9D\xc5\x8b$\x88\x8d[\xd3\x14(\xb3Dj$(O\x97\xfb\xfe\x97g\x8db\x95\r\xe6\x95\x17\x14\xa5\'\x08B\x13\x10\x9c\x1e\x1eD\xa8A\xa6\x99\xb4\x83D=\xe2\x08YB\xc2\x115*\x8b>\x14.3)p_\x86{\xc3\xe0\xd0\xc6#\xcd\xc5\xe1\xa2\x0f\x0cC\x10\x90\x90\x90\xd7E\xc6\xd7CWb\x9c\xd0\xef`\x96\xc4\xc3\xf0\x94\xa0\xde^\x17%/\x01\x18\xb5$D\xcc \x96\x17\x07\x87\x86%B\r\n)#qU\x08,,\xa2e\tQ\xe3d\xdd&\xf12\x96W\x14\xb9Bq\x9f\x1c\x1f\xc0\xd7\x17\x85\xd9!10\xd5\xc3\x1a\x1a!\r\x18\x9b\x10HJ\x1d\x0c6\x18\x8d\r\xc61\x08G\x9fz\xff\x00~\xe3{/\xc2\x03X>"D\xe18<=q \x94C\xa7\x85\xdd\x90Q!!\t\x08K\x0b\x0b\x15E\x16\x11\xa1a,\xa5\xc6\tr_\x1c:\x1f(Br\x84\xcfBSa$B\x13\x0ck\x0ccXK\x04\x12&\x86\x83\x0c\\YxM\xb7\xe8oz\xeb\x18|\x15\x8b\xc0\xbco\x8e\x84\xb1\x08NLxc\xc8\xb0BL\xf3\x94\xd6<\x0bYB\xd6V\x12\x9d\xd4\x8e\xd6\x12\xc2\xca\xe2\xb9\xae\x1d\x93\x9c\xf8\xa78\xf2L\th\x84\xc3D\x18\xd1\x06\x88"\x8a\xc0\x90x=\x18x\xa4c\xd9\x067\x9eZBc\xf0\x98\xbe@I#A\x86\x1eB\x13\x08$B\x10\x84!0\xf2\xf2b\x08-3I8H!a\x08_YB\xc5U*\x99fV\x12\xbcVfW%\xc6|\xcc\x9cZ\x1e\x13\x1c\x1a \xd1\x06\x885\x89D\x85\x81\x04\x86\x98\x1b\x18\xd0\xf1D\xc3\xf9\x97\xfb\xf8+\xbfee.\x0cQXQF\xd86+\x17\xc6\xd6\x1c\x1c\x1e\x18\xf0\x82\x08!&G"\x10\x84,\xac\xb5T!H\xecI\xc1.\x08\\\x90\xbf\x01\x08B\x13\x9c\xcb\x19\x0b3F\x10\x88a\xa1\xa2\x0c\x98!1\x0e\x91A\xb8\r\x1a"\x80\x95\xc19O\x85\x07\xa6\r\x10hh\x84\x1a\x84&\x1a\x1a6"e\x13\x9be\x18\xc6\xca\\l5\x82\x0bL\xeb\xc8\x0bB\x10\x84\x85\x95\x99\xa9dQ\x94\xb8\xac\xae4X_\x1c\xe7\t\xc5\xf2l$\x89\x884444*\x10HB\x10oE\x06\xf21\xa1\x8d(j\xdb(\xaf\xd6\xc6\xfdGM\xd9\x8d\x10hhhh\x83D\x1a\xc3CXM\x10I"B21\x184 lo\x03cf\xd8Q\x06\x84\x123\xc2Q\x13\xecK\tabae\xef\x1f\x9cH\xc4.+\x85\xe2\xb9.3\x13\x08k(\x99\x94X\x1c\x0f,J\xf1u\xc2\x0c\x84\x10K\x04\x85\xa1\xb1\xb1\x8ccT\x98e\x82\x0b\x08\xf1_\x08\x83CCCD\x1a\xc4\x1a\x1a \xd0\xd6F\xc3\n\xc5<;\xe3\x84]`\xc5,\x13\x13\x18\xc6 \xf1\x97\x1f\xc9 \x84\xb3\xde\'\x1a"\x02\xe1\xa1O\'\\\xae\x10\xb8R\xe6\xe1pK\x8a\xe6\xc4\xb2t\x8b0\xf0\xbb\xe2<\xc2\x10\x84\xc3cc\x18\xc7\x86A\x9d\x08\x87\xe6gJ\x06\x89\x83CXh\x84\x1a\x18\xc6<63\x1b\x1e\x0b\xe6\x02\xc1\xb8\x85\x1c\x0c\xactl\x1e\xc9\xbcY\xc2\x0cN\x8b\x92\xe3\xb8\xb2\xa3\x13\x1a\'\x05\xcab\x9777\n,R\x97\x8de\x16{\x14W\rBa\x93bVIg\xb2\x13\t\x8f\xac1\x86\xf0\xc6\xc61\xa1\xa1\xac=~\x84\xb6t="\xc6\x10cThhh\x98cC\x18\xf0\xb6PhH\x86\xf0X,\x14\xae\x02\x89\x8d\rp6&\xc4\xc6(\xc4(DH\x86\x9b\xc4\x9c\x95\x17\x14\xb8R\x8a\x99r\xe3\x86\xe8\x84!8\x04#(\xa2\r|\x0b\x8c\x166\xc1\xa7\x9aS\xb0\xba\x12BKG|1\t\xb1"\xcc\x12$\'\x06>\xc7\xb1\xe8c\x1e!\x06\x86\x8d\x10\xd4\x12S\xe8\xb5{\x7f\xd0\xd5\x98\xd1\x06\x8641\xacA\x8cc\x1e\x18X\xdd\x8eHx\xc2Bx\x12\x1a!\t\x04&@\xeb\xa1\xacB}Q\xab\xf1\x04\xde\x86\x06\xe8o\x8b\xcd{\x1d4&T\x15*\t\xbe\x02\x94\xa5)F\x93\x10\'\x13J\x13\t\xd8\x9b"+:|\x02\xb0\xac\x90\x84!\x110\x8e\xc2\xa5E\x83.{\x9b\x1a\x84\xd1\xdd\x8co!f\x0b\x0f,xc\xd8\xc8Llh\x83E\x86A"o\x840<\xb1\x8f\x0f\x0cd\x1e\x1eT\x84\x15\x16\x881\xad\xe2\xe1!l4B\x84\xcc\xb9Ea\xf5\x0bd\xd2\x81>\x02|\x0bJ(1\x94\xa0\xb9\xa6Es\xfe\xfa\x10\xac\x18\xd9A\xd8\x85\x04!)D@\x94J&\x0f\x14P\xfeS\xef\xb50\x9c\x16#d!\x04\xde\x1dN\xa7a\xe12A"\x1d\x0f\xb1\xe1\xe1\x8d\xc1\x8f0\x99hh\x82{\r\tW\xed\x8f\xd7\xdeX\xd8\xc7\x87\x86<1\xa1\xa2\nH"\x1d"d\x99\xdce\x13\x13\xf2\'qG\x92\xf7\x89\xd2,o\x15^\xfb <\x84\xae\xc51\xecHl\xd2\xc8\x8c,\x13 \x82D\xa4!\xaa\xd9#\xc05cR8$4X\xa0D\x90h\xd4(6*b\xc2BD\xc8\xb2\x18A\xe8cegd!\x08.\xce\xe3\xe8\xd8\xed\xc0t[\xc2\xfb\x1b\xf1\xc5\x8ccX\x83\x19\x0841\xea\xa2\x1a\xbdj\xfe\xe3X\xf44\xc6\xc51\xa3a\xb9\x1a)\x8d\x91E\r2\x86\xc3fV* \x82\x1a7`he\x82\x08-\x8a*\xec\xb6\xc4\xa3h\xe9\x94U1E\xb0\xdc>\xbc\x1fh\xc7\x93\xca\x1a\xed\r\xfb\x10\xf8\x94\x89\t!\x1f\x98k\x8c!\xfd\xb0k\xec\x7fa\x86\xc3f[\xf3\x83\xec\x17\xbb\x05\x8b\x14)\x95\x94&\xc4\xd8\x98\x84R\x89\x88Q11<\xbc\x18\xf19w A\x15C\x0cv\x13\x8c\x90\x82\x18\x95\x97\xd0\xdf\xb3\xcf&\x86L\xb4LA\xa1\x94\x99?\xd8\xa6\xf05\x19\x07\xf4,\xdc\xa67C\xbc\x1d\x1b\x12l9\xed\r\x0e\x96h\x9c\x0f\x80(Y\x8c\xb5\x1f\xa9dh\xad\t\x91\x0e\xc5%\xfb5\xf2(\xe9\x8d~F+\x86\xdb\n\x97\x91\xfb\x88y+\x0e\xc5\x96Y^\x06\xc3q\xb8\xca\xc4\xde\xf0\xa6%rO\x81\x14\xc2\xe0\t\x94LLAr\x00\xdc(|\xf2\x9aK\xa2\xc3tLR\x84\xe3\x10\xa4^\xc5;\x12\x92AJv4A\x90d&<\x8dF=\xecE\xbf\x85G\xba%\xb1(3\xc0\x9d\x89\xb1%\xb4&\xa8\xdb \x9a44\x9d\xb0\xce\xacl!B\xece<\x17\x12+\nP\xa7\xd1b\x19T\x19\r\xb1b\xcbDh\xd9Z\x13\x15\x83\x08&\xd8\x9c\x14\x160c\x1b\xb3b\x89\x89\x8b\xe0PT*\x14*\xc8\x90HTTR\x97\x10\x84\xcax\xb9\xac\xacA\xb1\x87Y\x1e\x1a\x1a61\xb66\x13\x8fC\x07\xa0\xcd\xe1"!\x06LB\x95\x96\x84\xc2dX\x82A0H\xcdp\x99\x84{\xc6\xdb\xde\x86;A)F/!~D\xd8\xd1S"\x16\xba6*\xf4TT\xf0\xda\xcb\x18\xe603\x19\x8d\x84l$"\xc3\x0e\t!\xcc\x18\xd9pm\xdd\tkb#\x12\x12\x131\xb3CP\xd46+\n&\x12\xe1\x04\x84\xc2mp\xfa\xd8\xa9X\x82\xc8\xbcQA\n<\x12,h\xa0\x9e \xc8NFy\x86\xa2\xd0\x98\x98\xc83\x84!\x08LL\x9f\x05t\x90\x99\t\xc4\xa2`\x99\xe4\xb4x\xa2\x06\xb1\x17\x98\x9a1\x8b\xb1(L\xb0\xa5e\xc8\x98L\xbf\x07n\x89\x92\xd0\xa0\xa5\xd8\xe8o\t\x8e\x0c6\xc7\x94\x88PVP\xb6\x07\xae:\x12I"\x11!\xc6\x10M\x91\x08,\x14Lt\xbb\xc4\xdae)J\xca(\x11E\x16\x1bl\xde\x13\x16\xf0\x98\x98\x99JR\xe6\xe2\x143\xb1\x0c\xd4\xd7\t\x04y)\xd8\xc3n\xc6\x88B\x13\x06\x96\x0f0j\x935\x89=1w\x84\x9e\x18T\xa6\xce>\xc4\xabCQ\x9bHL($bu\xd6\x14\x8c\xb4B\xc2e\x1b)J \x93\x1a"4\x86\xc6ChjH\x8b\xd7\x0bfR\x89\xa4@\x91"Q\x02\x9eO`d\xe0\xd0jo\x83\xa2\xa3!\x08B\x10\x82CL\x84dbL\x84\xc4 \xb0 \x90\x93bfQE\xe1E\x9bX\xa0\x98\xa2\xc5\x9bB\t\t\xc2E=\x92\xc6\x8f\x03\x11/\x04c\x0fh\xa3!\x8e\xf66CdL\xb4\xc9\x88B\x10hy5p\xd1\xab\xaf\xc0\xddCf\xc5\xf6\x1dP\xd6\x0c\xb4`U\t\xb0A\x87\xb4H\xc8\xc9JnP\x98LP\x98\xafx\xb2\xc32&\x9e\x1d\x06\x1a\x0cN\t\x94P\x9e\t\xfd\x96QE++\x06\xa2\xae\x12~<5\x19\xe1\xa2 \x82\x98\xa4c\xf2X$ \x94\x93QD(D:4\xe8\xa2\x82I\x8f\xd4i\xa6X!\xa3a\x8d\xa2\xa1\xa4\xca\r\x18\xdf\x03P\xc8\x9d\x08\xa5d\x06\x8c\x88\xd9\ra\t\x98B\x0fcS\r\r\n\x9f\xe9\x12\xd0\xa8{\x11\xa3e4\xc6\x8ce\xb9\xb4Q2\x10O\xecN#\xc8\x90\x912f\xe1\\#\xc5\x11\x06_\xb0\xd9q\xa3E)JQa\xd7\x87\xb2\x84b\x8a\x9avI(\x82H"4\xb1\xa2p\x98B\x11a0\x84\xe1\x12\x83\xfa+4\xc5{\t\xa0\xe8i\x85BFZ;\xe8l\x84\xe0\x94N\x8d\x18\xc6\xc2*\xcd3q\xba\xe8Uw\x81:CxKg\xa0n\x1ea\x1e\xc4\xbe\xc4\'\xd4=\x02C\rba\x8d\x0cx}\x8b\xf7\x8aX(&\xf0\xd2\x19n:\x8aB\xf7+\x12\xfa\x1b\x8cF\x8a\xd0\x99\x10H\x8470\xa9\x89\x9a\x1a\x1f\x05\x02\x8c,\xb6QE\x14&\xdf\x08L\xae\t\x1d\x90\xfa\x1b)E\x18\xdf$N)\x94\xa5\xe2\xa0\xf6\x115\xa1\xa6\x8e\x85D\xc8\x7fLI\x0fA \x9bB\t\xa65\xe85\x15\xa10\x91\x9acW\xd0\xd5\xe0\xac&]\x89\x1b+\t\x1fE!\\x\xc1\x02\x8e\x87\xba:\xe1\xb3@\xf6\x1d\xb6:_X\x06\xcb\xb1\x90\x83CC%\x1az\x88J\xee;\x1a\x19\xe0\xa6\x98\xd1\x8f\xd4lVY\xb9X\x84\x18\x82\x89\x9d0\x84\xcb\xb2\xfchd\x16\xb0\x84\x88,\xac,&>\xce\xa3b{\x86\xe8rDz(\x9ea\x08.\x13\x82\xe2\x98\xd4i\xc2;\x1a1)\x08\xa4G\xb1\x01\xa9\xd4G\xe4Z\x13\xa1\x0f\xb3\xc7\x1b.\x86\xc8U\rC]\x8d\x07\xa8l\xbc\t\x84\x88\xf6 \xd2;\x19\x07\xbd\x1d\x84^\x04\xea3\xd1\xb4zv\x0b\x08\x80\x8f\x01vG|\x95i\x89_XLF`\xe6i\x05"xx\x9a&\xf1p\xd1\x8f\xd0n\x89\xc5R\x8d\x96\x89\xe1b\xa2\xf2\xb8\x99\x84\xc2\x16f\xf9"L\xa0\xb0\xb1\xe2a\xc7\x85\xc9e|=e9\x81[\xf68\xa8\xaf\xd0\x89\xad\xa1\xa5\xc8N\x10OO\xa1\x0b\x84\x10&\xe8\x84\x9e\xf1\x90\xe9JZ\t1\xafH^F\x87\x14\x98\xd3\xa6!\xd2e4\x17\xd0\x8d\xe0L\x88N\x99\x0f\xc8\xe2]\x8d=\xa0\xfdRJ\x1a\xd3\x17\x9ccP\x97\xab\x18>\x8d{\x1bC\x86\xfc\t\xc2\x8b\x82\xbfF\x88JQ:\x19y"}\x11\x0f\xb1\x05\xb8j\xb1\xa3\xc6[\x1aPj\xc6[\xa23E\xc6\xcaR\x97\x13\x9b\xe0\xb2\x99r\xb0\xb8\xa1\xba\x17\xce\x18\x87cth\x84!0\x84\xb3\t\xc5p\x9c\x10\x99:\x87\xafH\xf6\xd1\xa7F\xd8\xee\xb2\x9d\x08\xf3\x85Zv3M\xd0\x92\xeb\xecw\x88^V\x8d{A\xa3\xb4\x1b\x17F@h{\xa8\xd1\xe44\xf4\x1a\xba\x1a\xf4\xa7\xa1\x07\xf5\x0c\r\xc6\xdeCn\xeb\xbf\xc9[\xc3\x12\x7f\x02`\x9f\xba \xc6\x111\xba\xed?u\xff\x00\xd1\xaf\xf9\xff\x00\x86\xfe_\xf2 \x93O\xfb?)rTG\xb5\xff\x00~\xa3\x95o\t\xa4P\x0f\xcc\xfd\x88\xd2\x1c\x18\x90C=\x18\x95\xa2\xa65:6V!\xd8\xc90\xfa\xcbcy\xc1"a\x05\xf6!\x89\xdcJB\x10\xeb\x13\t\x9e\xc9\x8b\xc2\x13\x14O\x1a.o\x16\x89\xf8\x95\x10\xd4{\x8azQ\xf8\x90{\x9b\x11\x89.\xd5\'\xb3\xf9/\xe1\x9fP\x9d\xe8^\xf4\'\x12>\xe6{[!\xe3\xf9!\xe3\xfb"\x1a:K\xf6\x14\x97r\xfd\n\xbacA\xfb}\x8e\x9b?S\xca\x12\xa1\x1d\xe3S\xb1 J\xc0c\xc6?\xa0k\xe8m\xdc>\x82/F\xbda\xa3\xce\x1a\x1a\x1a\xb1\x86\xe3a\xa6\xb2M1\xa1\xa81\xe1\xb1\xb2\x8b\xa1\x93\xc8\x93\xc3CX4#\xa2\xa2\x94\xa2b\x83K\xc12\x94\xcc\xc2\\.v(x\x83T\xb1>\x18L$,\xae\x11\xb1;\xc0\x9d\xd3\t\xa3\xe9\x17\xd26\xfa=\xc8G\x9f\xe0/+g\xdc\xc4i"~\x04\xf4\x93\xf6"\xf4\'\xe0\xacc\x1ef<\x9b\xcb\xd9\x1b\x14R\xd0y\xf3<\x11\x12\xe8~\x86,\xc7\x16\xcc\xac\xc2\x10\x84 \xd19\ra\x86\x8c\xf4\r\x96(x\x1b\xa3hlx\x13ECHA\x14m\x11\xd3\x13\xbd\r4V]\x16\xe6dh\x97\xa2{\x13t;B\x89\x88\x98\x9cQ`\x98\x9bLB,\xc1\xa2\x10M\xe0M\xe9\t\xfd0\x9c\x17\xa8M\xee\x0b\xce\xd0\xbd\x82\xf3\x05\xe4l\x8f\xb1#\xc0\x91\xe0$t\x82GI~\xc5\xf3\x06\xcaR\x08>\xe2\x1eQ\xf7\x9fy\xf6\x1fa\xf7\t\x98\x9e*}\x13\xe8\x7fS\xf2\x15\xe8^\xa2\xc2\x9e\x08\xde\x04\xff\x00\x1f\xc0\xa7_C\x8d-\xb1w\x1cB\x17lI.\xbe\x14\xa2\xb6\x10N\xf1|\x18\xf0\xd5\x18\xd9G\x1eY\x06\x1a\xb1\xfa\x8d\x86\xe3,#\x83\x08\xc84$\xc4!0\xb6\xa8n\xdb \x93\xf2\x84\x9c\xdf\xa3\xf2\xe1 \xa9\x04\xfd\xa19\xa4\x16\xbc\x0e-X\x04\x03n\xc8XD\x1a!\x19\x04\x84\xbd`\x87\xb0\xe1\xb0\xd0\xc6\xa7\xa8\xcb\xd8\x87I\x1e\x12\xfe\t\xf8\xc0\xd7\xd1\xb8\xac\x94N\xe8z\xd1~\x91n\x97\xf0W\xc7\xf0G\xf7\xfb\t\xde\xcf\xcf\x17\x91\x9fw\xf2W\xcf\xf2/h\xbc\x8c\xfb\xd1~B\xfb\x8b\xc8>\xd3\xed>\xe1\'\x04|\x08\r\xdd\xc1\xa4\xee\x15=\x12\xf2\x8f\xb9\x1e\x86}\xbf\xc1\x1d_\xe0k\xd8\xfc5\x8fBGv\x87H\xbe:\x86\xd0\xb4a\xa6\x8b\x92\xf1xyha\xe0\x98\x98k02\xech\x98\xd5\xd1\x15\x886\xdb\xc0\x86\x85\xe8\x15\xc1\x03\xce\x1b\xbbXHH\xeb\xd7\x12\xbaSPk\xfa\x12Of\xc3\x9e\xcb\xb8C\xb4\xf4-\x89\x0f\xd5\x1a\xecM\xbd\t\x9e\x11\xf4#\xe9_\xc1\xaf\xaf\xe0\x87\xaf\xe0\x9f\x95\xfc\x1fK\xf8\x1a|\x0f\xae4p(\x1fE\x1a|\x9f\x9a9kc#\x81o\xa4\x8f\xa5\x0f\xc5\x07\xf5\x8f\xd8\x86\xdd\x86\xd5\xacF\xd7\x91\xeaw\xfa\x8d\xfaf{\x18\x99\xd0\xd1\xbd/\xe4h4\xf0\x8f\xca-\xba<\x00\x9fH\xef\x02\x9b\xd8\xe9\x97;\x9a@\xcb.\x04\x85\x86\x8ca\x86\xb3r\\\xbc\\\xbc\xb11\x06\xbd\rL\xb5\x06w\x9e\x84\xd3\x12\x18\x9f\xbcA+\xa1\xa6\x9e\x8d\xb4WCv\x84\xda\xd9\xd6\x8fJ-\x86Rc\x8e\x8a{\xd0J\xef\xb1R\xc2\xcf$,AwJ\xb1\x8b\xb4U\x9a\x08\x90\xa9\x8d\x89\xd0\x80\x99u\xff\x00\xc12cW\x96[\xff\x00\x18\xfd\x1b\x1a\x06\xcf\xc1\xb6+\x12\xe8H\xf0l\x10\x93\xd1\xdcg\xa5\x91<\x03I\xe4\xe6\xf8\xa8"\x90\x89v\x8a\t\x10\xbc\x03\xc1\x8f}\x8bw\x8e^\x07\xc1Jh4BQ1\xb3\xd0\xd0\x95\x12\x90$X\xa3Ha\xa1H\x8f\x8d)JQ\xbf\x85\xa2!\xa1\xa1\xa8L\xb7\xa2\xce\xcaS\xb1?\x02Q\x8f\xa1\xa3f\x92A#BQl\x93\x1b\xc36R\x89\xbadnV\x9e\xc8\xd8J6\x90K\x94\xf1vQ\xec]\xa8f\xe9\xa3f!\xf4\x14G\xe8\x84 \xd0\x91\x10\x84\xf4v\xa4^\x04\x8f\xc9\x87\xe44\xf0&~\x0b^\x0bx\x1f\x81\x1e\xc4}E\xfc\x1fI\x7f\x18\x8b\xd2\'\xf6\x17\x80\\z%<\x0b\xd44+\x06\xc9\xc6\x86\xbaG\x80;A}\xbc\xf1%t\x8e\xb1s\xd1q\xd1R\x1a!\x08H\xfd\x83\xbc\x15\xdb)\x89v&\xd0\xa4Apj\x8a2^\x86\x18a\xa23|k\xc5+(\xac\xa5)p\xd9F4AYq\x05S\x98HB\xdad\x84l5Q\xe3\x13\x82UJA\x86\x84\x86\xc6\x91A\xe7g\x9d\x89g\xb1)\x87%C\x1d~F\xf6&\xc4\x831\x86\x97Co\x83\xeb+\xe0\xfay\xd5:\xf5\x0b\xd2/I\xf5\x9fX\xbd\'\xd2O\xc0\xbd"\xf5\x1fA\xf5\x1fY\xf4\x9fI\xf4\x1fQ\xf4\x11\xe8\x8b\xd0\x92\'\x06\xe7`\x97\xe0J\xe9\x12|5\x10\x86\x81(o\x81\x83=\x0f\xca\x17\x98\xdfGhi\x1a\x18$bG\x86o\xc1B\xcdHAxA\x86!\x084Bq\xa3y|\x1b\xc4\x1a\x19D\xe9\x08B\xb3\xb3\xa6}\xa161\xb8\xcd\x8b\xe9\x92lI\xb6\x13\xb3\x16\x16U\x10\xd3F}g\xd3\x97Hu\x0b\x14O\x0b\x8e\x84\xf8\xc2\x10\x84\xcc!?\x01QQ\x08h%\rF>\xd1\xa3\xf2#\xd8\xd3\xd8\xd7\xc8\xcaI\x14\x14\x84[Q\xd1\x87M\xa1\xee5/b\x10J\xca\x8db\xbc\xa8\xac\xe8\xc2\xa1\xb4DB\r\x131\r\x10y\xa5/\x02:!\x8eF\xa1\xd8\x95D%$%$\x10\x9d\xc4\x91\x92b\xdb\xd8\xe8G\x01$\t\x08 HA\x04\x10A\x04\x12I\x02B\x08\xc8K\x8dG\xd8O*\x02I\'\x90\x01\x04\x92}\x84\x86\r^F\xa1\xfb\x86\xaf#G\x91\xfb\x87\xec\x1a\x8d\x99\xf7\r\x9e+,l>\xe3\xee/\xd9Br\xca/\x17\x94\x14\xec\xa0\xbb\xc8\x9d\x0cm\xe1\xe2\xa5\xd8\xac\x82\xa2\xfcb\x05\x1f\x16\xc4\xde\xf0\x14\xe8X\xc6\xc6\xc7\x82\x08\xef\t\xe50\xd0O\x0b\x84\x10\xd3\n\xec\xb9\xed\x10bb\r\x89\x94\x80\xe8a\x06\xee\np\xc0\xeb\x05\x03\xa1\xb2\x8d\x1e\x17\x05\x96Q\x19\x08B|W\x10\xa2\xb0\x95+\n\'\xec\x9cY\xa18\x99\r\xb1a\xee:\x1f\x15\xc9\xbcY\x8b\x8e\xf1D\xde\x04\xcb\n)p\xa1\xb1y\x93\x08\xc3\x04\x13!\xcc\xb9F\xc7\xccN\x9a\x1a\xa3B\xc1\xb0\x85\x96\x84\xa0\x9e\x14\x10\xb2\xf2\xf2Lu\x85)F\xca&B\xbcXB\x8d\xe0\x98\x98\xd2\x11\x83a\x86\x1a\x98o\xe4\x81\r\x10$@\xd6\x0c\xa5e\x15\x94QE)qJ^\x17\x8c\x1e\xb3HX^4\xa18\x9b:W\x82t \xa4Eb2\xc3e5\x89\xc1\t\xe2Q\xc0\xa8\x86\x16!8\xb7\x81\xb0\x9e^I\x94\xb9R\x94\x82\xc5)p\xc6.\xc4"\x9a"\x1a\n\x10L\xdeo(\x86\x12I\xb0\xd0a\xe2\x97\x0f\x17\x85\xe1JR\x977\x0ck\x14\xa5.)JR\xe1111\x112\r\x1b+)\x1aq\xcb\x82\xc4:\x1fb\x16\x84BQ\x84&\'\xe0Z)G\xc1\x10\x1212\xe2\x8cbs\r\x86.)D\xc4\xc7\x8b\x8a\'q\x04,R\xe0\xcfN\x16\xc7D\xe2\xb9R\x94\xa5)\x7f\rs\xbc\xb5p\x84\xc5.\x17\x02\x08 \x8c\x90T<4Bf\x94]a\x13D:\xc2\x13\xc3^\t\x05\x8a&\'\xce\xe0\xa2k\x83\xd6(\xd9K\xc51\xe2\x97\x14Ma+\x93\xf6\xb3J\xf0\xa8\xb0L\xb8\xa5e\xd9p\xa5\xa5)q(\xc3C\xa1\x97\x04\xc4&a\x07\x94\\\xde=|\xac\x9cP\xb0\xb1\x08B\x10H\x83 \xd5\xc3\xcc(\xbe%\x89\x86\x89\xc5r\xb8\xa2xo\x0f\x17\x17\x82\xcfd\xc7\x91\xe2\x94\xa5\x9c\x02|)K\x9b\x96\x86\xc3\x0c\xb9\x18\x84!1<\xe2p\xa5\xcd\xc2/\x19\xca\x10\x84\x11\x08$$,N0J\xe2\xd1\x06\x86\x89\xc13y\xd1<\xa2\xfcw\x08\xa2be.g\x0b\x84\xcaR\xe1\xef\x0f\x85\xc5\xc2e\x13be.S)J,\xb40\xc3LYj\x13\x10h\x84\xf8)K\xf2\xc2\x13\x10K\x1330A"\r\x0c0\xc3CCD\xe1>\x04Qu\xc1\xe1\xfc+4\xb8O3\xe1\xeb\x94\'\x1b\x8aP\x9d\xc2|)JZ\\\xc3a\xa1\xd6\x86[\x93\x10\x84!\r\x9b \xb8\xd2\xe6\x9e~\x08L\xacA"\x99!\x116$%xA\xa1\xe1\x91?\x89a\x08Y\xb9\xf1\x97\xf0\xa7\x85\x85\xc1\xf1\xfa\x18\xc7\xc1\xf2\\h\x98\x9d\xc7YYLYC\x1a\x1e\xb0\xd2\x1aC\xc3\\|\xe6\x8b\x8a\x08\x90s\x99\x9cca=\x0c8\x98\x91\xc5\xd0,\xba\x8b\x14z\x0bq\x861\xdc[\x8b\x18\xea/E\xea\x9d\r\xcd\xca\x83\x0e\xa7\x10\x84!\xbdA\xfd\x03\x06n\x1d\r\xc1\xe9\x82\xd8G\x0c\xce\x8d\x90\xd5&\x16[\x80\xd2U\x002\xc12\xc6T\x1eg\xeeA\x1c\x15\xf2jgl}!pW\xc4E9\xf8\x8e=\xf30[\xf3\x16\xf3\xc5\xc1;\xf36/R\x80\xa7\xea\x0c%q(Ed\xd5FL4\xc0\x13C\xb6\x07\x12Q\xefo\xfc\x98\x8dJ\x8e#\x9f\x83\xf2Gc\x06\x9f\x18\x97\\\xf1\xcaW\xa3|U\xbf\xd4u\x9fP\x9b\x19\xec\xcdq\x88\xad\xc3\xdb\x18\x07<\xdc\xd8Q\xba\xf7\x08\x16\x85w\xc43\x03\x0e\xe2\xacnc{\x88[\x97\x98\xc7x\x9eWq\xd8\x9c(\xf6\xe6\\0\x97\xaa\xf0\x1c\xb0\xa7q \\\xd42\xca\xceg\x7f\x99z\x87\x15,r7\xcc\xde\n|\xc4b\x1e0\xc2\x8d\x989\x88\xca\xf1|Ll \x1e\xee\xb1\x08\x06\xfc\xee*wX\xd4\xa4Ug\x98W\x1e_\xc4{6\xb8\xcc\xb2j\xd7/\xb82\x9c\xd9\x82\x05<\xe7\x98\xdd\xcd\xdcF\xbcL+\xf1P\x14\x14i\xa72\xeas\xfb\xcb\xbd\xc9\x9b\xf1\x1a\x8f\x17\x18-\xba\xd9R\xbfGX*"\xc5\x8c,Z:\x0bqz,]\xe3\x16,X\xc7qe\xdfOP\xcc\xab\x95\x99]\x0e\xf0\x7f@\xdfK\xcc\x1cu3\xd0\x83p`\xc2\\ \xd4\xbd\xcc\xa6\xe6 \x06[\xdb\xc4$(\x97-A6b*\xc4\xaf0p\x0b\xf7\x13Q\xf2\xe2:A\xdb\x89P\xaf\xb2`\xddz\x8a\xb8m\x8d\x07?R\xecK\xec\x1b!]\xf1({\x98\x00\xcb\xeaZ\x1b\xc4Bn\xcc\xde!\x88n\xe0@h\x14\xbc1kC\xa9jl\x15\xfc\x7f\x0cY\xba\x08`\xca;\xb0\xbf\xb0\x07\xc4\xb8\xb7l\xdd7\xcb\t\x82g{G.e\xe1!\xe6\t\xcb\xbb2\xbb\xc9\xa6SS^\xd1\xbbu\xb8\xf8\x8c%\xe7\x93k\x07\x80.\xb7\x08w+\x87\xb4\x13m\xfe\xf2\xf5uu/\x08\x96$%\xccS\\\xeaX?\x9c\xa8[\x8fvT\x1c|\xc3\xa1W\xdbQ\x8f8k\xaf\xed<\xb6)\xd5(\xa8\xeb\x1a/I\x7f\x88\xaf\xa9\x1c\x8d\x8d\x7f\xbea*\xf4\xdcz_+\x19{\x05\x87}\xc2\x03\x1b>\xa2\x03\x84\xa9\xb1\x82\xa5\x83\xbd\xc3\x1b\x15l\xdc\xb9+>\xe6L\x8f\x15\xd9\xcf\x88\xe3\x15\xfbA\xc1\x9fR\x98\x1cU~e\x14-\xfe\xff\x00\x89\x98\x7fp\xc9B\x9e\xd3\x88TY\xa7\xc4\x11\xc3p\x14\x8f2\xce\xd6%\xa7\xd4\xb2\xaf\xa4X\xa4[\x8a?H\xb7\x16/B\xc5\x8b\xd0\xb1b\xc5\x8e#\x88\xbd8\x96w\x86zWO\x9e\xb5s$:_S}M\xc0\xc7\xf7\x0ct\x1b\xe9\xa4\xb5\x9b\xb3\x02\xad\x95\x85\x1b\x99\x06\x0bp\x83a+\xcc\x01\xc6\xfd\xc4U\x03\xca\xc56\x1b\xdeS\x8eO\xde\x05\x9c\xf7\x85\x1a\xb8\xc5\x97\x1c\xf6\xcc?\xf2Z\x0b\xb8"\xf6\x97a\xab\xaf\xa9\xa2\xdf\xccY\x17R\xfa\xba,\x88<\x9e"2\n\xb8j!\x14N.\x19\xea\x15\x0e\xf5\x9f\xcd\xc6<\x83vX\x1fV\x91\x86\n\x07\x83\x98=\r\x93\xb92\xb3\xb7)\x8c\x14W\xc7\xed<\xa4\xd6\x08{fbr[LS\xb2/\xdc\n\xd1M\\\xf0\xceq\x02U fU,\xdfx\x12\x96\xddL\xeb\xa2@4l\xaa\x85Y+>\x19M\x94@\x14o\xdf0\xe3\xf7\x7f\xb8\x19\xa6\xafQ\x83\x16\xc7>e\xdc\xc0[\xc3\xcc\nXMM\xcd\x17\xc4\xae\xd9\xa2 \xda\xe4\xcee\xb4Yu\x99Mv\r\x9a\x88V\x8f>cB\xf5\xc1v\xeas\xee,\x96\x06\xfe\x99U\xab}\xb9\x8e\x8eK\x9b\xa9AF\xefl\x0b\xd4\xc72\xdb6w\xb9\x91\x9b\xf2\xc0j\x8f\x01\xb89vr,\x13\n\x8c\x1c\xd4!}r\xff\x00\xbbC\x9a!\x05\x85\xab\xb4\x17k\xe6Q\x05\xb3\xab\x80F\xd9\xc5m\x99\xd7cs\x0c`3\xd01qz\x0b\x16,X\xb9\x8cZ\x8a1\x8fG\xad\xd1\xd0\x8e\x0e\x87@\x893*\x1dC\xe7\xa0B\x1d\x0c\xc0{\x9b\xfd\x03\xd2)\x81(X#\x93\x10\\\x0b\x87+Z\x85\xd3=D\xd7~Rh\xc9\xda\xc5\xc85\x15\xa35\xde"\x0f\x9d\xcc}\xafW\x06|\xccNo\x12\xb2\xb7\x14\xc7~\xf1\xb5\x8dJ\x8d\xfcJiKo3\'\x9f0\x93u\x087c\x98\xeaj\xe3(\xc2\xe4\xed,2\xc3\xe2\xad\x91\\\x18c\x8c\xb0\x1a92/\xd8\xfc\xca\xa0\nT\xf8\xff\x00\xd8r\xe8\x1a\xcfJ\xd2l\x8er\x97#\xe2\x1a\x96\x8f\x10\xc51\xacYPej2\xa6\xf7\xf3\x16\x9f\x94^\x16\xb8\x89\xfd7\xf30Q\xe4\xd9\x06\xa3\xe1,m\xee\xe3R\xca^\x86\xb5\x03H\xdf\xbdT\xe6\xb5\xfa\xd4\x15\x15\x8f8\x8c\x8d/\x93R\xfb!\xad0\x0c\x07?\x104\xfb\xa4\xb7\x9e[\xdf\xa80\xac\xb7\xacC\xeb\xdfP\xed\xde\xcd\xe2\x08\xa5k\xb5G\n\xd7\xac\x90;p\x86b\xefcVK{D&\x03\xa8\xa1\xa0(\xc4\xa2\x0e\x12\x90\x15\x1a\xaf\x13\x06\x08\xad\x90\xaa\xc6\xabu+4\xee\xf9 @&5L\t*\xd7fa\xa8{f\x06\x17\x9f\x10\x82\xd9\x93R\xe2\x16-\x86\xee;\xe6U0\xbc\xbe%\xdbu}\xa6\xb1\x07\xb4\xf2\x9f\x88\xa8t,\x85\x8a\x8c-\xc5\x8cX\xb2\xe2\xc5\x8b\x16/T\xc4u\x18\xfa\x9cC\xa7\xc4\xdc\xbf\x99w\xd0\x83_\xa2\xe1\xf9\x87Q\xe8f\rC\xf4S\xd1\x8a\xd8\xf1sL\xcf,\xb3\xb3\xd89\x85\xc2^W1\xb6\x84\xf7C\xefY\xbd\xcb\xcap\xb0/\x8c\xcaS\xbdA\xf7Z\x94?\xf6\x17=\xfe\xd1C\xba\xc4\xec\x12\xdb\x16\xa1{\xe6\x14/\x10=\xc8\n?iE\x15o\xb9\x9c\xdc\xd5\xd7\xc9\x15\xd1\xcdJRJF\xd33.\xb0\x0b\xee\xe3\xf9\x86\x8a\xf1\x1c\x85*\xab\x95\x9f\xa0} \xd3\xe0A\xdea\x82`\xc7C#\x1f\xf6\xa0k\x0f\x82\x7f\xe3Bq\xfa\x9f\xf2\xb0\x1c/\xb5B\xac\x1a{\x90_\x7f\x10\x1c\xd7\xea/\x1f\x8c\x06$N)-F\xdf\x11\x7f\xf8\x8b\x1b\xf9b-xs\xa9N\xff\x00\x08\xa3\x7f\xb6R\xdb\xf8M\x17\xf8GG\xed\x8b\x1c}\x85G\xb9\xcb\xc1\x1e\\\xbdbx\xbfW?\xe0\x911\xac\x1e\xc5J_\xd2.\x16\xc8\xf2n\x04p\xfa\xdc\xe0\xec\x00\xe1\xf2EZ\xaa\xf8\x97\x8b\xf44\xce\xdc\xf8\xdc!56\xbbK:7\xa3\x18\x96N<\xc2V\xcf\xc7\x1f\xeb\x86ED\xc7\x84\xd2g\xde\xa0\xac\xee\x98\x87\x1a\x8a\xaf\xe3\x1c\n\xe7\xb5\xc4>r\xac\xe2\x16\xb3\x1c\xca\xeaa)%\x11R\xe2\xc5\x8b.\\Yqj,w\x16_E\x8fA\xfd8\x95\xd2\xba\x19\x97\x0f\xd1\x7f\xa0\x83\x0f\xa9x\x97\xd6\xa5\x99|7cHE\xa0\xe7\xfd\x92\x08\xdd\xd9\xb5\xcd\xcbs\x93:{\xc3?\x0f\x98\x95\xba^\x19g.c\xbak\x1d\xa2\xa0\xef1/u;\xdb\x94Wx\xab\xed\x01\xc7$K\xabj\xe2\x99-=\xa5\x13\x1b\xf36\xf5\xcc\xad\x1dm7,i-\xdcz\x0c\xe7A\x1a\xcaj\x9eee\xa5\x0c\xcb\xb3C\xbe@\xfe\xd7\xea"\xc3R\xeb2\x126\xed\xfa\x0f\xa9\xe6\x85\xc3\xb8x\x96\\\x19\xd4\xc5u\xd2\xf4\x8a\xae\x1d\xaa[?\x8c\xe7\x08n\x1fP\xbb\x0f\xa9\xae?\x10?\xf1;?G\t!q&^r\xae3\xc7\x8a\xcf\x0e\xb2\xce\x11\xec\xa2qO\xa8\xa7\x07\xd4{h\xa6\xb0\xf5\r\xbe\xca|\xc0\x000\xa8\x1f?\xccu4\x98\xb0\xc1^`\x99\x99\xe9\x17\x96)\x83\x14\xca.\x83\xf1\t\xc6a\x0e\x99\x95\xf7\x0ct1\xc41\x98\x17\x05\xca\x84\x02PtFTb\x13\xf5\xa0!\xbdC#\x07\xb4\x0e\x88.\xa7\x82\x19\x94J\x99]@\xa28\xbf$V\x06\xa8#\x1dA\x0c\x1d#\xb9^ j\x08.\xa1 \x81\x98\x10@\x87\xc2\x1b\x81\x89R\xa5J\xe8 \x82\x02Q\x13\x15\x98`\x9c\xc0\x1d\xcd9\xe8\xabn0D\xdc\xda\x08\xc7\xf1\x12& \xae\x823\x88\x8e\xd1\x9a\xcaJ\x13].,Zb\xce#\x17F\xa3?\x11\x8b\x16/W\xdc\xe2\xe1*\xa7\x1a\x9c\xfe\x8b\xe3\xa1\x12T\xa9L\x16\xa0\xf5\xa9X\xfe&\xa0\xdc\xb9\xb2j_J\xdc\xc6\xb7*\x15\xce\x0e\rx\xff\x00\x12\x95^N\xf1\xd6\xc5\xf8\xcf\xfb\x98z\x07yQ\xdb\x89m\x1c\xc7a\x16\xdb+r\xa2\xf5\x06\xeeW1j5\xe6e\xce\x19\x870x\xd4(\xd4\xb1\xdb\xdc\xc9\xbd|\xc4C\xa2(\x1eq\xb9\xc7Y3\x18\x11\xce<\xccX \xcfm\xbf\xc4\r\xe6\xa0*x\x00\xd0>\x96\x9e^q\x18\x9d#\xa4s\x06\xf1\x15}ZA\xd0N\x13HU\xf4!\xa8B\x07A\x1a\x88 \x90\xfb\xc1`\x1c\x906\xccy\x8e#\x80\x07\xcc\xa5!\xf9\x85R\xf5r\xe0\xcc\xa9\xe7\xa6\x9d\x0c\x13\xb2\x08\xc4\xe6$\x11%G\xa5u_\xa8\xc5\xccs\x1dt[\x8cX\xafS\xf7E\xfa\x9e\xa5\xfe\x8e&\xfa\'S\xa70\xe8t\xb9\xb2&:V \xd4\x1b\x7fF\x0c\xb7=\x08\x92\xe5\n^K-\xf7\x95\xc6\x0c\xbf\x8f\xe6P+\x1f$m\x9d\xce\x05\xd5\xe6,\x01^\x03\x89`v\x9av\xcc\xe3\xbcv\'\x12\xff\x00\x10\xce\r\xd0\xe0\x88\\\xb8\x86Y\xe0\xe2e\xe5\xedp\xbd\xb8\xdc\x1b\xbf\x13M\xddq\x05\xe719 \xb2,b\xa2]\xe2\x0bc\x10$g\x85\x11R)k\xe5\xc7\xe0#\x92,\x9a\xa0"fz\xbd\x9b_\x96\x9f\x99\xc4c\xd1\xa4\x10A\x13;\x86\x0b\x9c \x84\t\x94\x12\xa1\r@\xa8B\x89P\xdeb\rO,\x1e\xf2\xa9M\xdb\x05Q+\xcc\xb9)\xf7*\x00&PO\x17+\xb6\xab\xbcJ\xcddP\x19\x1c\xd3z\xff\x00j[W\xb0\x82\x03\x86X\x12\xec\x8b\x14U\xd4c\xd0\xb9\x8cC\xff\x00#\xab\xe9\xa9TK\x8b\x89qz1e\xd3\x16\xa2\xd1\x16,\xbcE\xc4X\xb5\xa9u.\xe5\xdc\xae\xa1\xff\x00\xc0a\xb9s\x8f\xfe&\x18=x\x98\x13\x03)\x91)q\x11\x16\xc3\xe8\xe6\rSP\x02\xbe3\x08\xa0\xd5\xfe#^\xf5\x12\xd5L\x03\xccS<\xcb\x1d\xc3\xa7\x9c\xc0\xdcKq\x89\x86\xad\x8c\xf5\x03\xff\x00e\x06\'e\xee2\xe9\x9e\x0b\xa8\xca\xc0\xf9\x94\x19\xc3\x05\x05\x8c\xae\xf2\x91\x80\xa1\xd8*\x17"\xeaU\xdaP\xfc\xa6%\xb0)\xfe\xf0\x11^\x8cw\x040\xc3\x1d\xcat\x9a\x82\xa0y\x81\x02\x10\x81\rB&\x07\x942\xf3\xd1o\xff\x00f\x90"M\xaa\x08\xd1\x0fq\x04\x00\xdea\xee\x96y\x9d\xb6\x006\xc0\x13J\xc2Ir\xd0xU2\x19[\xcaG\xb1@[\xc1,}\xc3\x98[&\xb13\x92\xe3qE\xa8\xb1z\x18\xc7s(\xc5\x9c\xa3\x19\xccX\xc62\xe5\xdcX\xc5\x8f1b\xf4\xb7\xbcb\xe2_\xdc:y\x8c\xdc%t\xd7R]\xf4\xba\x97}.\\%Ka/\xa9\x0f\xae\x97\x1cd\x8c\xe8\x0bPO\xccwv\xd7\xf6\xca\x85\xda\xa5\xb9kQ\xbd<@\xb4\xf9\x9e\xc7\x99\xc2\xcdC\r\xc1h\xb9\xb7\xf74D\xbb\xc4\xc5\xa9\x9e\xb3p\xd3\xc4\x07N\xf1\x86R\x95\x97\x95~nZ\xefs\x8f7\x0c\x0c|F\x9f\xcam|\xeaW\xa5\xba\xaf\x8c\xfe\xe4o;`(P\xaa\xd2\x98Ov`a\xe9\x9f\xc7\xf1\xd5?A\x10\xe6"\xe6s_38n\x04\x0b\x81\x88 \\ E\xa2V"\xc5\x8bq\x85\x8f~ff.-A\x84&\xff\x00A\xfa\xccM\xbf\xa6\xe0\xdb\xd0\x8d\xca\xb8\x15\xd0jn$\x11\xcc\xc9\xf2\x94\xa8\xec*\xc3\x83\xfc\x9fS\x01ov\xfb\xc4p\x13r\xf4y\x8d\xa6/\xb4\x14\x9f\xd4\xb1Gb\xa1C\xb1\x04j8\xf9\xe2\x0f\xe3s 0\x1a+\xfd\xfe%\x01\xee\x1e7\xfe\xb80\x1b\x8b \xb7\x8f\xf7\xa8m[\xae#o3\x92\xa5\xb9\xd4)x\xff\x000\xed\x17\x046\x162\xf0\x98"\xe6.\xe3\x17\x16-\xc7-\xc5\xb61c\x0b\x88\xb8\x97\x16;\x8b\xd3\xe6,\xb8\xb1s\x18\xc5\xa9tKa\xa8\x10\xe9pe\xf4\xbe\x97\t}.\\9\xfdc\\\xcb\x97\xa81\x95\xd5r\x95\xb0jS\xe8\x9f\x1a\xcc\xa4\xda\x1d3Y\xfe\xaaT\xd5\xe13\x15\x13;\xfc\xc1\xc1\xbc\xccR\xcf\xee:G\xb6\x98\xe8\xc6\xe3@\x15~!\xf0\x016\xa9\xc0\xd4\xc0\xa8e\\\xf7!\xe4\x9d\xed\x8e\xf2\xb2\x99\xbe\xc5\x9bo\xf1\x15\x07\xdeb\\\xb5\x1a\xcc\xb8\xd7\xe25v\x92\xbb\xccMW\xa7\x9a\xcf\xe6_l\xb9@pW\xca@\xa4\xba!\xd0c\x1f\x86\x0c\xb2^&c\xb9\xa7G)y_\xea\x86ht\x881\xff\x00a\x01\x08N!\r\x92\xd1\xf3\x06\xc2o\x12\xf3\xd8q\x12\xb9K\xd1\x99h\x16\'\x89\x9d)\xbf$\xa9^wq\xbej\xbdK\xc5\x16l\xc9+\xd9\xc4A\xf7\xdf2\x96\xe13\x8fr\xed)\xdf\x88\x06\xd0\xc7f_\xe58\xb8\xfb\x05"\xd9B`\xc7Z\xe46\xf8\'\x88\xd7(\x96\x92\x86/FDi\x15\xe8\xb2\xe2\xcb\x8b.\\X\xb1\x8b\x16."\xf3\xd1e\xcd\x92\xf1\xe7\xdfL\xcb\xf12\xc2\xea\x12\xaf\xccYu\x1a\x9f\xbc^\x97Q`\xa2\xe6\x0c\x19h=5\xfa/\x13Z\x82\xfa\x83\xd1\xccq*SY33?\xcf\xa1\x1d-\x01\xee]\x1f\x8a\x9d\xa6y!8e\xef\x18\xa6/\xc5\xca\x02\xab=\xa6\xd5Z\x88R\xf16>\xe1L\x93\xea\x17\xb0\xe3Fc\xc9\xe6*\xbb\x93\xc3^\xe1R\xff\x00h*D\x05\xeb\xf7\x85\x9e> \xb3\x9a%H\xb9w\x00\x16\xa6\x14\xfc\xc5\xbb\xdc\x16\xefe\xda\x8c\xbf\x81\x9c\x8c\xb3\xad9\xb8\x1f\xd9|FH\xa4\xd1\xe6\xbf\xf6(0\xb1r\xe2\xc7\xa1t\xe3\xb4\x1f\xa1i\x02\x1d\x02\x10f\xc9`\xc1\xbdAG\xb90\x87\x0e*e5*\xbcL\'\xe4 \x16r5(\x8f\xe7\x8cJ\xf2!\x8d\xcb\x80\xc8\xf6\x96\x94\xed\x99v\xd5\xfc\xfe\xd2\xb5\xba\xf7p\x00\xd7\xafp\xdd\xe0\x1e\xa5\xd7\xee\xc5NX\xf8\x87\xb0\xb6-\xac@\xb5}U\xb0\x08p\x9d\x12\xa2Y\xe24\xe6[/\xa2\xf3q\x86\x15\x8b\x16\xe6\xcf=\x17\xa2\xc6\x17\x11\x87\xdc\xbc\xc6\xd2\xe6\xa72\xf3\xd7\x10zs\xd7S/S\xa9\x02_\xdc\xb8t\x0bK\xfd,)\xe6\x0f\xc4\xb9}#6Z \x1f\tJ/\x00\xf3(2\x96\xf7g\x1d\xe4\x86\xb9?\xeck]\xa3\xad\xd1z\xa6/Hf0W|^cl\x19\x97>\tW;|K2|\xc0\xff\x00\xdc\xa4\x8c9\x80F\xe2{y\x8dKq\xee\\\x97\xbe\x02\x07\xd1)W\x9f\xb8*~n\x1b\xc5\xe7<\xb4\x7fs\x03\x18\x8a\xa0\x001\xc9w\xff\x00;\x8b\xd8\xd4\xff\x00\xbd\xac\xc5\x14\x19\xa4\xbf\xd0)pA\xfa!\x0e\x81*\x10\xc4\x16t}\x1a\x08P\xc8\xc3O\\\xae\xa28\xe7\xd8#\xd1\xe4/\x98\xd9m0k\xb2)\xe4\xa4\xbc\xb0\xa8,]M`d\xd8\xee\x13yV\xb8%\x8a\xac+5\x05\x0cd\xe0amU\x8e\xec\x15\xab\xf8\xdc\xb5\xbc\xe3\xbe\xa2\xe8\x08\x1f\xe6\x11J\x95\xcf\x89\xb1\xcdw\x99\xaa\xe5\x81\x15\xcb\xc4X\xb2\xe3\x0b.,X\xe3\x8b\xe8\xbd\x16,X\xb2\xf1/\x12\xfao\xa1\x96U\xbd*T\xa9_\x89R\xa5G\xa1\xd7_\xa7\x89\xc4\xe2\\\xbb\x83p\x83\xfa\x06\x11s\xe2R\xb3)vXA\xec\x12\xf8\xbf\xf9\x11(0\xee%\x1es\x19\xc3\xfc\xc3T\xec\xc6\xe1\xa4\x9axc\xd3\x98\x0b\x16\xea\x01\xcb\x12\x97y\xdc\xca\xddS\xea\x14+\xb4k0\x11Q\xd9\xd1(\x07h#}\xbb\xf3\x16J\xcf2\xbd\xf7\xde&\x86\xa2\x99n\xcb\xdce\xd5X\xa8\xc6\xb0c\x98W\xf6\xbf\x01\xfd\xaf\xd4X\xf1\x0f\x93\x0e\xb5\x92\xf93\x15\xa2\x96G\xb8\xa2\x83/\xab>\xd0fQ\x07\x88!\x99\xb3\x84\x0e\x87\xe6=\x08\xeb\xa1t\xa5\xd6\xc9k\x89P\x97\xb6x\x97pVK`\xe1O\xc5\xc5J\xfb\xf3\xe27\xcd]\x9c\x10\xd1\xc07\xda\x0b\x95d\x99\xed\xf7\xe5\x96\xe4\x0e\xd0\x83%^Su\x01^\x1eRQk\x9f\xb9\x98x.\x89PhuL\xab\x85U}\xb38\x89G0\x89p\xcd9\x94\xa2\xe6/\xc4an,Y\x91\xd1u\x07\x13Iqc\x0f@\xb9\x8b\x18\xd3\x1e\x86\xba\x1b\x95\tR\xa5J\xc4\xa9Q.\'Vk\xf5c\xa7\x13Pq.\x0c*\xfc\xcb\x97\xd2\xea_\x12\xf6zA-W\xc4\x1c\xb4\xb1?\xde\x98\x8d-\xe6\xae\x1d\x9fo\xfb\xf1\r\r\xfa\xc4\xd4W\x9fP\x97\x0f\xe3Pq\x9cG.`\x14\x15^H\x9fD\xe0\xb9\xe6+\xe1\xb9\x97\x8aX\x82\xb9\xee\xca\xcb\xe3\x88\x92\xaf\x0c\rd\x83\x9e\xd0\x10\\\xd7i\\\x97\\J\xcbs)\x92\xfbNoh\xa8o\x14\x04yr\xfe\xf0T\x17\x1a\xda\x07\x07\x96\x0b\x80\t\xf2V?\x7f\xc4\x1a\xae"\xf9\x8e\x102\xe2\xc5\x8b2e\xbd\xbf\x10C\xfa1\r@\x84\xd4&Du\xd29e\x0c1\x00\xad\xa6\x1cu\xe3Q\xd5\x9a\x95,S\\\x7f\xbf\xf2Zd\xc34\xf6\x9a"5\xd9\x9cZ\xafz\x95\r\x8a\x1c\xde.v\x1f1\x01\xabX\x12\x8b%\xe7\x98i\xc1\xfbW\xa9A\xae#6\x85\x0f\xcckj\x85\x0eLC\xcf0\xb76\x1a\x91\xac\x96\x0c\xdc[\x97\x18\xb8\xb1\xa4X\xc0\xf4\xb8\xc3\x16\x8a,X\xa2\xdb/\xf4\x84:\x19\x81\xd0\x9cg\xaa\xc5\x96E\xb2-K\xf3\xd0\xc7\xe9\x19}F\xe5\xf4\xe6,u\x12\x0c\xaf\x12\xbc\xf0\x1ap\x15\xf8\x96HW\xf4\xdf\xe6\xe5"7\xb3\xbc:\x18\xb7R\x80X\xa3yw0\x94\xd2\xc5\xfaB\xf7|J|5\x10\xe5}\xc7[n\xbc\xcf\x96X\xddF\x94\xb5\x8e[\xf8\x86\xecb^\xd6*&\xa5\xe6%Rj$\xa2\xdf1\xf6*\\\xd3g>\x15\xb8+\xb6\xa1;fbL\x06\tP\x96\x9a\xee\x8d\x7f3\xc2\xed\x83\xc5_\xf2A\x8f\x11E\x07\x89}\x18\xb1\x1e\xb0T\x1dC]\ru"L\x10fm\x9a&e\x16\x0f0\xd7\xd9\x19\xc1N^\xd0Ga`\xbb\x8fm%wIS\x87\n|Lf\x00\xee8\x8a\xca\xe0\xef\xf3\x88\xf6\xba^\xfc}E\xd0\x01{\xc7\x88\x0b\xc5\xe2\xb6Kl\xf9\xa72\x8e\xd9\xd4\xa1\x80n`O\xcc\xaf\xebb\x1d\xeb\x0f\x17\r6\x13\xa2",Y\x84\xb8\xc2\xee\\!\x85\x8f\xc2\\_\xa8\xb1\xfa\x8a,\xbf\x12\xfef\xaf\xa9\x08B\x0c;\xf4\xe2\\X\xea>\xe5\xc6n3];1\x06_\xea\xbe\x97.\xe5\xce8\x97\x89FR\xe5\xe2a~gg\x88\xfb\x84\xe2\xadV2\xe6d\xe5\x83\x8b\xe2\n]\x0f$\xccu\xc6Yv,\xce \xa3 \xf9\x89%M\x831\xbe\x07q\x1bO{\x97P/|\xcb%\xce\xe5\xc4\x1a\xadL\x19\xfc\xcc7r\xc4Lf&\xaf\xe2\x1a\xb3\xdf~"\xcd\xe7\xb4>\xed\xee:l8\xefU\x17\x18/\xcb_\xc9*\x01\x8c[b\xdb\xd9|\x0b\x10U: \xfc\xff\x00QA\x83qW0\xb4\xb9r\xe2\x8b\x89\x8e\xdd\x01\xa83\xd4!\xb9]B\xbfE\xdb2UCpo\xc4\xb8?d\xa3_\xb8\xb4O\x90\xa9N@\xf3\xfe\xff\x00jd\xf3\xab\xbb\x88<\x94_3\x1bw\xed}K\x02\xafu\xad\xf8\x85J\xcew\x1d\xf2\xf2L\xae@\xbb+\xfd\xe2\nQ\xf8\xcea\xc1Y\x037\x01UA\x181B\x87%\xcb\x15\xda\x1a`\xb2\x1ab9Y\x97\x86b\xc4\\\xc5\xa6[\xd1ioC\x17.,U\x97\\\xc6\x1cF_Rs\x06\x12\xee\x0c\x1a\x87\x96%\xf4-\xeb2\xe5\xe2,\x7fG2\xfa\x92\xea\rb\r\xfe\xab\xcc\x19qjV\xb31g0?\x13Q\x05\xf1\xaf\xe6\x15\'\xa4&\xe8k\xb4X\x14\xc7\x11Ft.\xce\xf1\xc4M\xf7\xb9\x94\xb0\xec\xc0b\xb8\xef*-\xfb\x84\x15\x9e\xd1SI\xcb\x05%\xe2\xf3r\x90\x1a ;\xc3(\xba*/\xbdy\x99\rS\x12\xea,\xe3\x92<\xb8}C\x13\x8d\xe2b\x8b\x8f\x04\x12\xdar\x91\x05c\x97\xc1\x9f\xe4\x89w\xb2\x1b\xfbd\xd1\xb7\xef~I\xd8WO\\~"\xc4\x18\xb2A\xb2\rK\x83\x16,x\x8e\xa5\xf9`\xe8\x1fs(\x10\x84:\x07J\x83\x13\x14\xa5zb\x82L\x18\xfe!Y\x1fQ\x023\x7f\xce\xe6h\x16q\xc4\xb6\x8f*\xaf\x89\x81\x90\xee\x81U\xdd\x9b \x03U\xe2T_8\xb7s\xb8X\xd5\x7f\xbc\xc2\\\xd7\x96\x00\xa7MB\xb6\xddx\xf8\x99_l-\xf5\xae\xc4sS&P\x03\x9d\xd70\x16X%H_@Z\x8b\x1e\x85\xc1\x96K\x97.+\x89r\xf8\x97\x16=\xe73\xf3*\xa1\xd2\xf8\x97\x8b\x97\x06\x0c \x8fh\xc3\x17\x17\xa7\x99s}w]._O\xe6{B/\x1d5.\x1d\x17-s\x11\x19Z\xdfym\x90-\xbf\xf7\x99]\xdd\xa0g\x84\xb9\x87\xe2\xe2\x98\x0cE\xbaQw\xc4j\x0bx\x8a\xab6q\x02\xe8\xe4\xd9\x00K\xa7\x10DYD\xc2]\\\xe6l\x7f\x12\xc5^\xdd0\xe5\xff\x00\x91\x17\x9cT\xb2g\'\x88\x01\x8f\xb8\xe85]\xe5Z\xed\x1c\x05\xeejM\x83\nM\xe81\xde\\\x7f\x93\x9a\xfcT\xb0\xd1\xb7\x80-\xfcJ\x8e\xa5\x1b\xdaO\xb8X\x08\xa1\x03\x15A\x83\x08X\xb1\x96A\x03\xbc\x08*\x10\x86\xe1+\xa5M\xca\x97KI\xdd\xe9\x02\x81\xef\x01\x1aB\xd0\x1dK\xe8\xac\xb9{J1T\x1b\xb1uM?\xef\xfb*\x06\x8f\x84\xcdt\xad@\xc8\x99?\xb8\xdbm\x8d\xc9\x97\xc9\x89\xa0Xvs\x00e\xc0d\x86\xd09u\x08\xb7DG\xd0Z\x84.a\xccwY\xa3\x99\x98.P\x8b\x16]GIs\xba\\c\xb8\xc5\xd4b\xc6w\x9cF\x89_\xa1\x8b/\xa5\xf4\x0c\xb9vK\x97/\xf5\\\xbe\x84\xb9w\xd6\xfc\xc1\xe8\xb8\xb2\xf3\x16.\x19[\xf7\xd0|\x06^y\xb6\x89`\xe2\xae[x\x96\xab_S\xbf\x00\x8dE\x97\x86{\xfb\x86hRE\xcfx\xed\xde,\xc8{\xed\x15)W\x8ee6l\xeds9c\xe6`\xb6\xf8\x86<\xfb\x94\x93\x87p\xb8\t]\xa1\xc1\x1a\x05\xba\xf3\x08\xb6\xb3+E\x08\x8805Bw\x8b\x91\x0c\x1d\xd9\xacv}\x05C\xc2\xaf\xe5PO\xc9\xf8\x86NPWp7\xfe7\x16z8e\x06\xe2\xa87\xfa\x16\xf1)\xda\x08\x17\x02Vg\x10\x84!\x02WB8u\x0b\xc9R\x89\xc2sv4K\xa4)\xab\x18\x8bk\xad\x1eHS\x0f\x19\x98@\xb8\xc7\xaf\xf7\xf3\x0f4\xe3)\x8f\xf5B\xd9l\xcb\xee\x11\xc1\xc46\xd6\xf9\xc72\xc3X\xf53p\xb35\x16\xd7\xb6\xe0Q\xac\xdf\x15\x0b\x95e\xe2\xb1\x1a\tk\xb3\xc5\xc0\nU\x1a\x8cf\x13\x89}g\x10\xc1\x14b\xe7\xb4\xb9\x7f\x12\xfd\xcb\x97/\x12\xe5\xcb\xe9P\x87@\xb9u\x1c\xb1\x97\xa9\xa9\xcfK\x97\xff\x00\xc2\xfc\xfe\x9b\x97\xd2\xfa\xf1\xd5}.T\xe5\xedK\x16w\x98U\x03\xbe)`\xd1,\xbcKvL\xce%\xc5&"e\xa9Z\xfd&s\x93{\x8c\x14}\xc4\xd0\xbbq)\xc6\xc2\xee\xd2X2\xb6\xf8 \xe5~\x99\x88\xdf\xb4\x01c=\xe0\xaa\xda1Z\x1e{JZj\xe3\x03\x9e\xf4\xc7\xbe\xa3\x1a\xc4\xb49N\xfd\xe2\xdf\x86K\xe13\xfcGU\x18\x05\x81y\n\xcf\xc1|\xc0L\x10Lw\xd7\xf1\x1d\xc1\x8a\xa0\xd9\x14!\xa4\xb8\xb2\xe2\xf4\xf0A\x02\x04:m\r\xfe\x8a\x953S\x1fC\xe5\x9b\xb1\tAw08u\x044\xeb\xf1\xfbEye\xe1\xf32\\\x86?\xdf\xbcj_\x17\xb2\xa5t\xbcw\xb8\xf4o\xe7P\x95\xc6{\xcd\xe5\xd3\xeea]\xf8s\x08\xc5#\xee)\xcd\xe7\x12\xa3\x0b\xf8\x8b\x8dx%\xb9\xad\x99\x8f\xce\xe0\xcc\xb8\xed\x9d\xe54^\xe5\xbbF\x15#\x1at\\\xb8\xe1/\xcc\xcdo\xa5\xf4a\xd0eJ\x8bl\xa9OJ\xfd\x1c\xce\xfd5\xfa.\x13\x99\xcf\xff\x00;\xe9pe3\x95M\xd9\xe2W\r8|\xc3\xb6\x1b\xc3\xb8:f\x1a\xea\x0bx\xdc\xca\xc6\xe6\xa27\xf9K\xd6\x08\xa6\x0fi\x94\xe0\x84ar\x0e\xa3\xc7\xfc"\x91\xc2r@RU\xda\xb7\x1f(-\xc70\x08\xbb\xcd\xe3P^E\xe2\x08a\xbbs*n\xb1\x1a\xd2\x8e>\xe3\xaa\xd2C\x90\xc4F\xe4\x1b\x1dLUT\xf2K\xb2\x07\xbdKsx~IJ Y\x9a\xe2o|%\x1cQS5\xea:\x05g\xb4]w|\xfb\x95\x06\xf4Ek\xc4|; \xb9D\xe6\x1c\xc2\r\xcdf\\\x99\x85DYpf\x90\xcc\x1a\x86\xe2\xcc\xc1\xe8O\x1dYQ\xfd>:/C\xf4\x06nn3s\xe6Zt\\\xbct\xe2\\\xba\x97\xd6\xe3*\x8d)\x95\x99a\xbc\x03\x15\xdc\xab\x17\x0fM\xe6\x1f1\xfby\x8e]\xa1\xe3.\x81\x06\xf0\xd4$q^\xa7\x8e\x0bi\x14+\xf6@\r2\xca\xebU[\x80#1\xa4\x8f\x97\xecq\x048x\xd7\xfbP\x890^\x1c\xddKUqxME\xb9\x10\x9e\x04\xb7\xea\xfe\x98\x82>\x00\xc8\xf6\x9b~J\xf6\x10\xb4\xf4\xbf/\xfd`\xd4!\xd0\x1875\x14"\xe6%\xcb\xc4\xbf01\x03\x10\x89\x12V%@\xb8\x15\xd6\x98\xf5a\xb9T\xdd7@P^&\x11\xb7\x88\xbb\xec5\xcdG\x01V\xe0\x96\xa86y\x8c\xdb\xe7\xf3\x0b8\xc9\xb8CT\xfa\x18`4\x07\xe2 L\xb5\xc4\xb5v3\xee,\xdd\xf6\x8bB\xeb\xdc\xca\xd0\xe6\xf8\xf7\x10\x0c\xab\xc4\xadz\n\x12\xe2\x1a\xc0%\xa8\\\xc0\xcc\xba\\\xb87.\x0c\xd4\xba\x86]\xc3\x13p\xe2\x10\x97\tr\xff\x00A\xd0f\xba\x9f\xc4\xbe\x9ez\xdc\xe2\\\xf7.\xe5\xe6\x1e \xd7B\x8c\xbdE\xcc\x19w\xe6\\\xbe\x97\xb2\xd5;K\x7f9\xbff\xaa;/,R\xa9\xb6\xea}\xa5\xd9\xbb\x9be@\xef;\x8c\xb6\x03"\x00q\x0b\xb4\x01\x15\x9850z\x95\x88po\xc4w\xb1\x9e\xcc^ez\x86\xc8\xf2\x8c\xa56\xe9\xc4\xec\xe3%\xdbq\xd9R\r\x9d\x81\xfb/\xa9\xacAD\x11\xf9_\x81\xfcB\x10\xe0~\x1b\xfd\xd6\x06\x03\xbc\x0c\t\x02@\xf7\x81\xef)\x01{\x9e\xd2\x9d\xe5e{~`@\x95\x02\xe0\x12\x89R\xba\xd4\xa8\x9b\xfd\x06\x0e\x85\xc4\xbfR\x9fX\xa9\xb1\xbdG\x15\\\x1a\xb2\xa1\x11\x91\xdb5\xae\xf8\x95\xb8\xcc\xb6\xbc5\x8cE\x0f\xe31\x15\x96?\xeb\x89\x02\x8a\xa6\x8cK\xae\xf7\xa9\x83\xf8\xa8\xb5\xba\x8f\xf7\x8c\x18\x97\xa3bQ\x1c\xe8\xe6\t\xb9Q,\x0c\xe2Rt\xbe\x81\xdc5/0\xd4\x18@\x87K\xe8K\x9f=9\xfd\x0c\\M\xcb\x9c\xcb\xed\xd2\xc9}\xba^f\xa70e\xcd\xcb\xe9\xe6Y7\xa8\xe3\xa5\xe3;\x8b.V\xe6\x15s2\x96\x85\xb6\xccr\xf1\x15\xcf\xf0\x8d\xdfx\xb0\x98\xe2\x0f\x99d\xa1\x9f\x94\xb2\xe1\x05\xda\x0b_\xcc a\xe1y\xf1\n\x8b\xaa\x9eP\xd1+\xb4\xa1\xf11"\xd6\x15-\xa9\xd7h#t\x8bU\xe7\xfd\x99C-MS5\xb9\x99\xe0\xb7\xf2\xbe\x89\x83\xf9\x8fU\xda\xde\xc0-=\xd5|\xb1QZ_\xcbs\xc9\x03\xde\x03\xdc\x04\x0fh\x18\x18=\xe0;\xca\xd6\xe5\x0eb]\xb2\x90a\x95\x02\x05t\xa9X\xfd\x06\xa6\xba=6\x8b\x05\xcb\x08&8\x15\x0be\xf5ZV\xcaa\xcc%\x01m\xe60\x95\xc7\xe2P\x85\xf8\xdc&\xf3\xfcK\x97Z!\x1e\xa3l\xc9\x87w\x15o\x9f1\xc1\xad\xbec\x92\xd4\\?\xeb\xe84\xb4\xe41(h(s\x92\x0b\x14\x06.w\xd7\xea\x183\x06\x97\x06\x0c<\xcb\xc4;B\x0c\x0c\xc2q\xa8\xce:i\x9f\xb7M{\xe8\xf4|\x91\x8f^:\xdfK\x8c\xf7._\xe8\xb9\xc4\xb9u,^\x8b^\xa5\xd4\xb9[\xcc:Im\xba\xb8\xab|E\x12\xd2\\T\xabX\xa8:\nx\x86\x1d\x0b\x88\xbf\xd7.\xbfR\xf8\x80\x1c\xfd\xc2GO\xcc\x04\x94\x1fr\xbe\x9b\x88@\x01\xb9\x9ciR\xb3\x06<\xce\xf9N\x98\x81@X;\xb5\x8f\x984\x94}\x87\xfd\x03\xe2;\n\x96\xe4$\x0f.\xa5P\x03YV\xd9?\xdd\x12\xb7Vb\x1e_\x98\x12\x00\x11\xc33m\xca\x9c\xc3>R\x84\x18r\x9e\x89^\xe4zP@\x95\xd0!\x15\x8f2\x89W*T:;\xe8#2\x86\xc8R\xe5\t\x11\x83R({f\xb1\t\xd9=s6\xdcr\xed\x05\xb6\xaf\xf7\x8fw\x9b\xf5\x16\xf2W\x8ege\x9ee\x1b\xfd\xe2\xcds\x16\x0e"\xdf\xc4\xb3\xcea\xbf\xfb(?\xcc\x00\xc1O\xb8x\xbb\xb2\x02\x83D43+x\x9f\xeb\x83\xda\rCp\xfcB\x0f\x10!/\xa1\xf9\xea0\xc7D\x89{\x8cY\xae\x97Q\x97\x1cK\x8e`\xcd\xb3\xb4[\xe8V`\xd4\xbb\x83r\xe5\xcctX7\x1e\x8a\x86R\x89\xdex\x95k\xe2*\xaf1\xe71\xd6\xa7b\x0fo\xcc\xa9\xbc\xea\x05\xc0\x8f\x13\x15\xac\xc4\x12\x92j\x08\x83P\x8b\xc9.\xb3\x81\x11\xc1\xc6\xf1\xcc\x05Yn\xb3(0\x12\xb7P\x8a\xec\xc2\x14\x96>&\xa8s\x8b\x86k\x81mo\xff\x00!C@\x0fF\x08\xab\xab\xe5U\xef\xc5Kc\x81\x0e\xe8s\xf4|@[\x98\x13\x98g\xb8g\xb9\xef\x0f9^\xf0\xa3\x07\x9c\xf7\x8f\x94|\xe3Ng\xbc!*\x04\x08\x10%TH\x93L\xbe\x8fL\x1e\x85\x86f\x82\xael\xc4\x02V\x88o\x11\xf1\x0b\xdbb\xa5IU\xa12\xbcy\x8f\xed\x8c\x92\xd2Xx\x9d\xf5\x9dK\xf2\xff\x00\x7f\xaa37\xf33\xd9\xad\xc7C\xdf\xbc\nj\x8e\x93q@a\x1b#Z\xa5\x02\\#%\x13\xbc\xb0\x1b\xc6\xe5\xd5\x99A\x0cK\x86`\xc2\xe6\r\xc3\xc7[\x87C\xf4Tb\xe2/K\x8fE\x97\xd2\xfa\xf35\x16\\\xe6\\q\x16\xa0\xcb\xaej_[\xe6\\\xaa2\xb0\xed.c\xa4|\xcb\x95\xed\xb8\xdf\x9f\xccF"\x1e\x93\x1d/\x11\xa5\xba\x99y\xbea\x8d\x98\xf3\x049 \x0e\x12\x9b\xb5r\xa4\x1d\xa9\xcd\xd5\xcdJ\xcc1\xab\xb4\x02c\x10\r.c\xaa\xee^\x1d\xe2\xac\xbe\'X\xc3\xf6\x93\x03\xfd\x89\x95gp\x01\xedZ>Yp\xd6ge\xfe\xc8\xc8\x83\xc5\x0f9\xef/py\x90\xff\x00OE\x97?\x98>\xf0}\xe5\xfb\xc5\xf7\x96\x80\xc4\x14)\x08\x06m\xd6TTD\xb9X\x9c1\xdfL\x9e\x86.`\xb9\x8aZ8\x9b\xa0\xa2\xac\xa7\x88ty\xb5Qr0\xd6H\xa7(\xb8\xb3\x82\xdd\\\xc9]c\xeah\xa1\x9fP\xd5\xd5o\x12\x9c\x13Ix\xdc]\xce\xfa\xc4\xde]E\xbcB\x11:V3\t9 Kk=1`B\x10!\xea\rB\x10\xfd:\xe9\xde&g=/\xab9\xe8E\xf1.\\\xbe\x97\xd2\xe5\xcf\x89\xf34\xcb\xc9\x17\x1e%\xe2\\\xb9pa\xb9r\xb63\xc6T\xb6{L\xa3\xf5\x85\xd72\xf3p\x17\xa4\xb7A4\x03\x96\xe1l\x84.\x92\x96\xdf\x94\xba\xd6\xb6mj\xbe\xd0\xd4\xb0\xd8\x95\x88~"\xb8\xac\xa4)\x970\x1c\x08\x05\x82x\r\xa7\xe0\xfa\x85;G$x[7\x9e\xac\xf8L\x0c\xec\xef\x83_\x80K\xe6\x0e\xf4\xb5\xb8A\\\xcb[\x84\x12,\xb7\x98)\xda\x8frZ\x08@\xe9P\xdc\x1e\x8fD\x89\x1ebb\xea0\xf6\x821\x99\x93\x03\xd2\xb4`\x92\xaeX ;\xcc\xa4\xaece\xcc\xb2f"\xebO\xdcta\xfb\x95\xa5\x9a\x8e\xab1\x97\xb2v\xd9\xf3\x11\xdf\xe6=\xb57ML\x97\xa1L9#\xbb\x8c\tp\x02\x96\xeb\x17\x08\xa2\xc3\x92X\x1b\x8f4\xac\xc2m\x08O]._\x89}/\x12\xe5\xcb\xc4|\xa5\xf9\x8f[\x97\x17\x1d\xe7\xe2\\\xb9r\xfcM\xcb\xcc\xccYy\x8b\xd7R\xf3\x16\xe5\xea\x0f\x1d9\xb9r\xb8P\xe08\xfd\xca\x03M\xc7\xcaa\xb6\x1f\x19mf4.\xf2\xc8\x0e`\x9d\x90\x82\xcaV\xb0!\t;\x7fF-V\xaa\xbc\xcb\xfbo\x18\x04\x03\x13\xb40cP\x00*\xe07X\x95\xb34\n\x16\xf1.\xa2\xbe\xe4\xd1\xf8\x08\xb2\xd3\xd8\xe0\x0ba\xa0K5r\xd7\xa8j\x0e\x0c\x13\x0ea\x16\xb3\xcb1\xee=\xee]\xd0\x1c\xc20o\xa1o\xa0\xc4%W[\x86[\x97S2\xe3\xd2\xea6\x8b(\x82\xc6Q8A\x89B\xcbO\x13gh\x0b\x0c\xd4;Jn\x1aR\xbf\x10\xd3\x94\xc5\xe2w\x953\x98ns.u\x92s\x7f\x11\xd1\x97<\xb3h\xa6b\xed\x18\xe6\xe6\xb3\n\t\x19\x96\x02\x9cs\x07\x18\xa1\xde\\\x19\x95\x9c\xf43\x088\xf1.\x0c\xb9r\xf3._\x9e\x97\x17r\xe2\xdc\xba\x97.]\xcb\xbdK\x97q\x97.,Yr\xe5\xf3\xd2\xe5\xc7\xf4]K\xb82\xf17\x15\x0c\xadJ\x91sc\xe2&YT\x96\xdd\xc1w\x9d\xc7r\xe4\xcc\xd5\x99~\x98%f\x14\x00\x18\xc4E$9\x99GR\xef\xb4\x06\x9d\xc2v\xb3\xc6!\xd8>j\x1a111?\x1cS.cT\xc7\xc4\xc8A\x0fk\x9aA\x05sEK\xd1\xa6v\xc0\x93\xd1}\x14u\xc0\x9b\x9am~\xc2/\xe6=\x0b:\xdf\x93\xa9(b\x0c\x1b\xe9p{\xc0J\\\x06\xe1\x15\xa9^\xf112\x91\x84\xfb\x8e1IT\xc7)\xe8\xb1\x84\xa0\x97\\\xb6\xe5\xe2\xf7\x94\x04\xdc\x10\x8e\xed\x902\xf7J\x11\x11q(\x11c\x119\xaa\xa8\xb0\xd6\x1f\xa9W\x19\x8a,\xe5\xfcE\xb8\xbf$c\x18\x1b\xa6WZ\x82\xe1m/\xcc\x05\x06\xf3\t\xac\xca\xcc\xca@J\xa4\xafyIN\xf2\x95\x1c&\xf3\x03\xa3)\xbe\xe5\xff\x00@c\xda]s\x18\\f,e\xcd\xc5\xfd\x17\x99sR\xe5\xcb\x8b\xde0\xae\xfd\x03.V\xe5\x18\x98\\\xcc\x91V1\xadn\xd72\xfb\xba\x97.e\xb2\xd0\xb9\x84\x84\x0c\xea\x1f(\x03r\x81"\x8b\xaf\xcaR9}\xca\xb8\xe7\x984w)\xbd\xaf\xeaR1\xe2P\n\xfcL,A\x04r\xa9I\x11\xbas)F@\xf1\x97\xf1\x06?\xa8r\xc0\xc7jO\xd9X\xb9v\x1d*\xa7\xceLe\x82\x99\xbd fO=,Qf|\xd3"k\xd4\x183\x1ee\x8eb\x8ec\x97x\x1e\xf0\'$\x07yJ\xdc|\xa7\xbc|\xe3\xe7\x1c\xe0an`.\xe0=\x0c\x13&\xe5\xd0\x1f\xea9\'9\xa4\xac\xe2\x12j\xda\x8dw\x96j\x19\xd9Z\\\xc0m\x9b=\xcf\xa2U\xb4T\xb1q\x1c?\xdcc\xe2?q\xcc\xde&\xb8\x95\xd8\xa6\x99\x8c\xab\x88@\x8aB\n\x97\x88HfT\xe6W\xbc\x07x\x1e\xe4\xae%\x0eg\xbc\'8\x89[\x94\x81\x80a\xfa{X\xf5\xf8\xb3\x19^\xf2\xa3\x11\x10k\xeaW\xbc\x04\xa4\x04\xc9\x97/\x99\xc1*\xe6\xa2Hn8\xed\xd0\xb9\\\xd5\x93S7\x84\xeea\xfd\xa29\x82\xb1#\x11\x80[\x00o\xea\t\xcb\x99W?p)\x08j\xca:s\xdfR\x8bodd)\xcc\x10\xd1\n\xabO\x88\x00\xc4+&\x14\xa1\xe2;\xee[\xe3\xbb>(jr&8W\xfa\x18\xe1P\xab<-\xd9\x9e,_\xa2Q\xf8\xcb\xc5\x07_*e\x8ep\xcb\xa5\x93\xc4Y\x8f\xdcQ\xcd\xa3\x851\x97/\xa2\xbe\xe3\x8cS\x99\xdf\xc3\xbb\xf2\x85\x8d\xfdG\x95\x91\'1\x15\xb8\x1f\xfa\x95X\x1e\xf0\t\xbc\xc2\x82\xe8\xc5\xd4\xcc\x945p;\xc6s/7-\xe81\x18\r\xe1\x82b\xb1\x8cS\xcc\xc5\xd5\xf6\xa8\xcd]\x91P\xe691\x10\xa2\'\x98\x95\xb8\x90c\x12\xbcJ\x8c\x12\x1dF\xaeS\x96\\\xcc(\xcd\xcbL\xae\xf7\x10\x1b\x1e\xe1\x9d\xd3\x8c\xc6\x85\x1bc\xc9\x03\xde\x03\xbc\x0fr\x03\xbc\t\xbf\xcc\xef$\xa5W\x11\xc9\x06\x8c\xc0\xf7\x80\xdd\xc0\xee\x01\xe6\x05\xe6\x1e}>\xf3\x06\xe2\x10\x12\xf2\xfcE\xf7\x8b\xe5\x951\x84\xc9\x01\x02C02\xbb\x84^\xaaQsB\x18\xee^3/\x12\xe0,\xa1`*\xe3\x82\xb9U\x94\xfcEf\x12\x1c\x80\xe2\x18\xddL-\xe51\xa5>\xe6g5\xf3\x18 \xe5\xe7\xbc3f.Y\x83\xe6\xa1\x83\x1a\x95V\x90\xc0V`\x94\x04\xa4,\xdc\xc7\x12\x83\xdf\xcc@\xa4\xb3_\x88\x8a]k/\xe7\xf3\x1c\xe8e\xb2\xb4\x8dkq3\xa9\x83;\x96\xb5\x14\x8a\xb4d\x98\x19\x88\xaa\x8e\xbc\xe6\x12\xb6\n\xd6/\x98\x96\x0f\xdc\xa4f%7\rw\x0e\xaa\xe5y\xd4\xca?(I\x1c\xd9qJ\x98\x87"\xe4.Z\xbdB-\x8cZ \x02^\xa2^\xa5\xf3\x88\xb7\x8a\x8a\x8a\xf9\x97\x8a"\x17\x98\xb5\xa8\xd70\x81{y\x96\xa5\xaf\x8b\x86\x15\x9b\xe6\x189J\x1b#\r\x90\x1c\xf4\x01`4\xc3t\x11p\x90k\xf2\x86\xaa\x01\x8e3\x12\x11LF`\xfa"\x82s\x15\xda#\xda%j:t\x05q\x82-\xf33\x98\x00Q\x81\x18Y%\xe1\x99S\x0fN)\x95\x98\xbb2\xec\x99\x89\x98\x14\x94)\xbcK\xbd\xee.^0?\xdfq\x9c?\xb4\x01s\x02\xb5\x0b\x942\xf3)\x18\xb2\xf4&\xc0\xcb\xc4\xad\xe5^\xd0\xd5\xe6Y\xa4*\xd9p\xa8T!\x82Q\x0c\x96\x1a\xe3\xb4))\x8b\xfc\xa1G\x9cJ\x8f ?0\xdd\xc8\x8d\xf8[\xf9YV\xf9m\xd4\x90b\x8d\xbd\xb25\x9f\xc7\xdd\x99\xd9\x99\x98J\xef\x1ch\x99\x93q\xcdb\xcc\x1dO$\x1b\x8b]\t\xda-\xe2*\xea)b>\xda\x9b\x92\xbe"1\x99V+\x11\x05x\xf9\x95\xe2_q\xff\x00\x99)\xd5\xa8*Y\x00h@\x1b\xac\x1a\x8a\xf6\xf0\x08\x87\x01\xd6#\xfa0\xb9\xe8\x1a\x93\xb5\xdf\xed\tH\xa8\xd8\x1f\xf6\xc8}\x06\xfc\xc2J\x1e\xee\xd4\xbe\xaa\x16\x84\xd5\x07\x04\x03\xdf+\xf1\x1c0\x0cs\xcf\x91\x87u\x8fIR\xc4\xfd\xe5k\xf3s\x04\x18\xc4\'\xd4\xb1y\xd4\xa0O\xa8T\x05\x17(\xb1\x0c\x8a{\x8bF1\xb9\x8d,\xb4}\x91\\G\xb7\xfb!\xa4\xdf\x88\x9f\xf2%w\xf0\x83b\xbc\xd5J\xd2\xbe\xa1R\x8b\xe2\x05\x0e2*oW9\x95eT\xc1\x0b>\'\x03\x1e"t\xb40\x17>"+?\xa8\xd0\x0b\xea p\xf5\x1d\xbb\xea=9v\x95\xf7?R\xb9\x0e\r"\x9cA\x9cz\x85\x1df5\xe2\x10\xd4\xa3\xc4S\x0cC\x99^\xf0.\x1c\xf7a\xb5\t\x85|Y\x15\x9e\xd9=A\xa5Es\x1d\x1cI\x8eZ\xe3\x89Mj\x19HT\x87\xf0A#\xbcM\xefU\x07rj^\xd18\x95\x1c\xca<\x91\x9ae1\xebVX\x05\xdb\xdajt\x86\r%5fa\x15PCP\x86e\xd3\x18\x8d\xa5+\x98\xba\x91\xcf2\x97R\xebq\xa5Vf\x9d\xa5\xf5D\xcb\xf4@\x19}\xcb\x1dF\x9a\x10\x1a\xecU\xbe\xe2\x8d\xb5\xf9\xe6\xfeP\xecAq/k0\x8eH\x0e?\x10Ud\xed#\xf6\xe8x\xa2v\x82\x83\xed\x17UR\xec\xf4~\x97EAi\x95\xa8\x94\xb9`\x8e`\x1e\xf1\x1d>\xa6<,\xb6\x05\xfdF \xca\xdd@\x18\xc9\xbb\xe25{lX&\x1ae\x84l\x95\xa6\xe7\x81\xc5\xfa!%\xd2l(\xdd\xfbZ\xf0f\x07\x08AMVyX\x0e\xbf\xee%sB\xce\n\xc7\x81k\xbd\xbe \xab+\xc5B\x8e^\xa2\xbb\xb9\xe0\x82\x164D\x8b\x15\xcd\x12\x82\x7f\x98\xe60\xe6X\xf8\x19\x8e\xd1\xa7x\xe5J^\xd1m\\\xf0\x9b\x98\x1b|\x11\x1d\x89\r3\xe6\x99cY]\x9c\xff\x00\x9c\x83<\xa5?\xeb9\x19\xfa\x8c\x7fI\x98\x9d|\xbabz\xc85\x89R#\xb4\x08E\xca/\xb4\xad\xd4(\xddOH!\x894\xb3\x15!*\xc0WS\x88\x86\xef\x00\xc4\x04\xa2`c\xa4\xb5iq\x1dn\x04\x08\xf1g\x0b\x1d\xdc\xe5\x967`;B\xe0\xc7j\xdf\xc4&_\xb0\x8c\xa4\x934I\xea7\x84\xfa\x95\xa6G\xc4\x001\x08\xe2\xa5|K\xe9\x8fS\x08\xf3\x06\x8a5\x1ff/3\x02l\xcf\x83\xa3O\x98\rZb\x10GKP\x0b)jy\xcb\xf6C\xe2\x16y?\xb8$&8\x02\xf5u\xf3\xc1\xf8#?\n\x10p\x86\xb7\x08y\x02\x1aWg)\x0b\xc3\x0f\x88B\x92\xf8\x97o\xd5*\xa5\xe1.\xb5\xae\xe9)\xc5\xdd\xc9P\x17\xfa\x8b\xb2\xb7\xc94c\x9e%0\x16\xd5\xf35by!\xce\xe3{O\x07\xdaS\x1aG\xce \x9b%\x83/\'c,\xae\x1cP\xa8\xdd\x85\x984\\K\xe2\xbe#]j\xb9\x98\x94[j-\x06\xda\x975\x0b\xf1\xb8=B;d\x03\xb9\xba\x11\xa8"\xb3\xc3\xf8\x080\x1frS\x8b\xe0\x82\x7f\x08"\x0f\xc9\xa9x\xd0wC\x06%\xc8\xa0$\x88\x8d\x97\xde=\xcb\x9f\x10\x11\xfce\xaf\xe1\xb8`\xd5\xf5*{%j\xc2)5ma\x18?P|>\xa1`\xbc`R\xa4\xc2\x0c\x0c\x010\xb1\x05\x94\xd6\xb1)\x8e\xc1\x11\xc19(`\\\x1d\x03\xd0N\xf4\x0fdI\xc4\x1e\xd2\x8e!Xe\x0e\xd3(\xbb\x88\xdcW\x8b\x97\x92\xd6\'\xb4A\xd2\x06\xd1\x02\x80:\x82\xc3\x13d\x02y\xa2\x184\x16\xe1\n\x97\xb3\x8c\x86\x97(\x17\xc4e\x0e\xf2\x8c\x19\xb3 :C\xdc\x15\x838\xe6h\xdf\xa8\xf3B\xfdA\xde\xdf\x04\xbfQ\xf1\x1d7\xf5\x12\xcb\x95m~\xa5E4\xf8\x81\xc1r\x89\xe6)\xda,\xc0\xd4NN\xfbM\x9d\xe3MK\x1b\xb9i\xa5\x1c\xcc\x96\x8c\xf9a\xff\x00L\x0f\x06\x08\x0b\xb1w8_\xd9\xbe\xd2WB\xb3\xb5p\xff\x008\x84\xa1\xae\x0f(5\x00\xf7\x18\x9b\xe9\xe6>\xcb\xefY\x88l\x0f\x10]\x96\x9d\xe1\'\x05\xb2\xa6h9cr\x8c\x16g\x12\xef\x91e\xc0J\x170HYy\r1+\xaa\x9a\x80X\xa3Cpi\xf2"\x81\xb3\xda\x89\x14[\x98$\x13\x98G0Cp[\xcc\xdb\x98\x95n#_\x10Yg\x13\xc2K\xf8\xfa\x9e+\x99V5\xe0\x8b\xe3\xf5\x08\xb6>\xa2\xab\xa7\xd4\xa6\xd1\x1fR\xfc$\xc1\x08\x96!Z\x84\xb2\xb4\xf4f%Y{0]j\x1e\xd3\x91S\xdf\x98~\x18\x8f-a0\x00\xa3\xee\'\xa0\x89q)\xaa\x82q8f=@\x7f\xc4\xac\xd4\xc3\xa3\xea\x1e\'\xd4\x08\xd5J6@\xbaB\x1fi\x8b\x88\xde*W\x87\xf6\x8c\xec\x8eL\xc6rEUK\x0e!\xfeXF\xc3pX\x86\x16\x98\xe2U\xc5;\xc3\xca^\x98#\x11\x04\xc0z5f\xa7\xb2b\x88\x10LA\x82\x88K\x11\xe0aoP71\x15\xa5\x9cA<\xc7|\xa8j8{\x8b\x8ct\x14q\xdfE/5\x13\xc8\x86f@\xd0\xf8\x86\xb4\'\x1bb,>!\x8dR\x12\xd4\x85\xa5\x90{D\xf0>&\xe0Oq\xd5\xd7\x11\xb8M\xff\x000\xfc\xca\xb8\xba\x82\xe9\x9a\xde\xd5\xaf\xcdKg\xc71\xa3\xf2\xac\xb0MC\x95`\x07\xb0\xc1\x1d+C\x83\xb4\xcf\xde\x1b\xef/\x08\xd2T\xa4\x18\x167\n\xed\x01\xaa\x85v%\x16%e%\x92\xc6V%\xc7=\x16 s\x11\xcc\xef\'q\x88\xfc\xcb\x1d\xca\xb5\x98\x0c\xaa!z\x95\xf5,f\xa1^\xa5\x07\x12\xa3\x00\xea\x0bl\x94b\xe9O\xb9Q \x7fhU\x7f\xba^\xaf\xdf\x15\xa8dU\x80@=\xcc[\x98w\x0e\xf7\x03{\x9d\xc4\xa7\xfe\xa0\x0ee;\xc4.\xff\x001\t\x05\xe6\nB\xef\x1d;\x0b\x85x"\xe32\x9d\xe23i\x04FC2\x86\xe19\x80\x1b\x96\xf3\x18\xf7r\xc6\rD-\rF\x97\xb0\xce\xa5\xce$\x9c\x91G\xa8[\x98\xe3\x1c\x1f\xdey\xca\x0b\x84\xa9jk\x18l\xdbq\xa7\x8cC\x1a&\\a\x8d\xe4\xf1\x05h\x9e\xa5\xc2\x98]E\t\xbdu\xc8D\x83\x1eH)\xce=\xc1\xb7\xe6[\xb9(\xf7r\xfe\xc4\x1b\xe6\x15e\xadx\x0c\xfd+*\xc1\x86\x0f\xda5\x80\xcb\x1c\xea\xe7\xf10E\xfb\xa0D\xa2\xcasvAv\xc2\xe5\x985\x08\xb4\x1bs\x1cMy\xe8\xb2\\\xb7\xb4|\xe2%XB\x12\xb5,\xe2Q\xc4\x03\xbc\x12\x8aA\xbc\xc1y\x94\xb8)\xb8\xa8\xdb\x08@.\xa3w\xa8\xe8\xe9\x0c\xf7E\xdd\x8f\xb8\xec\x08\xcbL\xe6XM\xde\x12\xcf\x18\x83\xcb\xdc\xc8\xfd\x0c\xce/\xf3\x0c\nz\x94\x14\xa8T\xaf\xcaw\x9f\xb9I\x86Rq9\xd6&\xb8\x8e\xe5\x88\x93\x96\x14\x08\x10\xa5\x0c1\x84J\xe21_\x82^\x18!\xe5\xdf\xdc=w\xf3\x00\xc5\xa4\xbd\xb6\x10\xdc\xf2\xfe\xe5\x8d\xb06\xa6\x194\x82\x1aB\xad\xcb\\Kr\x92\xceOPC\xfbJ\x96l{=\xc2x@\xad"C"\x07\x02\x0b\xc2\x01\xcee\xbc\xc2\x0c\xa4\xe0\xc0\xdb \x86\xe0\x8b\xfc\xa2\xae\xa4\x0c\xc1\x87\xb3P\xc1\x88d\x9e\xd9\xc0\xfe\xe1\xf8\x1fqS\x07\xe6+\xd8e\xea\x97\x13\x00m\xbfS\xc4&\xd5&\x98}\xca\xbc\xc0B\xdc\xcb|\xe2X\x8d\x17q\x82\xfa%\x89w\n=\xe0\xf3\x16]\x13\x0e\x10H>w\xc4\xa7>(\x89U\x8a5\x88i\xef\xff\x00DS\x02\x8e\x8a\xe3\xf6\t`\xe0\n\xc7\x05K\xbf\xba\xac\xba\xb9\xb7\x11\t\xa6\xd4\xf0El\xe0\x11B\xc9\x8c\xc7\x8a@\x98\xb1\xecf\x80|D5b\x17,\xe5a\xe5*\x97\xeeT\xae\x9b\xe8\xc6\x1as\x00w\x07\xbc pA\xee"\xc7y;\xae\x9a\x86\xe1T+\xde2Gy\x8c\xe1\x08U\xd1\x18j\xc9aW,\xb6+\x06j\xc8L*\xb4\xfdK\xe0\x9d\xa6H\xba\xdf1\xbc\xfe\x12\xd6Cb\x81\xfd\x99\xb1\xa7\xb6\x12\xbf\xce86!\xc8\xa4Yd>\xc5\xda06\xf8\x89\xb4?\x13\x94\xb1\xd0i\x1b\xc8\xb9\xa4\xca\t\xbcD\x8b\x9c\xf2\xa6\xba\x1a\xc0S\xbf^\xfb\x86R\xb7-B-P\xe8\x18~\xd3\xbd8;)j\xc0\xbfK\x1cWv\x1e\xca\x085q\x9aP;\x95Gkr\xa9\\*9\xa0HO\x10: B\xdeH\x08\x15\xbc\xc0\xcc\x17\xdc0\xa8\xc1\xbb\x9b\xc8\xc3p\x0c\xac\xb1\xeb\x90.Q\xc4U\xa8\xec\xc5\x1f\xf6w\x10h]d\xc7\x1c\xc10,)W\x18\x81\x17\xf9\x83Z\x87\xe3\xa3~\xc4dO\x08\xbe\x13\xb7_\xa9a\x0bi\xf5\x15\\\x9f\x13\xff\x00\n4i\xf2A\xf0\xf5\x01\xe1\xf5+4\x80\xd4a\x83\xb4J\xf8%\xae5;4\x1e\x92\x04\xe9)u\xfb\x95q\x94\xf6\xfc\xc0\xa0\xb6-\x86e\x0c2\xe4\xc3\xb4HvA\xf1=&!W\x89IYb\xe0^\'\x05@V&\xdc\x8c\xc5Y\x98G\x02\x06\x06\x15/)\x04b\x8fR\xeb(:\xbd\x12\x84\xbf$\xc9]\x9e\x18\xdd(\x1e`\x92\xaa{\x94\x87\xe1\x00f\x8cvPxg\x0e&\xd0\xf8\x9eI\xf5\x00\xa7\xf1\xc4\xd7\xa9\xee\x1b\xa4\xcd\x08K\xc1\xa3q\xe0m\xe5\x95l\x0f\x17\x00`\'\x86\x00R\x07\x98\x03\x97\x81+\xc6\xc1:^\xf5\x11\xaa\xfd\xc2\xca\xb9\xe1\x82-_P\xb7\x92\xbbET\'\x88\xd6\xe1\x1a\xa2\xd4\xaa\x00|\xc0\x84?p[\x03}\xa5>&p\xc4%\xb8\xb9\x9d\xcc\xc8R\x1er\x9c\xfe\xd2\xd6w\x06\xb7\xd2s2\x8bC\x80\x05\xed\xb4\xa2\xd0\xe5\xc4\x08.\x04\xd0Y\x01\xeeJa\xb9\x02\x13\x80e\x92)\xf1,\x9f2W\x96j\x16\tK`\xc4\x0b\xabK\xc8oh\x14\x95\xeeEh\xce)\x061 \x07x\xd1\xac\xcf:W\xdb\x0f\x0f\xe2\x0f\x9f\xb8v\xe9\r\x97p\xa8.\x15l!\xdc6\x14\xef\xa2\xfc\x91\xa3s\xb8\x08\xe4\xc2\x01\xd2\x0f\xba%\x15\xb7\xf79(\xa6\xe1\x7f\xf6%F\'\x06\xa7\t\x96\xdd$v\x8b\xc8\xbe\xa7\x1a\xd9\xa7\xb8\xdf33\x1c\xf3\xe2r\x8b\x13\xe2\x11\xc2\xcc\\\'\xb8\xae"\xb7S\xb4\xfcK\xde}D\xf7\x9eTZ\xd33j\x1e0\xaf\x08\x07a\x99\xcb\x96pD\xc2\xd4qA+\xa38\xad\n\xa9h\x7f\x17R\xf5\x97\xa2\x0b\xb7\xe2\x15\xeb^*ZdE\r)\xee:\xc2\x86\xed\x98\x85\x98\xf1@\xf6\xa8\x8f\x15\xe23\x86\xfe\x18\x80c\x18L\xbfD\x11\xb0_8\x96\xab"F_\xa6g\x1bN\xe4V[\xecM`\x17Y\x8aTe\x8b\x14v# \xc3\x8dEM\xe8\x84!\xbd\xb0\xd5\x15\xf9\x89\x82\xb7h\xa1\xb7\x99\x1b"\xa6s\x981!\xdc\x94\xb8\xef0\x02\x0f\xb9\x8c\x05\x96[\xcc\xcdA\xe9,\x96=K\x162\x0e1;\xc4\xc4\x07\xcc\xac\xa3~XqK\xf3-\x87=B\xe1x\xe8\x12e3\xc4\xbc\xdf\x88\xbbG\x83\xbc85\x8c\xf6k\xe4\x10\x11\x19\x7f\x12R\xfa\x94\x82,\xcc\x80\x8f\xaa2\xb5\xf4\x98\xb2\x92\xf6K!j\xa8v\x93,\x88\xa5\xe5\x0e\xec\x9c\xeb\x17\xcbs\xbf\x17\xd9\xe0E\xa3\xcc\xc3\x8d{\x87\x9c\xc9\x1b\xe9\xdeb\x08\xdbc\'\xccP\xccb\x8c\r\xcd\x0b\x86n)\xc83\x86\xcc4\xc1r\xc5\x04\xf5.ag$\x86\x81;1\x1b\xa2/\xc3s\x8dg\xb6c\xa2\xc1\xdf\xcc)^\x92\xc70D\x1dL\x89w\x1d\x91.\x17h\xc8\xd1\xd0\xd8\x8c/@\xb62\xca+0\x04\x17\xb4m\xccq\xd4\xb2zF\xd0;OYO\x12\xd7\x13>\tvL\xc2\x1b3/!*g\xc6;\x03\xef\xdc\xa1\xcf\xda\xc4\x0cc\xa9\xa2\x1a\xb4C\xc5\xf5/\xe7\xf1\x07\xe5<\xc87k=\xe1\xf6\x94\xe7\xa9r\xe0#\x86\'\xb5\x8f\x91\x15S\r\xb1* \xdcn\x85\xcb3\x0c\xd4z`v\x95\x02j\x9f1j\xaf\xee:\x1aN\xd9\x89(\x8fr8@\x06\x9e\xd1\xa0\xa1<\x92\xb24\xd3H\xd6*!Z\x04y\x94\xe5\xaf\x9a\x95\x8f\xcc!\x94\x1b\x8b\x82\x9c1\xdc\x96\xcd\xc6\xecn\x00\x8bn\xe4L\x97|n6Ho\x16"\xf1\xdb*]F\x0e-dU\xce4\x03x\xcb\xa5]\x80\xaf\xed-i\x1e\xcc\x10\xca\xc6\n\xfec\xad\xcd\xc8\xf8\x8f\xda\xddZ\xeee\x1f85p\xd0*8L\xc4\xc2\x14\xde\xa5\x05\xd6\x0f1\xda\x92\xa8.\x9c\xca\xaf\x98\x94>\xa2V\xb9\x8b\xc1\x0b"\x9d\xbd\xa5\x01v\xc4V\xf7\x81G\xe9\x8f\xb0|T\x1b"\xe4*q\x00\x10\xe0"\x97q\xc0(\xdd)ek\xbc%2\xbf\x19\x98a\xe5\x92 K\xd4R\x805\x16\xb5,\x15\xb7}\x89\xee\x13\xc1\x81O\xbf\xdd\x16\x8a\x85a\x86@\xaa0\xc0n\x08\xe6]\xf4\x14 e\x89\x14%2\x04s$\xd2D8\x97\x95\xd0F\xc8\xb9K\x88\xb8\xc1\x9dL\xce\xf0\xe8\x89\x02\xff\x00in\xd2S\xe6S\xd0\x9a\xdc\xa4eSQn=\xdd/dX\xb1\x83\xbc\xc7\xb9\xeb\x1d\x18cQ\x13\xb7\xdc\x07,\x1c8\x9e\xb1\xa7\x12\x8ee3\x82\x04\x07\tC3\\C\xb5K\xba\x9b\xf4h\x86\x12bRc\xf1\x06\xce\xd2\x80\xb3\xf11\x1f\xb4\xa0\x96\xeay&\\\xc3\xce\x1ep=\xeaR\xf7*\xe6R\xf7\x01\xdeS\xfe@0\x11\xc7\xa7\xc6Y*Z*\x00\xc1(ff\xa2\x90C\xdc\x9b\xaet\xe1/\\4X\x12\xdc.\xd4\x11\x9bf\xedd\xc7\x1dj\xd6S5]\xdc8\xe1\xc83\x15\x82\xc5\xb5q \xb3V\xeb\xbaQ\x04\xe3\x00}\\\x00\xc8\xdf\x88\x90+\xad`\xfe\xa5\x99\'4\xab\xfcMh4\x1c7\x05.[\xcf!\xf31\xa3\xf0\xa9}\xd4\x1d\xc6\x16g\xd4\xadT\x9c\xc1\xbfg\xb7\xff\x00\x11D\x1a\xd0_8`\xcb\xd0$\xf8\xe5\xfc\xcc-\xa0\x00\xdb\xf7\xa9w\x90]\x11\xda\x81>\xe2\xf3\x02\x94\xb2k\r"\xd8\xfcA\xa0\xa2f\xdf$Q\x82*J\x06\x1b\xd8\xfa\x86\xc2S$\x01n\\q\x14{ \xd8\xfe%nS\xb8\xfd\x88\xe26;\x97\xf1\x12\xe8\xa2\x00\xf6e\xed\xa3\x81y\x0b\xd4|\xaa\x00a\xe2\x9c\xe6\xeem\x9c\xff\x00\x11+\x88\x1a\x0e~\xd8$\xae\x15\xb8\x966\xee\x98\xb3\x93N\xf0N\x13\xc0\x991|\x8aUa|\x99\xac.\xc8\xfd\xd2\x93&\x07F\x8b\x82\xca\xca\x16\xdd\xbd\xf8\xdcg\xcdE\xd5\xedv\x08\xb8\xc4UhU\x92\xa0{\xfcT\x14\xf8\x10\xc3u_#0\xa3X|\xca\x1d\x81\xd0Cq%`\x81\xb9l[\xc4E\x923\xda\x03\xa8\xe1\x0e{J\x85\xcb\xb1\xa9lV0|L\xb0~\xc8\xa6~q\xb1\x89\xc1/Q\x87=\x0b\x111w\x18#\xcb\x99\x93\x059\x9d\xf4\xc9\t\x161\x10b!\x11"\x8dDs\x17\xb7\xe6 v\x86\xc5X\xc3\xb0\xef\xb4\x004D9\x97\xac\\P\xc7\xe2#\xd4j\\\x16?0\xce\xf1\xef\xa9"\xc3\xb9\x06\xe6\n\xb7\x13\x19\x83\x92\x0c.i>\xa6\xe1\x98\x10Z\x86\xf75p\x96\x90\xb5\xce\xa0x\xf9\x82\xa1)\xb2\xe6\x84)\xd4\x99\xbb\xc4\x1a\\\xab\x12\xa8\xcd)\xfa#/\xee\xd0\xedP\x95\x97\xba\x9b\xf9\x98\x01W\xa3.>\n\xf4w\xcdBh\xfd,\xd9\xe4\x98\xd3eZ8nQA\r\x94\xd2\xc5M\x04\x94\x06+\xf7\x1f\xcd\xba\xff\x00\xd2P\xb6\xdeg\xf0\xc3\xccc\x119I\x95y\x98b\xa2\x05r\xabC\xf3\x18\xd4K\xdc\xb3H^9\x9a\xe8\xf0\\h\xaag\xb0\xbd\x9b\xfb \x9b\x13\xb2\x7f,MX\xed\xb2\x0f\xe2,\xd7\xc5\xff\x00\t\x81\xc5\x80\x19}\x96\x7f,r\x99\xdd\xb7\xf6AZ\x84A\xf8\x8eR2\xaf\x96\xcc1\xe7\x9f\xca"\x817\x9c?h|U\x16dL\xea.C_I\x88\xa3\\4p\xaf4\x01\x95@/\x11N\xc9?`Q\xf9\x99\x8dv\xa2\xbe/\xf1\x00\xdf\x0cw\xfbcB\x96\x85\x13\x85T_$\xb3\x9f;h\x9f$`\xa1\x0e\x02\x15;\x8b\xe1\x8d\x02\x98~\xd2\xea&\xb0\x16.\x15w\xbb\xc4pU\x0f\xf8\xc5\x8fZ\x87\xf3\x14r\xaa\x92\xec\xa8\x98D\xb7\x94\xfcAY\xea:\x94\xb4h\xe0\xafK+\xb8\x05\x90\x86\xb7\xe2"\x80{\x93k\x83\x86/_<\x12\xaf\xdem\x16z\x86k#\xccJb\x07\xa9\xbe\x9cGqg\x04\xee\xcb\x97D\xafhf\xba@7,\x0c\xd2\xc0b\xb6B\xea\xb1\r\xdc\xad\xd4&\x8e\xe0\x05\x91\x0c\xf1\xde\t\xef\x14s/Y%\x05\xea\x0ff\x03\xa8\xdd\xe9a]\xa3\x86\xda\xf2M\xcc\x15+\xcc"\xc3\x17\x86\n\xf2|\xc0\x19\x8b\xc3\xfbDW\x89\xc7a\x06\x81\x88)\x18d\x80h\xe18\xad\xdcj\xdc#\x9c\xc0*\x01_\xc4\xafR\xe9\x8e\xd9\x82\x0e%.o\x12\xd0\xdc\x1a\x9bw\x84 \xc2\x1d\t\xa7\xa5w\x84(\xc5K\xc2\x90s\x10\xbfsm\xdb\xf0\xcbiT^\xd7q\x8fX\xac\x92\xb4\xa01\xea*\xe8\xb4,}\xc1\x0c\x8d\x02\xf7\x98\x8dr\xb1"\x8b@\x16\x8e\xd0\x1c*&\x0b<#\r\xf7S[\xb2%J\xe9\xfb\x89\x1a\xbc\x17\x182\xbf\xe1\x98\xf0\xb3\xf9\xfe\xc8\xf5Pa\xb1\xfd\x94U\xb4\x9b\xc1~\x08#\xec\x88\xcf\x82\xe0\'\xf3\x0e\xfd#\x03\xe0\xfa\xf5~\x029b\xc7\x93\xfc\xc2\x9d\x13\xcd\x17\xee\xc4\xdd\xfc+~\xf3n\x81\xe0\xff\x00\x10\x1e\xabmn\x8f!kYk\x89\xae\xa9=\xbe\x02\x08\x18\xa0\xac;\xd5W\xf12\xb4jS\xe0\xdf\xd4P\x1ap\xff\x00\xca\xc3\xf1\x96\x05jU\x95/\x94\xb6i\x8c\xa0a \x1b\x14}!\x9f\x13H\xb7\xd4\xc7\x82j\x82\x1el9\xa8\xba\x03xe\x9e\xce\xd1\xa1\t\t&\x1c\xc0h\xfa\xa2(\xe0x\x8d\xcc\x10|\x12\xc1\x80\xfa\x8e\xcf\xf1\x15\x82\xc5b\'\x88y\xc4;.\r\xa9k\x05z\x8fQ\x8c\xd8{\x90v\x16\x13H\xcezE]1*61t{F\xd2\xd8D\x91\x91\xad\x93\xfeX\x9c\xe2\x83d\xbbi,\x9c"\xadJ\x97\xa9l\x8cA\x08\xa1\xab\x85#\x14\x98j6\xdc\xdec\xae#\x7fp\x95V\\\xaah\xce\x02t\x05R\x85\xc7;\xb9a\xb8\x05\x08J9\x8a\xed\x072t \xacG\x1a\xd0}\\h\xa3\xc5L\x86%\x92\x9a\x95\x14Q\x08Wd\xb9\xa2\x01S\x00\xdek\xb4\x0e%\x93%\xf1\xd0\x9a \x0etj\x07!\xe2Q\xbf\xd90r\xb3\x02\xf1\x15QWhN\x99xG\xec@\xc5>\xcb\xf7%-\x17\xf8n\x16e~\xd2\x16\xd8v\xbf\x8b\x8dG\xd1V\x8c\x0bO;\xf7\xa8\n\xfab>)\xff\x00\xdd\x98vc\x9aG\xe29\x7f\xfe\xf8c8\xf7\x8f\xf3\x1c\xbe\xeb\xfe\xc8\xb3c\xbb\xfe\x8c\xa6xA\x0f\xc4\xbb\x93\x1e\xa6Q\xba\x89\x1c\xa7\xdc9\xa2\xf2$\x1a\xc1\xd7\xf6LM\xf8\xd9P\xe2\x8d\x0b\xd9\x07+\x83?\xd7\\\xc3/\xfc\x18"\xea\xff\x00\x87bP\xd9\xf7\xd3\x8b\x19\x7f\xee\x88{pk\x05\xfd\x11\x95\xf0\x82?"^\xde\xf8O\xdf\x00\xf4?\xc3,\x01\x98qJq\xcc;$\x9b\xac&\xae\xa9X\x8b\xa7b\xfa\x11\xbc/\xfb\xd0\x94Q:\xac\x84\x81|\x95\xbb\xf7c\x93o=\x97\xd8\xcb\x95\xe4\x077\xaf*\xaf6J\xfa\x07\xed8\xc2\x95\xa3\xef\x04\xc2\x98\xa8\xfc!\x02\x1a\xd6ei\xf8\x7f\x10\x08\xbc\xe5\xad\xf6\xc0(\x01\x073q\xc3\xd0\'0\x83\x89w-\xf4\xa9\x959\xa7\xcc\x04\xe2\x050\xc3\xcaY\xde\\\xbe\x97?\x11\xf7\x0c\xb7\x06x\x0fQ\xc3\x19\xf2E\xd1\x99n\x90nj\x1e\x1b\xe8:r\t\xa0\xfc\xc2\xec1\x0c\x96Ak1\x0eS\x03\xb8\xb7B\xc0\x1ee\xf6BW\x8c32\nM\x9d\xe3\r\x83\xc9\r,\xa7\x99F\x1a\xdc\x9c\xcb\nWfT\x85\x8ds\x04\xc2l\x15\x11V\x18L\xb0\xc8N\x17\x8cG\x8a\\\x98\x9a\xe1\xda\x16K\x18[\x96\x19\xe6\xea\xea(\xab\x9e\x19#\xd4\nwSFe\xec\xbb\xba\x82\xb9\x02\xef\x89\x94\xf42B\xc0\x9c\xd0\xacbt\xed<\x08w\xc0\x1a~\xe1\xf6\x9f\xb7\xf7O\x19\xcbu\xc1`\x8b\x01\x16\x03\xbc3a\x05\xd6<\x99\x95v\xc3\xb8\xc7\xdc\xc3)D/\x06\xfb\x1b\xfa\x8b\xb8{;\x8e\xcf[p8^,\x90\x90n6\x17\xf6\xd4\x01\x855;9\x13\xc4tXo\xf2\x024\x89\xdcK\xfd\xe6\xedk5\xfbA\x96\x0c\x9e\x16\x0b=L\x1f\xc41\xa0.\xf1\xdb\xf3\r}\x00\x7fy\xdeO\x80~\xf0\xa39\xc5_\xd9`\x94\x1b\x87\xf6\xc7\x13gd_\xf6$\xcaV\xb1\xfdRa\x85\xe4_\xc0\x9b\'w\xfe\xc8\xb4\x8e>MK\x8c\xff\x00\x81\xda\x83d\x87o\xfb\xa8\xf3\x91`\x1a \xb1\xff\x00\x8c%\x85\x87\x81\xf5L\xbf\xce\xf6_\xd9\x88\xa3\xf2\xc3\xf7\x8a9\xad\x97\xfe\xec\xfc\xf6\x0f\xdd\x8aW\xca\xb1\xfc\xb3"\xc7\x9f\xfaG\x0f\xe1\x0f\xe6 g\xfaH\xeb\xb9\xee\x9f\xc4=\xa6yP\x83\x1f"\x7f\x89kH\xf3o\xe6\x1e\xd7\xf9\x1d\xe6\x8f\xd6_\xdc]\xc3\xea8C\xd0\x8a.\xff\x00l~a\xe1E`\x08\xfa\x02\n\xa1\x8fe"\x83\xe92(\x82\xebx\x85\xa6\x96\x93\x80\xb7\xf1s\xd1\xc8\xc5\x1c4\x08\x97\xb1\x15_q\xed%\x05_\xe6U/\x94\xf5\xff\x00\x8ee\\y[\'\xa7\xf3p\x80m\x1f\xb2\x08j\x8b\xe2[r\xefMA\xbb\xeeM\xcb\xe2\\[\x9bzj\t\x94\x8b)p\x8b\x19[\xa9\x8dM\x90\x1c\x92\xd3p|\x18[\x99~e\xcb%\xd4\xb9r\xcea\x03\x11\xb1\x15\xacE\xe9\x10\x83.\xe6p\x10\xebW\x11\xda-\xe2\x03\x18\xba_\xcc\n\x01\x1d\xd9 *4\xf3.\x04\xf8\xc4\xc8\xa36\n\xeb\xf9\x80\x96\xb3\xb1"2G=\x98T\r9U{8\x86\x83ic\xa1\xf1\xdeYk0\xdbg\x87\xd4\xad\xe7\x910\xf8H\xc4\xd4\x98v?Q\xacT\xbaH\xb5+@\xcc\xd2\x8b\xf9\x89f\x80j\xf0;\x8cm\xaf\xc0\xe8\xfe\x7fy\xa8\xe5r\x94\xe6Lf\x1e\x12 \xd2\xd0\xa4\xfd\xec\xdce<\x85\xd0e\x1c?\x1b\x8bo%\x05\x80\xddU\xf1\xfe\xdcA\xa5\xab\xb9{\xe87\xc3\x11A\x83sfJ2rTW\x82\xf0d|\x82\xa1\xd2\x06\x957\xc1\xb3\xfc{LR\xba\xbc?\xc4\x17\x98\x1d\xc7\xf3\x07\xbf\xf3\xf9\x94\xf5\xf2\x7fd%\xadwo\xfb\xcd0\xac]\xef\xed86j\xb6\xcc\xe5\xca\xe0\xdf=\xa7\x83\x90\xbf\x06\x07\xe9{\x9a\xc8VF\x81\xe4\xcd~\x12\x82/\xb8\xf1a\xf8R1\xe4\x95\xc1{l\x9fP\xf6\x15S-\x1e,\xfb\x04\xa5\x80"\x87\xdc?\x92\x04,H\xde\x07\xc6\x12 \x81\x93CO\xd3\x1a\xd4\xbe\xd9&|\xc5\xecD\x0f\xfd\xc7\xb35>\x15_\xccY\xb5\x1e\xff\x00\xf6\x87\x17\xc3\xfd\x92\x85_\xe3\xfe\xd1\xdc\xf9\x87\xf3\r\x07\xc9?\x98P\xbb>\x14\xb3!\xe4\xfdT\xb0\xbb\x8d\xd8\xdf\xc4\xbe\xc4l\xd7\xf8\x9b\xa3\xa2\xff\x00\xf0\xcf\x04\x00\x87\xed*\x85\x9a\x15\x8f}\xa0\x17<\xdc)\xefQ/\x06\xd6}r\xc2\x00\x9e\x88_5q\xed\x80\xfbaX|\xcap\x98US\xe4$\x18\xa6,l>\xe1t\xa1\xa5\x7fe\x98S\xa6\x1c\xd0\x1c\x00E"\x13\xc8\xe1H\xfeP\xf4U\xd1h\xfb\xb4Ja\xadOmHh\x81\x82\xa1\xf3A\x00W\xec\n\x7f\x92\x06=\xf2\xc7\xab\xc7\xfb\xe5\xdb\xac\xd5\xdd\xc7\xcd\xb4\xfcV\xa5\xc9\x08\xbb\x8c\x9a0\\Y\x7f\x950|\xa4\xf8\xce\xe5q\xa0\xe9v\xbd\x03\xf5r\xc8\xf1b\xfc\x9f\xc90-J[|\xd6\n\xfb\x94\xc1\xa8p~B\xcf\xe2W\x0b\x85\x07\x1f4TJ\x1a|\x8f\x85\'\xd4\xb5\xa2\xc3\x95\xefP\xd1yk\x1f\xaf\xfb\x0cC\x19\xba\x9e\xeb\x7f0\xeb\xe1\xc6A\xe8U\xe2c\x8f\x88\xec\x975\xacA\x9e]\x17.P\xe6m\xe0\xfc0\x07\x02\x1dD{\xf1\x02"[W\x99p\x14@\x9e\xf1\x1cN\xc6e}\xc8_\x94O\x99n\xf0\x800\xbfA\x1d\xc9\x87=9\xf4\x19nX\xec\x88\xacnS\x06WP\xe8\x9dS\xa0\x1d\xe5\xee\xe6\x1a2\xf9K\x1e\xd8otv@\x85\xbe\x9d@\xb8\xddp\xbf\x98\xc2\x07\x90\x15P\xcb\xa0\xec\x1b\x89`\xac\xd6\xc4*\x99k\x88\xf6\x8e\x02\x8cW\x99Ap\xbe{\xcc\xa7\x99\xab\xf0\xcb\x92aip\x8fZiU\xee\x03\x9c\xdb\xa2\x01\xfb\xa4h\xb2%\x8a#\xe6T\xae\xa5\x81\x98\xd5\x98\xe4\x1b\x17\xd4@[Y\x1b\xb2W\x9b\xb6\xbb\xc3\xb5\xf0\xfdKG\xc4\x18\n\xc6\xf5\xe2\x07\x12\xc5\xb5\x17\x83\xce>\x99\xab\xc0\xc4_c\xe4\xe3:8\xbd\xc0\x1eaT\xd4\xd3\x7fR\xce\xf3\xdc\xcfx\x1bM\x04[\xcf\x88q\xef\x8d\xd7\xd7c\xc0\xc4:\x02\xd0\xa2\xfas\xfb\xc0>A\xc6\x08|8>\x02\x0c\xa8\xd5\x1c\xbd\x17\'\xc2z\x98\xdf\xa9\xc0\xed\x9a\xfc\x98**\xbb\xd4}\x87\xf21\x05\\\xc2?t\xc7\xed\x1d\xa0N/~\x04\xe6\xb4 \x83\xe3BY\x16dP{\x05#DG \x17\xc8H\x94\x93 \xa3\xe0S\xf52\xea\xa6\x04\xfda\xf5)\x89d\\>\x89\xaa/"?hp)\x84T\\")B\xcf~\xdf\x88EB\xb9(\x1f\x10\x80\xa1\xbc3\xf8\x84\x0b\xbal\xfd\xac\x8e\xb2\x9b\xe4>ayw\x10\xb0\xfdO\x07`\xa3\xe9\xdcr\x177\x953\x0e\x0eT\x0f\x9a\x88\xed\\\xb8%\xfb\xdf\xe6.\xda\x95\xa5\xfe#\xf1]\xae\x8d?\x1f\xc9\x04\x05I\xce\xcf\xad~"\xb3\xeb\x90~g\xff\x00"y\xcd\x80\x0f\xf7\xb8\xa4\x120&\x7f\xaf\x89^\xb6\x0bi\xf9\x80\'d0\x8cR\xecw\xa3\x10UZ\xbb*G\xe0\xe9Zw\x8e\x98/52\x9e\x16[\x85\xfd\xc3\xe4\x1fS 1\xefP\xa8\xa5d\xd9\xc9\xdd#q]\xcb\x8fd\x87\xbb\x01d\x91`\x10\xcdE\x97e\xd3\x88R\xdd\xf8\x99\x8c\x1f1\x81Q\xe2\x19\x12\xaf0"\xfei\xa4\x13P\xcdk\xf1.\\w*]K\x97\x10s\xf9\x9b\x91\x03\xd2_X>`M\x7f\x1bF\xa4{\xe8\x81\x96D\x05h\x88sX\x11{,\x98\x8b\xe1\x8akQO\x10\x830\x93p\x1d\xc8\x07L\xc8\xe1\x8aAM2\x8e\xc1\x83r\x92\xc6\xb3\x10\xf8\x8fa\n\x12\xab\x98\xa9\xda\x0c\xda,UK\x8c \xb2\xf8A$_\xa4\x8b%\xc5\x8fW\x06\xcbVaUE\x00\x07\xbcW\x9e|\x19N\xdd\xb2\xed\xab\xdd\xc0n\x948\xf7.#\x97i\xa3F\xb8\x98W\tL-\x0e2\xce\xe9\xbe\xfc@\x90\x06\xbdK\xa44{BZ\xb1\\F\x92\x80/<\xc0\xd3\x93Qb|1c(2\x9d\x98\x99\xbf\xdcux\x82\x92\xc5\x83\'\x98\xd2e\x0c\xf4 \xc0J\xeb\x82%2\xd6\x8b\x97v8\x19gv\x1c\x9e\xb0\xaa1W\x96[\xe6\xe0\xbc\n\xc6=\xca\xa9\xba\x8d\xaa_\x88\xb6i\xee\xa3\x86\xa5\xf8\x8b\xff\x00B!\x9b}"\x17K\xe2<\xef\xc2k:D\x80f@\xba}@\xb8\xfdC@}O\x17\xea\x12\x03\xd4\x08\xd6Q\xc6\xa0f\xb0\x1e\x136\x91\xff\x00\x81\x01\xc1?\xf3:A^>\xa8Y\xfcP?\xea\x81\xeb\xea\x81p\xfa\x94v>\xa1I\xae\x89Du\xe6 \xa2\xdf\x1d\t\xa5\x104\x03\xff\x00\x85\x08\x96\xd2o\xc7\xdc\x0f_\xb8\n\x8b\xe6\x06\xd8v\xc9\x87\x05;\x98?1\xf1\xed\x16\xc5\xa9\x1c\x97G\xe2e\xe6\xfd\xb2\x9a\xfa3\x01\xd1\xaaX\x84\xf3H\xa8\x10\xf2\xca\x84\x11\xd53Z#F\xe3m3\xe4\x87pL;\xb9~\xe05\xcc\x08\x16\x02\\\xfb\xc0\x98V\xbd\x99\xa7>#\x06\xe3\x9b\x80\x84\xb1\xdcqw{\xf3\x18\x06\xe0`\x8a\x00\x8c+1\xf3\xde\x1a\xf0P\x04X\x8b\x06\xe5\xf9\x96\xcbR%\xdaJ\xa5H\xac\x1d\x88;\x82Q\xc4\x80 \x03%B\x000t\xd2\x01\x94\x8aK\xebpf\xa72\xc2Y\x16\xd9_\x12\xcc[\xcc\xbb\xc3\xeaX\xe7\xf1\n\x1b\xfcL\xb9\x8b\xff\x00\xc4\xff\x00U0\xe6W\x99\xa9x\x84\xb83\x89\xae\x9a\x97\x889\x95\x89u\xccA\xccKb,p\x9b\x038\xff\x00\xdc\xfe\xe2\x81\xaa_2\xdb\t\xdc\xc4\x03\'q\xfc\xc0\x11>U\x1f\xccE\x19v[\xf9\x88\xa8o\x16\x0f\xa2_,_\xb8h\xa7\xcc\xe7\xdfI\x01\xdf\xd2!j\x8e/(\xfb\x9ed\xb8\xf8\xe4\xd4HO\xb6\xdb\x07\xd30L\xfbSS\xc9N>\xe1\xaa\x84\x14\xcfy\x83\x08_L9d\r\xa4\xaf\x12\xb80\xb8\x97;\xc1\x1af+Y\xde@@\xb2\xac\x1aQ\xe65\x136\x8d\xb8\x8f\xc2\x1a\xf7\x00\xc0\xca\xc6U\r\x90[\x83=B\xb9\xebV\x01\x99\x82\xe3\x0c3\xacl5\x0b\x05\xe7$\xc2]\xbbF\x08\xbfq\xd8\tO\xdc\xc4hy\x8eq\xa6,\xe4=\xcc\xa9G\xec\xcd\x15\xb1\xc4*\x16]]\x8f\x15\x18!I\x14C/\xde\x01\x05.s\x19\x00\xbb\xb2\x00,\x83Q}Ax\x1c\xc3\xee/\x92\x1d\xf8\x8e\x1fqr*k\xd3/\x10j\n[\xb4d6\xefqkUq\xa5\xb7\xec\xfe`\xc7\x89e,\x00\xcc!_\xe0\xfe\xe5)\xb7\xd3\xfb\x83c\xe8?\xb8\xb2\xbe\xbf\xfa\x8d\x00\x1d\x8c\x9bbW\xe7\xd7\xf9\xcc\xbd\x8f\x8b?\xb8\x85\xab\xe17Hy\xff\x00\x89\xc5^\xd7\xf5\t\x8d/\x97\xf5\x18\xe8<\x9f\xf1\x1d\x01<\xff\x00\xe2\n`\x8c\x80\x1fqS\x00\x9e\xe3\xc4>\xe7\x14\x0f\x98\x9eG\xdc\x13W\xa9n\xc7\xc1\x0e\x17\xeavd\xf5\x053G\xd4\xec\xdfP=\x17\xe2p\x8e\x92yC/l"\xb9\'\x01\x18^\t\xd9#1\xc3\x88n\x91\xe1b\xf7Y\xc3\x1e\xa2{~k\xa32\xbb\x17\x95\x1er\xe1.\x13\xc2\xb9a\xa7\xd4[FX\xc0\x9c\x81\xf1\x06\xf8\xb2\xbc\n\x9d\x81\x05\xc4h\xd1\x0f\x84\xa7!\x11\xc5\x10#;\x92\xc9gf%uP+\xf9\x80&\xf8\xa8\x07\x8b\x81P0$U\xf8\x96U\x87\x14\'\xc9\x13R\xbeX\x97\xc4\x07\x0bR\xa4\xc6m\xd9\x11\xab\xb7\x06,\x9f0L\x1fr\xc0\x17\xe6n\xd2\xcc@\x93q\xe5\x0e`\xf8f$\xb6e\x84\r\xe6(\x9c\x8c\x14\x98\xdc\xc5/1\xcfp\xfb\xc3\xbd\xc5Gu+K\x96LD\xa8\xd39\x94\xaa\xe6+\x94\r\xdc\xc7zL{\xa6\x1b\x0bo\xa9\x9f\x130O\xb84\xf4\x14\xd2\xfeff3\xea/O\xd9\x1d\xbee|\x12\xf0K\xf3\x14M\xe0)a\x98\x92%9\x19X\xed\xcc\x03F\xaam`\x80[\x80\x9b\xce\xe7ij\xb2$\\\xac\xc3\x15\x95\xa8\xa7\x06x\xa9W\xc8e\x16H\x07p#\x088\x1c\xc6\xa3\xaf\x99I\xde\x07$\xb4\xfe\xe1au\xe6Xo3&H\xf8\x8c\x17D\xc6Y\x1f,\x0c\xb9\x18\xc9\xf3;,\xa1\xd4A\xfe\xa0#\xdespg\x98w\xc3<0F\xcb;\xcbr8\xf3\x01\xea\x0b\x9dJ#/\x89T\x07\x0cuX\x99\xbbL\x95\x107\x88 \xdc)\xe6g\xc40\xdc\xc7\xb8\xae%\xfac\x0b\x88\xdf1_^\xa1Wp\xae\xe2\xdb\xfd\xe5\xfc\xc7\xf3\x05{a\xfc%\x0c\x03\xdaw\x96Kt\xca<\xc0c\xb9\x92\x01\xfdK8\x967\x88\xd8\xc3\x12\xb5\x88\x95\x130\x83\xb6_\xb8fC\xf1\x08q<\x84\xce]x\xa8\xa9\xbb\xf9"\xc3\xe5\xa3xhve \xdcwe5a\xe6U(\xfb\x84\x80XV\x91\x18*S\x02\x9b\x97v\x80\xbe\x9c\x84\xf2\xcc0\x02\n7QP\xc7\xcb\x0e4\xcb\xde+5\xfb\x82`|\xca\x02\xe2\xd2+\x06\x84\xc9\x95\xef\x03\xbe_\x12\xff\x00\x82{\x11\xecJ\x8d\xcb\xdd\xc8\x82\x9a\x808\xb8\x1d\xf3\xde%\xe7s:g\xc4B\xddb\x18\xa5\xc7F\xaa\x93\x7f\xfb\x01\xad\xdc\x1d9"&\xf1\x14\x8a\xc3\xbb\x8e\x98\xb5?k\x82\x97\x0e88\x85\xb1\x0c73\x80\x81\x95\x19\xe4\x8b\xc2\x1a\xd3\x08*nV\xd5\'0B6;\xc5\x02\x14@\xd4q\x04]^!\xa1\x82\xe0\x13,W\xcc^\x88\xed\xae\xd3\x10\xe6Z\xc3.r\x11\xdc~#9+\xdc0\xbc\xa8\xe5\x00\xee\xe6\x85\x9c\x84\xa3\xc8\xca\xaf$\xbf\xa8\xf8g\xfe,\x1b\xc6%\x98w(\x80\xd8\xce\xe4\x01\xb8\xbfx\x85 ,\xc5C\xb4\xa8%\x9c\xd4\xdfs\xbf\x06\xcc\x18\xdb\x9a\x8d\xb9\xc4|\xb1\x02\xaa\x08\x7f\xb9Q\x86\xaa!a\x88\xb8\xe6,\xe6\x7f\xeaJ\xf9b\xac\x18\xa5\xd4\xa5\xc3q\x14\xae\xe3\x1c\xbf\x98\xbc79\xdb\x87\x94|%\xdcHn\x107\x95\x88N"x\x85x\x96\x11S\x0c6+\x84\xdf\x08\xe5\x94|M\xf8zI\xa8G\x984\x17\xcc\xe4\xa1\x90L\x00\x1a\xca\xa7\xf7J\x88i\x9c\xa0L\x9fpH\x16\xa9\x9eX\x15\xb9G3p\xdcV\x17\xcc\xc64\x0ea\xdbq\xdeY2\xe6\xa6b\r\xc06\xe6\x11#T\xee+\xa7I\x97\xf7\x87\xc3\xd1\x15\xa3\xb8r\x06"\xa3\xc7\x11\xc2[\x8d\xa5\xa5^cO\xae"bn\x8a\x9d\xbc\xc6\xee\x9fI\xc8\xb1\xac\xac\xaf\xbb\x049@\xa7\t{+\x81e\xc1\x1b\x847.\xbb\x95\xee\xf6\x89Y1\xc1\x19\x84\x95\xb4\x08\xc8?0\x984J]\xce\xe3,\xbc\xcfxa_\x986\x15\xa0\xe26~\xa3\x00u\xc4\xbc\x8a\xa2\xa1\x8dn_\x84\xbf\x98\x82)op\xb23\xf1\x17%\x89{\xc4\x17ln\xde}\xc7z\x97\xe1\xa8\xe8\xbb{L\x9b\xe6bh\x94\xa7^\xe5\xc6*]\xc9\xa8\x8d\x06%h\xc4DZ\xd7x\x0cE\x1d\xe7.\xcf\x10\xa6C\xd4\xa0\xa2\xce\xf0\x15c\x88a\xdf\xcc\x07A4\x8cx\x85\xf8\xa8\xae\xea\x118\xf7\xd1WW(u\x985d\xc0\x10\x07\x14\xc3\x82\x13\x1c\xae$l\x86\x8c\x8e\xd1\x8a\xbaK\xf1s\x11\x02\x9b\xbf\x98+\x94=a\xc32\xc4\xc0\xd6 $\x0b\xc9\x88\xf1\x98X\xf8\x81O0*\x19&;\x84G\xacA\xddC\xb9`\xb9\x94\x15p\xa5E\xa6i\x9a\xce\xe5d\x13\x86#\x89\xe2\x94\xd5N\xd1\x01UR\x97\xa9Sd\x11\x80\x88\x04I\x86Hw\xa2\x1f\x0c\xc2\xc9`s2\xe2W\xc4Z\x96\x96!\xce\xe2\x1c\xc0y\x94X\x83x\x1f\x98\x91\x07ER4\xdc\tu\xc7\x96l\xe8 \xe0\x89\xbaG,\xb3\xa5\x10-5\x01\r>e\xc3\xf9@\x02;\xbb\x9c\xe8\xee\xbfx\xe7g\xdcMF\x8e\t\x86m\x98\xdf\x98\xd9\xdc\x05\x12\xb2\xeb\xee]+\x99J\x1cJ\xf9\xd4\x1c 2\xf9\xdcA\xdd\x91\x9e\x1d\xc1=\xa2\xc5\x8d\xc2;\x94\xadDJ\x1b}Em\r\xe6\xa0\x84-\x84\xcf\x9cA\xd5\xcb\x0e#)#\x0b\t\xdf\x84\xfd\xd8\xe6.5\x9fQO02!\xf5\x0c\x97u*\xe3$]\x9a\xec\xc6\xfc~ \x02\\\\bh\xb2\x10\x0f\xf1\x0bU\x98\x0b\xc9\x98\\#\x02\x00L\xb3R\xde%G\x11\xf4\x86&\x0e\x82\x83\xa5\xb8\xaap\xf1(\xd3\x0c\x8b\xd4\x06\xd8\x8c\x8c\xa3/\xa9ef\nn\x14J\xba\x9bC,\xc2\x90(\xec\xca\x13\xcc\xaa\x8f\x08\xf1%\x9cA\xe1,8\x85\xears.q\x00\xeb\x11\x8e"\x95;\xd2\xa9\xe6\x8d\xb5.\xe3\x96\xa3W/\xc5\xc3LN\xf9\x1e\x96\x88\xd7\x15*\xe9\x8dj%\x8a\x1b"pN\xe4[w)\xa9hJ\r\xd4\xcd\xe2\x01\xe6Q\xe6\\a\x9bq\xd0s\xce\xa1\xba\x88\xa4\x9b\xee0\x11i*\xa3\xa8\xad\x14\x8e\xe6\x10\xab\xfc\xa1QxM\xd39b\x1a\xcc\xac\x87\xe20;\x10\xd1\x0c7;\x0c\x1fxbfa\xea\'\xb7h\x078\xf5)\xdd\\\xaa\xb2O\x19\xa9\x8b&Q)\xf3\xcdE[\x80+\x9f2\xd7\x1f\x89f\xee\x19\\\xa7\x8c\x82_1`\xef\r\xe1\xdc\x19\x8f\xa8\x90\xf1\x0b-K\xca\xfb\x8bf\xac\xf5\x15\xb8\xb2Vqsh(\x96\xa91\x0b\x8f\xb9\x9b\xb9\x0118\x03dK\xd4\xa9\x18\x07\x19zA\xdaP\xca\xb1\xc2X\xc6\xa3]\xea\x0b7\x1c`\x8bq1B\xf9\x0f\xc4\xa0\xfe\xe5\xc5\xd6;J2\x0f\xd4\xd7W\x1e\xfb"\xaaO\xc4pGG>%k\x8b\x85L~ \xfd\xe70\x86\xb9\xf5\x017\x98|\x89`\x8e\xa6\xe5Y\x9ae\x0bF\x93>\t\x9c\xa1,?\xcc\xb6%\xc6\xefF\xb1\xfaJ]\xcbi)\x11\x88m\xa8 \xcdB\xa6ny%\xa5\xdc\xb6\xa9\x99J3\t\x05\x8d\x19SS\x08N=\x14;\x1d\x08fD{\xc1N\x88\xc0T\xaf\xc4\xa7\xbe\x9c\xa0bjc\xf3\x10\xcb\t\x8f8O0\xedg\xd6\x04`#\x00\x82\xeaxg\xdd\x1a3\ti\x84\x04)\x0b\xc2\xc5\xc7\x8f\x13(:\x8a5\x0cB\x0e%L\x13\x84Cq\xf17\x87L[9\x06\t\xdfH\x02@g\x9d\x1938\xc9B\xf6J\x89C\x92\x18s*\xf5\n\xadc\xa6\x8d\xf4\x00>xe\x8b\xb8\xd8\xe2\x89\xba\xfe \xc7u\x98Y{\xcb\x97(\xde\xe3)q\x0f\xfc\x99\xe5S\xe3S{\x11\xccTw(\xf3\x18\x05\xca$U\x00\x97\xb9\xf5qT\xbf\x98\xcbL\xb3\xb4s\x9e\x9a\xa3\x11Qr(n3\xe2\x15#\xa38%\x0c\xcb\xfb\x88=!\x1f}\xe6\x0b\xd27r\xc8H\xdc\xc3\xd1_\x11.!\xe24\xb9\xc4\xdfG{\x8dD:\x1c\xd1\xc4\xb9U(s\x1f\x9dE\xc5\x86Y\xcc*f\x13\xcc\x17\x99\xe4\xc4\x03\x98\x0c\x11\x08\x84o\x1b\x13,e(\xd4Q9\xf46\xe21\x87=\x08\xef\x13\x12\xc6\x91\x96F\xcfN\x9b\x8d,y\x968\x82\x990\xc7q\x0c\xee\x17\xdc\xf7\x82\xf8\x84Q\x96E\xf9\x972\x82C<\xcc\xcc\xc1\x97P\xf9\x10\x04Q7\x14]\x0c\x91n!\x80\xb4Q\xa1\x9b\x80\x82)\x88\xe2\xa3\xa0\xbb?r\xc76J\x8cv\x8d\xad\xfcJ\x1c\xcb}\xcb\xb8Y\xf1\x13\x19{\xc7e\xe6\xa3\x859\x95\xe6\xa5\x17\x11\xe9Q\xee8f\xf8\x83%1j\x17\xd2\x0b\x06\xb3\xe2 \xf8`\xbf\xf1,\xdc\xcd\xb9\x97,\x17\xb1\x99\xf1\ry\x9d\x94S[\x811\x98\x18#\x13H\xfeb\xb0\xad\xf4\x07\xdcD\xef\x05\x1c\\\x0e.\x11T\xa6\x04;\xe1h\xaf\xbc\xd9\xe6S*$H\x93\x08\xdcu0\x99\x0c\\A\xe2>c\x1e8\x9a\x06\xa2\x14\xb8\x8c9f|\xb0Z\xcc6\xb3-\xe6Z\xeeXn\x16\xe6Y\nH\x0ca\x1d*cH\xe5\x06u\x05D\x92\xe3\x97A5\x94`\x832\xc8\x0eX7<\x19\x8a8\xb9I\x12!=M1\x0b\xc4\xb2]\xd4i\xff\x00#K\xe9<\xa1\xf3\x9eH=\x15d\x0f\xf3\x12[\x98\xc7\xa8\x12\xfa\n4\x8d\xdf\x11\\t\xc22\x938\\\n%\x8d\xc0-\xe6a\xac\xe6\x1cv\x80s6\xcb\xa6U-\xc4i\xf1\x05f-\xc3Z\xed\x00\xdcD\xaa\xbc\xc4@\x94\x98y\x8b~&Yb\xc5\xd0\x13\xcc\xbc\xf8\x9b\\\xa1\x14c,\xe2\xa2p\x8c\x1eb,\xad\xcc-\xb7\x0c8\x83q\n\x82\xba\x1e\x08F\x13\xe7\x94\'y\x86`\x94\x9e\xa7h\x91*&X \xa6\x08!"\x8e\xa2F\xc8@\xdc\x1c)\xe98V\xcc\x1b\x83\x8c\xc0\xef\x98.\xdf\xb8o2\xb7p\x153\x81\xa8\x08\x04\x1e7\xd0\xeet)\x94/I%$\xb7\x98\xc1j4\x8e\x12\xc4\x89\x1d\x18\x90\x18\x9e\xd1\x10\x80\x90\x8aDp\xc4\x1dG\xba\x05\x85\\)R\x87\xa0^\x05\xe8\xb2Y\x14^\xf0CGE]\x05\x89\xb3\xa1A,"\xcaE\xe2>/qo\'\xc4.\xe0\xae\xfe\xa6x\xb6\x17\xe5\x999\x86\x10\xbf\xa4\xcb\xeae\x8d\x13\x06n\x141a\x9b\x94\xb4\xab"x\x86q0H\xae\x0c\x7f\xf1\x16$\nc\x12\x8e\xf2\xf9\x8e|\xb3?R\xf9\xe0\xa8\xa2\x7f\x98\x8b70sqC\n\xe2.\x9b\x8e\x93=\xa6\xd0/\x89X\xa2\x0b\xff\x00\x90\x9bn\xa5\x89E\xac\xb5\x96\xc2\x06\xe5D\x89\x9e\x87\x95F\xfctvE \x8d\xb3\x1f\x8cb\x91\x83\x02\xce\xd0+SC\xf5\x18\xac\xc59\x8c%\xac\xb83\x99}@r\x96Ky\x809\xea\x03\xa0=\xbfB\x94L\xbc\xcc1\xacr\xc7E\x9f,\xb6\xba\x11\x1abq*\x98A\xf9\x98|\x1d\x18\xa2\xc7\xfb\x8d\xb8\xb9_P}\xe2\xf3\x06\x14\x84^\x0f\xbfK|j0\xb3$s\x9e\xe3\x8b\x136G\x86*\x8bE\xcav\xfcLc5\xe6^w\x967\x1b8\x97\x19\x94\x13\x08#,\xe2\xe78:\x85<\xcaWhR\x15\xe31\xae\xa1L\xc5\xa8a\xe6b\xf72\x99F<\x13\x1e:2\x8fL\x9d\xa53\xd69\xc6\xd8\xc4g\x19\xa4\xca4\x96\x9ab\xa9G3.\xf1(\xe6\x1dR\xc5s\x07\x0b\x86x\xe8\xd9\x0bM\xf7,je\x112\xc6~\xd9O\x12\xc9\xd8\x95q(\x8a\x93\xef\x11qrLN\xd0\xee\xd4\x03\nc\x7f\x11m\x9f\x07\xb8\xfe#)\x83U;\xbd\x03\x08|\xa0\x8dq\rtacG\xc4\xc7;p\xb3\xd0E\xcc\xa3\x98\xa2\xa8\xda| \xe37.\xc9s-]L]\xa5\xd9\xfd\xce$3\x85G\x83\x8e`~f\x12\xf3\r\xc1\xcc\x1f\x89\xe0G\xf9\x9aq\x0c#\x06q\x05\xba\x83\xfe!\xdc\xc7\x83\x88\xd5Kj*\x830`\xb0\x9e{\xc5\xbd\xc7\xc2er\xbcF\xf2\xc3\x88*.X5\x1eq\x11Y\x81\x13\xb7\xe2w@\x95\xf1)\xf7\x0c\xb5+\xb1\xf7\x02Z8e\ne-\x88\xc3\x1fx\xe1,\x8f\x84m\xc4\xf0G\xbbS\x128ssy\x8f\x88RU\xc4F3\x91\x1a\x1c\xe6y\xe7\x9aw\x9f\xb8\x0f3^a\xb5\x98]\xe7\x92\x16\x861\xdb\xbc\xb1\x8d\x9e\xa7\x0fn\x867\x19\x94\xf1\nv#^#d\xb9\xb9X\x16^\xee\x18D1\x08\x91D\xf1\x08;\xa1""`\xe6\x0e/\xa1\x87)l\xbc\x9f$]\x0c1\x8b\xa1\xdeb\x97\xba\x9eK\x9e\xf10u\xeeYV\xdc\x11WR\xb6e\xab\xb8\tO\xc40\xf3\x1cJ\xab\x85\xcaH\tQ\xcd\x8flL\xe9\xccK\xd7\xdce\xd4\xc3*m\n\xb2\xde\x88\xe3\xfe\xc1\x88t\xc5\x9f\x10\xefpx\x9c\x87>\xe0+\xbc\xab\xe31\xbf\x12\xc6f{\x81\xedr\xaf\xb8"\xb1\xd0\x1d\x03\rJ\xc4\tP;J\xff\x00\x92\xa8\xcc\xde\xa1\xb8a\x82\xb9DVM\xcc\xa3\x18\xcc\xee;F\xd1\xa7\x1dA\xab\xd1]G\xe3\x1a\xca\xa8\xf4\x0cUD=G1lC\x99\xaa\x15\x99\x9eRY\xcc\\C\xef\r0\xfeay\xca\x17\x85\x86"\xe2\x11\xcb\xa6,\xed\xc4\x98:\xad\x12\x92\x0c\xb2U\xcb\xa9ss87\x08\x17\x88}BW\xdc\xa5y\x8e\xb1(#)\x8c\xdb,\x8d\x95\xca\xc8\x17-\x8b\xd0\xaez\x13\x15\xccu(N Y?\x08\xcb\r\xa5\x15\x1e\xfb\xa9\xf0\xfe\x85\x7f\xcb\x8c\xaex"Ti\xce \xe1\xb5*wR\xbc\xde<\xcb\xe3\x1af]\xc2M\x90yC\x08Z\x19\xcc\xe6Ya\x16\x0b\x8f(\xfc\xe36\xdck\x1a\x1a\x8f\x84\x17\x89Z\xd4Y\xc5C\x1b\x85|\xc0\x98q\tMD\'\x11jT\xcas(\xe8\xd9\x1dG-\xcf\xc3\x0f\xe3\xc4\xb9ze\xcc+\xdc\xa9e;"\x83?\xea\x82\xe6\xf9\x8a&n\xe2+\xdcr\xd7\x98\xb6\xf5\x18\xf3\xa8\x8c\xc8\x89C\xe6!y\x96\xb9`b\xfe\xd0n\nz\x119\xce\x8f\xee&\xa2Z\xb5\x9e\x8d\x90\x0f\xcdN\x0f1?z\x94\xab\x86\x8fD\x14\\\xc1\xa4\xf3\x13prF\xd2\xeeN\x0c:U\xb9\x99@\xc4\xe6\xa5\x11\x16\x91(\x89\x97\xc4\xde\x04Xg,L\xc5\x98)R\xe9&\xea8\xfb\x86\x99x\x82\x82\xd9\x11\x82F\x0c\x1c\xb0X\xd8!\xc4\x1a\xfa\x9b\xaf0\xe6\x0e\x0f1\xc3PQ\x12P\x97\x04\x04\x01IW~\xea!\x0f0\x15\x11\x02\xa0\xc4\x18\x82\xc6\x1a/\x9a\x8e\x13.s)\r\xcc,\xce\xfb\x89\x8d\x99\xdfCVb*\xe3~"\xcbU\xc5Es\x18\x8a\xea.\x19\xda!.\x02\xd8\x9cD@\x08\x00y\xfe\xa3k\xf5\x11O\x88\x96\r\xcdE\x98f!h\x99;E\xdb\x16&\xa2\x8f\x0cNQ!\x13\x9c\xf1\x1b\x1e\x9c\xc4b\xb1s\x19y\xd1)-\xf1\xd0\xff\xd9\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="fileName"\r\n\r\nfile_name.jpg\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="useUniqueFileName"\r\n\r\nfalse\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="tags"\r\n\r\nabc,def\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="folder"\r\n\r\n/testing-python-folder/\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="isPrivateFile"\r\n\r\ntrue\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="responseFields"\r\n\r\nisPrivateFile,tags\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="extensions"\r\n\r\n[{"name": "remove-bg", "options": {"add_shadow": true, "bg_color": "pink"}}, {"name": "google-auto-tagging", "minConfidence": 80, "maxTags": 10}]\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="webhookUrl"\r\n\r\nurl\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteFile"\r\n\r\ntrue\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteTags"\r\n\r\nfalse\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteCustomMetadata"\r\n\r\ntrue\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="customMetadata"\r\n\r\n{"test100": 11}\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="transformation"\r\n\r\n{"pre": "h-100", "post": [{"type": "transformation", "value": "w-100"}]}\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="checks"\r\n\r\n\'request.folder\' : \'/testing-python-folder/\'\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="isPublished"\r\n\r\ntrue\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteAITags"\r\n\r\nfalse\r\n----randomBoundary-----------------------\r\n' - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual(url, responses.calls[0].request.url) - - @responses.activate() - def test_base64_upload_succeeds(self): - """ - Tests if upload succeeds - """ - URL.UPLOAD_BASE_URL = "http://test.com" - url = "%s%s" % (URL.UPLOAD_BASE_URL, "/api/v1/files/upload") - headers = create_headers_for_test() - responses.add( - responses.POST, - url, - body="""{ - "fileId": "fake_file_id1234", - "name": "file_name.jpg", - "size": 102117, - "versionInfo": { - "id": "62d670648cdb697522602b45", - "name": "Version 11" - }, - "filePath": "/testing-python-folder/file_name.jpg", - "url": "https://ik.imagekit.io/your_imagekit_id/testing-python-folder/file_name.jpg", - "fileType": "image", - "height": 700, - "width": 1050, - "thumbnailUrl": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/testing-python-folder/file_name.jpg", - "tags": [ - "abc", - "def" - ], - "AITags": [ - { - "name": "Computer", - "confidence": 97.66, - "source": "google-auto-tagging" - }, - { - "name": "Personal computer", - "confidence": 94.96, - "source": "google-auto-tagging" - } - ], - "isPrivateFile": true, - "extensionStatus": { - "remove-bg": "pending", - "google-auto-tagging": "success" - } - }""", - headers=headers, - ) - - with open(self.sample_image, mode="rb") as img: - resp = self.client.upload_file( - file=base64.b64encode(img.read()), - file_name="file_name.jpg", - options=UploadFileRequestOptions( - use_unique_file_name=False, - tags=["abc", "def"], - folder="/testing-python-folder/", - is_private_file=True, - response_fields=["is_private_file", "tags"], - extensions=( - { - "name": "remove-bg", - "options": {"add_shadow": True, "bg_color": "pink"}, - }, - {"name": "google-auto-tagging", "minConfidence": 80, "maxTags": 10}, - ), - webhook_url="url", - overwrite_file=True, - overwrite_ai_tags=False, - overwrite_tags=False, - overwrite_custom_metadata=True, - custom_metadata={"test100": 11}, - transformation={"pre": "h-100", "post": [{"type": "transformation", "value": "w-100"}]} - ), - ) - mock_response_metadata = { - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "http_status_code": 200, - "raw": { - "AITags": [ - { - "confidence": 97.66, - "name": "Computer", - "source": "google-auto-tagging", - }, - { - "confidence": 94.96, - "name": "Personal computer", - "source": "google-auto-tagging", - }, - ], - "extensionStatus": { - "google-auto-tagging": "success", - "remove-bg": "pending", - }, - "fileId": "fake_file_id1234", - "filePath": "/testing-python-folder/file_name.jpg", - "fileType": "image", - "height": 700, - "isPrivateFile": True, - "name": "file_name.jpg", - "size": 102117, - "tags": ["abc", "def"], - "thumbnailUrl": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/testing-python-folder/file_name.jpg", - "url": "https://ik.imagekit.io/your_imagekit_id/testing-python-folder/file_name.jpg", - "versionInfo": {"id": "62d670648cdb697522602b45", "name": "Version 11"}, - "width": 1050, - }, - } - request_body = b'----randomBoundary---------------------\r\nContent-Disposition: form-data; name="file"\r\n\r\n/9j/4AAQSkZJRgABAQEASABIAAD/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdCIFhZWiAHzgACAAkABgAxAABhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFjcHJ0AAABUAAAADNkZXNjAAABhAAAAGx3dHB0AAAB8AAAABRia3B0AAACBAAAABRyWFlaAAACGAAAABRnWFlaAAACLAAAABRiWFlaAAACQAAAABRkbW5kAAACVAAAAHBkbWRkAAACxAAAAIh2dWVkAAADTAAAAIZ2aWV3AAAD1AAAACRsdW1pAAAD+AAAABRtZWFzAAAEDAAAACR0ZWNoAAAEMAAAAAxyVFJDAAAEPAAACAxnVFJDAAAEPAAACAxiVFJDAAAEPAAACAx0ZXh0AAAAAENvcHlyaWdodCAoYykgMTk5OCBIZXdsZXR0LVBhY2thcmQgQ29tcGFueQAAZGVzYwAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAABJzUkdCIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWFlaIAAAAAAAAPNRAAEAAAABFsxYWVogAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAABvogAAOPUAAAOQWFlaIAAAAAAAAGKZAAC3hQAAGNpYWVogAAAAAAAAJKAAAA+EAAC2z2Rlc2MAAAAAAAAAFklFQyBodHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAFklFQyBodHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABkZXNjAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAtIHNSR0IAAAAAAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAtIHNSR0IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAsUmVmZXJlbmNlIFZpZXdpbmcgQ29uZGl0aW9uIGluIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENvbmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZpZXcAAAAAABOk/gAUXy4AEM8UAAPtzAAEEwsAA1yeAAAAAVhZWiAAAAAAAEwJVgBQAAAAVx/nbWVhcwAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAo8AAAACc2lnIAAAAABDUlQgY3VydgAAAAAAAAQAAAAABQAKAA8AFAAZAB4AIwAoAC0AMgA3ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUAmgCfAKQAqQCuALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEyATgBPgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMCDAIUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMhAy0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA+AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH4EjASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYGJwY3BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB/gICwgfCDIIRghaCG4IggiWCKoIvgjSCOcI+wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ+woRCicKPQpUCmoKgQqYCq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDPMNDQ0mDUANWg10DY4NqQ3DDd4N+A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg+zD88P7BAJECYQQxBhEH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIxNDE2MTgxOkE8UT5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbWFvoXHRdBF2UXiReuF9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGuwbFBs7G2MbihuyG9ocAhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4faR+UH78f6iAVIEEgbCCYIMQg8CEcIUghdSGhIc4h+yInIlUigiKvIt0jCiM4I2YjlCPCI/AkHyRNJHwkqyTaJQklOCVoJZclxyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg/KHEooijUKQYpOClrKZ0p0CoCKjUqaCqbKs8rAis2K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLyQvWi+RL8cv/jA1MGwwpDDbMRIxSjGCMbox8jIqMmMymzLUMw0zRjN/M7gz8TQrNGU0njTYNRM1TTWHNcI1/TY3NnI2rjbpNyQ3YDecN9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qjvoPCc8ZTykPOM9Ij1hPaE94D4gPmA+oD7gPyE/YT+iP+JAI0BkQKZA50EpQWpBrEHuQjBCckK1QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXeRiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSjdKfUrESwxLU0uaS+JMKkxyTLpNAk1KTZNN3E4lTm5Ot08AT0lPk0/dUCdQcVC7UQZRUFGbUeZSMVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYPVlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVlqmWvVbRVuVW+VcNVyGXNZdJ114XcleGl5sXr1fD19hX7NgBWBXYKpg/GFPYaJh9WJJYpxi8GNDY5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg/aJZo7GlDaZpp8WpIap9q92tPa6dr/2xXbK9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfByS3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyBfOF9QX2hfgF+Yn7CfyN/hH/lgEeAqIEKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuHn4gEiGmIzokziZmJ/opkisqLMIuWi/yMY4zKjTGNmI3/jmaOzo82j56QBpBukNaRP5GokhGSepLjk02TtpQglIqU9JVflcmWNJaflwqXdZfgmEyYuJkkmZCZ/JpomtWbQpuvnByciZz3nWSd0p5Anq6fHZ+Ln/qgaaDYoUehtqImopajBqN2o+akVqTHpTilqaYapoum/adup+CoUqjEqTepqaocqo+rAqt1q+msXKzQrUStuK4trqGvFq+LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm40blKucK6O7q1uy67p7whvJu9Fb2Pvgq+hL7/v3q/9cBwwOzBZ8Hjwl/C28NYw9TEUcTOxUvFyMZGxsPHQce/yD3IvMk6ybnKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI/0sHTRNPG1EnUy9VO1dHWVdbY11zX4Nhk2OjZbNnx2nba+9uA3AXcit0Q3ZbeHN6i3ynfr+A24L3hROHM4lPi2+Nj4+vkc+T85YTmDeaW5x/nqegy6LzpRunQ6lvq5etw6/vshu0R7ZzuKO6070DvzPBY8OXxcvH/8ozzGfOn9DT0wvVQ9d72bfb794r4Gfio+Tj5x/pX+uf7d/wH/Jj9Kf26/kv+3P9t////2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wgARCAK8BBoDAREAAhEBAxEB/8QAHAAAAgMBAQEBAAAAAAAAAAAAAAECAwQFBgcI/8QAGwEBAQEBAQEBAQAAAAAAAAAAAAECAwQFBgf/2gAMAwEAAhADEAAAAfN/zX+r6uPT1Px/bu8/LJ28/O9Xgsy08ul3Pcoo6WnfWTrZjerlNHLMocPch21lvfD2cz1cKd87sauxbMW/DfyvSzxfOYWeN7fJ437vy+H9Hy836XDD6uerj6PSfJ+n634n1PQeX19LOsKeW9Hj7/1OH0zt831Ho+Jq78L+nMRSxVSqVLHNRFpQlUqWMqVZ0iKglJUABI4cgFENAAGjAcjkY5AYDRo0kzOxpXjWPh6ef5/Zg4+qvG7pztuLHObFWOnJ8nt8r5/X5/qy9cU7zVvLEoiWraGrZnr6byvp3X5lHfPvP0f5rD8v10c+tOelOelM6wzr8639X0/J6PR/L9Gjlxw+jzc/0+HRz3p5dLMblJn6qtdZOl3PWrjm/nJTSo6K+28t7Yes5Xs81euenlu3GrcXRhu53o8+UOMwduPD+h4/Ifa+bx/pebm+/hi9XPXx9Ppfj/T9d8X6foPL7Olm4K8r6fD2/p8fpnX5/qvR8TV24aOnJCVSpVLGVRGaQLGVKpVEbVKpUqASkqhhQjkcAIwHANAcAI0cNCGBJlo0qx0w8PVh4+rDx9VeNzmZszYcmbn243l9fneXTlbzRvMNZr1mvUhvLgmpTUNSncWt38u/vvP5vaejwW/R8nrfpfFy+H0U46UY61Z6U56Vzf5j9H6bu/P9vc+f3nnjyvZ87L28unl21celmN2ZzR0V762Y6aOetfC381mdpp9LX01l6dMHXHL9fmjc6eW9HPV2daMtvKb+fOnGeZ283B+j4vLfY8HJ+h58Hu4YvVjZw9XpfkfS9Z8b6XofN7OhnWG58p6fF2Pp+f6bvx+r7/F19vPf15ApUsZVmpYqoSksRSqVCVNRlKUAKgCAaAwgCRgOAaAwgRo0cOAaNIZ1zPN7eP5/o5+XaczJlledY+fXjcO/AxeZvDQZhqQuY2Q1CxykrWNV6tmeujn1+oc/m9v0+bq/e+T2seOrl0px0p59ac9Kc7hOn46+59X0Hzfd0fL0q35+Z6/DDWNXLrp49LuepJTs3W/l0v572cNX87ZnpPPa3ParVx9+eL0ced6eE41cd6eW9HPenneh59bOWaLx5vp83C+h4vN/V8XM+hwxezjk9GN/n9fo/lfR9Z8b6Xf83r6U3gZ8p6vF1/pef6bryer6/H19vPo68WKWKqVSxVKshUJVLEUqaQlQlIAlLAaADCAcCEAwRwDCBJI0IcA0y8u/D8v0+d5/ZNFGeb53PrwuXTgJj6ZnlLOJzKZjqRsjYqlDmgFhbDW7sdel5en1rt8WPeex/SfnNHkzXz3TjpVz6UZ6VZ6V53+Lf0f0et4fVo52npwz9OMpb+fSzFnmx1mGg3POrcW7nZRKblOluOlW2Ttzx9+OTtytzrRy3o5bvxvTy10PLu/nmnpx53p83I93l4/0fLh9nHL35UdM9Dz+zvfM9/p/j/R7fj9fU59edvl5b6Pg9D68/V88vWb+Tt7ebR14sQpYqpUqzUIFUJVLFUqUEqCAFEByMACnIAEA4BoQDRyNGAQBLz+Hq4Hk+tk59sk1ysb8/wA9cDoxdVuZZmW4zbjMs5ViuY0qZLIajUNWvW7M9NPLt6TyZ+m+z4eTpr6F+q/K1fP61c91Y3Vz6U46VZ6V53+XPr/pNnPrbikzItzbpqebKGiotkrxHCkhUNUtrsz9M5d8cvXDzZSuFIRfwpOefpywd+HP9PDF6uOP0cs3bnRvOjl23+T1dfwevp+P07vP153p4cH6Pi9Zev2Py69k+bv7eXT14OiIqpUqlUIFSqFCaQlSghKQAAUwRhBBTQgGEjlILCHI0Y0AAhjXM8/t4PH6flOXfzfHfn+sxd5LNnLbjN3PNuJbjMoLmNKo1KHCqvSrWpTro5dt/n6+05eL3Ht+Uezl7X7356nw9YY1Vz3VnpVz6U43Bv5Z7v2NzNkljM0mzNJSTuZI0aMBSxWKq2CohULYwCI1EiEKSuyjWcu81azRvNG85t5gpnU+epYpNYt8+R6PP6nnr6b5sd7Xi29fLp68ZWRlBKoUqUFKhKoSpUCggEMAAIYBRACMAhgjggAcjkYIwKOfXm8Pbwse75hz7+T1a9FU823C7nLsZuxLMGjRLGlYqTJZVqxu7cdtnHv0fL1+kPkd/wBni3fb+f6Pr8yPPVeNQ57qxurHSnHSM1xff9G687Liy4nczSdy7mSSsdgNEqEqlSoSpQSqC1EZUsZYtRWM1BqE1BqC13UJqtYNVTVVtNZ9Z62efYefbvzaunC7pzaAlUqEqgWKoJVCVAoIAEojAAGABIDCiAYZjCCCgcjhoAZOXfm8PbwJ7Pj+OvF6WvUIuxbcNHPN3OW5zKHKyNIViQsjZXoTpp5ejf5+/S8vT6v6fgW+jn6T9H8To8fOsoY3XnVfPdWOlWN1Tb+pLLmVk9ZdzKnY7HY9R00dAIBaSFAwoR0UQClUsclNRljLGajLGWE1GahNQmoTVc3Carm3V15aN8r98p7wKClSoUqEqhKSoQKCAQAA6IAGAACOCgIBhmOGiUAaPIRS4uXp5fH2+Xer4xNZt2rcszbcXTyl/OXc5OZcMBKCsEEq1mra3PbZw9W7z9+15X1X3/ns3XXu/wBP+YPHVLDGoY1XjdWd18+kM6j9SzuZXMrJWS1HY7HqS1JWOwp2FA6EY7CnZLUeoUs1ZoAs2OLGWObGajLGajmxli1CajLGahNKVE7mzWJ6jsUCpUISqVKoBKCBUAUoABHQpAMACBAcOgIACRw5AIACIzXP4+vk8/X4aej5J0Q0hVuF/O6eV0cpbnMpHIqFYhU5Fc07zG6ux32+f19Pyd/WcvL733/Gj6efuf0n5yvw7jLDGo51XjVeN146VzUPpanczuZakrmWo7HZLUdkrDUejCwGFjpj1AURza+e6uXRRZ0zf25S3FixljLHNUsZYzSlUsZYrGVKpQYUAIFQhKpQFQhAqEACAAAYAADGEAQAOGAgkBw5AIAKsb5vH3cXPo+X3r896yvVC/ndHK6eTVyWZy5BmNorECSkhrNW8k66eXp6Pm9PQ8vb39+Z6f3fP2fW8PqPb8iPDSI41DNhjdeNV43Cbq+judzPWZ6zKx6jslY7mWj1HY6djsdFgCxxYYsOe68aox1xcvTy8e6y47Pb5270ebT6OIkc6UsZY5qliqlSxlWbBRVKhqgASoQSoSkJVSVCABDRAAAADABgrAcIIBhDCQAIcggqlz468vh7eBe3xzW/L9UKlF/PWnldPG6OeZ5gyMx1UrhjkbNesxup57a+Pq6Hm9O3hv6l0+N0vX5e59/5HZz4lKojjUc2GNV43XncM6p+j0s1mVzPWZXMqesuyWjseo9R2SsKURxqOLDGq8box1xc/TzMe3iZ93ndd/D+rhpT675eHsfT8Lb6fNZ0wpYyxzVKpYqpURmlKljKCUhKgVAKEqUEqEIQCAAAELRABgAK4KYQDAJSBCVgEjCBCFLj5+jk8vX5rXb4f0c7VhU5bud1cbp5LsRzMplVDRyhLJsDMNQnW/Ho18PXv8/fpebX1n2/nq+z2/6T83b5sgojjUZYY1DGq87hjVP0es9YnrM9ZlY7HZLUeo0ektRRDnqvnuGdQmoTWXHfl8vdw8+/zW/R5Pvjyvo8nF78OL6vKMfe/i/a+v5+L1vR4NPfi6jKs2IpqKkJYyqVClSpVKCVCCVCUVCEIQgBFTQsAAAHIAADUBRXKAMIJRBXIKSA4JItYOPp42PT4zp2+IdcVa0LZm6OV1cbp5LcZHNkaVrlZLMlMx1mNTz208vTr4erbw7+k48fpPt+LV2z779L+Yr8WwMo51GWOLDO68ajNZ/f2nrFmsz1h1LWZajsdOx6gVcd5ePfLy75c98GfXyp7ODr0+R6zx/p8fnfV5cvTlGycubfPk+jyfQPn/T/AEV8rv7Lv8TX289vXmpVNRhNKEKaSqEsZUEqVKCFCUE0hCIgKxIWADAEEFAsIYUQSlhKwAFc01ADIAcAQBEM65nL1cOd/mfXfynvIWylv561cbq427GZ5w5kthTlbTZnmNmFy5b8ejRy9ezh6dnHt7fHi9p7PmX/AEPH7D6Xw48NNFKs2Msc2GdQxuMub3d7NYs3iVzLUlqS1l07HYaLNzebth4+rzPD7HitfR872x5/tw5XTlzOvHH040dMRQsjZVrPL7+WyP0R8L7/ANf5fK6nfxau3B2RmoyoFjKKoiqlBKpUJQUJUoqERAjYIWIACwgsYIBQMAAAGpAEEoNSVtA8gIACApx05XL0+fvX413viOtrtsmtHG7OOtHKW4y5kZLqNrlaSksxHMx1iU6Xc/Vo5+vZw76uPX6fPl970+Prfd+T3c+GSNCI5qljNRxYY1Fcvu9Fmudmsz1mWsy3GktQp2Olm5PL35vD2fO+f6P417/Lyu3nr1AilWsVbzTvAlVzRvGTrx53XzfQvn/W/Rfx/X7bp8fX24XdOcZUqVQpUJVKCVSoSgoFioqEIiiuSkCAgCxoU0AAAQURqArAACUglYBNNSV5AAEZOfXkY9Hm99fhHecDoS3Y3p5a18bfzks5GWJpXTJZzPOZSK4CzHW/Hr08fXq5d+h5t/Xu3xn25+z+/wDndPDnJGgLJSxmo5sMajLm93eesW7xO5lvMtR2Oix09CTP5+2Dz+ryvn+58h9Hr8H7/n0XnZK5a9Zq1nP05w1K7mneM2+eDp57F++/E+79r83h6fXxaevF2JVKhSpQSkJUqAUoJUJUIjcpCkCILALloAMAAVMAAAGEA1FAUyJQcDTleaARXDy68fHfyPXr8B9GMug1fz3q5a08V2CZYLG0VpPOJ5xLOY3Nk3fz9F/P1aOXp1ce/oPNPqfr+NR0n0D9D+Xh4uj1JIIs0iM0soY3Caze7tZrFm8T1me8ysLHTsKdO5r5dMXl9HH4/R+d5+/8q9/i4/Xg0lFOs0azRvFesValOudG8Zt8sHXh7Xx/Q/SPxfoe218vV14Xb5oUqVApCVAqEpCEoqERRWJCkiChC5EAGgFAAjVIKAMAAFBwK1AglJSGrmjNZWvN5duLOvzzv0+Kds1bOa08ta+O9PJPKNkpqFK1yNizObMScxFLsdL8erRy9d/Pvt4dva8fN731/Nfq8/tPq/Ah5dyuXTQhSqWObHOo5ub3drN4s1mWsz3mWo7AdhTp2Rxcnm74OHr8bx/QfM/T08j6fHk1zjVes59Yo3mu5q3inec+uVNxh7cRPvvxfvfaPH5en08mnrwlYglSgghWkJUAKhKhEblIUkQWCK5YAjoQGAAAgABgAACg1IagSgSkrgmnNUy8rn24F6/IvRr5t0lWk5rXxuvjq/kBU5uGqrXMSmLcZnnIzKW/n10cvXp5+u7n23efr9Lz4PTenxdH6/zO9nwuSbLsYBCljlHOo5uf39p6zZrM9ZlvMtR2FjCnREc2jj0y8PR5nz/a8Nr6fgvX5OR14494ruatZp1mnWad86NZy7507xm3zw9fP7TxfQ/R3xvo+2x4dXXz6OnICVCBUCoSgAqEIVzFktiiCwuRAaOhGMLAEAEAgAAVoTQAAoCuAbRBKSkEubPTk8+3menT4P63l9SedXc9aeWtHK25JktTUbtU5zszi3GZSWZjmtHL0aOfqv5+nTy7beG/r9+bs9HD0v2vh7PLznrMkky7GLNQs2Msc2j39bNZlqTuJ7zLUKLGFOlEeeque8/Ltx+H0PKc/t+I9DyPp83M6cc9xVrNdlOs0bxn3jJ05Z986Nc8XTkrn7x8b7v2r586+/Jr6+edyhKKCEqggotBERXIisQhWFwACSsBo7AYWAgEIAAAlBgCoFcAKSsFJXKRGXHjrx8dfH+jf5t+lw591q49dnDe3hu/kszI3CaV1DWmSnOzGbcZcTl0c+t3P0X8/Tdjv0PL26vG/XvR8ujrn3n2vzdfj6SuZWSmZWMIUKWMqlp93SW8zslcz3mWoU7CgdBDlaefSrn0ycu/neH2PKb+j4L1ebgduGDpyqsjZC5z7zk3yx9eWTXOjeM++WDrw9j4/f8Aov4/1foHDy7Onnv3xFUJSkqgAKVAhIrkpJELkuQEB2MLGjsB0ICEKiRAAAOUUHKhiUVwlY5QJoiM1zufTizr4D2X89e7zc7tJ53u83foebpt4WzBzJUbuGrKJ4xbnM8hLca0cu9/P1X8/Rdntu8/X13Dn9J9Hgj34e5+t+ez+PvJmVjslI0AFmxlUV/Q3O5ek7mWsvUdOwphTsji1ct1c+lPPpzOPt8vz+14f0b8P6/HzOvOq5jIrKNZx9OWPpzxdeOffOjWMm+Ubn7l8j7f3H5vTsvNq68JsJUoqEoFgKhBI2FgiFYXKRoWA0B2Fy6BhQiEgqABAOCiABzQEoCikCuVilrzrlc+vEvT5l9CfLvTjmTPOqm60ctbvPvTyujmlnTWNsonnNmY5HF+Omnl3ux6bufqsz12+fp9K4+f2nfx6fo/O9Jr5656ky0lY0aMBZLNUsfpWVjslcy1HqOnqOgYWEQ56q57p59M3Pt5/wA/1vH6+l869vm856PNRrJCsp1nLvnh6csXXll1yp3im5x9OPrPN7f0P8b6n0Tzctm+GjfFKlBAIEKLEFyrASArgsEYWCA7FcuwGFgACABAAAAwUgAFcpKApKK4FzZ3yefTib38v+i8p2nMObmYMzJVVzdi6+O9PHd2NuJSTzLMglGvj30c/Rfy9Fme9uO23hv7Hy8nY7eft/Y+L0vLhsySVjkkjsaKUhZRmn9NK5dkrHY9R09R00LCmRxa+e6ufSnn05vD2eU5/b+f+m+A9vhx7wSFVazk3zxdeePfLLvlTrELMfTlG5+0fL+z9z+X27ueWvfCy5QlVgCFCFJC5KSCK5LAEdggFhclgAIUACAIKAAAoAAAKBDUlJRXKClx43ys9eJ038297j9Jz9OeYYx4mKZyJTqKrMb08t6+N0crbijNud7OHpv5+i3He3HbVx6dHhv7ffFV15+4+t+dj4+jslZJmUCSQRwpVCzZ/UzLUdjsdOwqWo7Cx0ABDnqrlurHTPy6+c832fEdPofM/f4vPd/NELK7M28YunLH055t8qdYrZo3jJvn6Th6f0N8f630bx56N56t8UIVgCFOxBclgiC5LkBGhYAi1ksAQpgCCCFAUAIcCgAoEoAKBBK5oCaRz8dOXnpweuvBe3WTUx1irHbiTJmYcuczg6YydoSdDzb2cN6uOrsa08euzj6bsd5Z7SnXXx6es8r65ryZ+3L6B9T87l8XolY0lcuSQ0aEEKFnVn1uc9ZdCOgKlqPUdgNErSMtfLdfPdOOnK8/v8hy+1869mfnnu8GbeIs1blNzj6cse+VG+dNzXqU3GbfNWfYfm/W+5fK9PpOfPfrnZcRCxI6EKGSwsAQuChBHQhYXKsAQoGgiQooCiCgBih0QAEopKACqHA05a5rmY6cyb8x6L5L1bpKaoXOuSsSY6wZYs552s83txq1Luer8XXw69jxejZ5/XfjvKdbMddXLf0jyT6Fvy3e3w+t6/Mq4dJMupMtJIwkaEKVS3/X5S1HYAA7HqOnY0KIKCHPdXPdfPeTj3835/s+G6+35f7/AAeX9XkjVVzRvOTfLNvnTcUazVvFTNepRrn6Hj6fv/yPq/UPC6Lnr1zSFDIPQRXBTALzdCCOhANYVgNFQggjpUBYAAAAAAArCAUDRKAoEOaozrlY6cu78f6tcLva5qBGWtqis5ksyxhtxSc+YwM83vyy9cW531/D6et4/bu8/psx2v59dnHX2Ty49L083W+n8jr+bDSUkkdjSSORghklUavscXrL0IAHRUrl0WAAAs2rnuvnujl15XD3+P5/Z+ce3l80+h87D05VazTc5enOjXOm5p3iq5rsruadYGfrXz/rfdvler1HHG3XO65SCOx2q4aFCFjuRGhTQDWBChCkjQsKQWAAIYkABWAAAAoECmaKglDLnpycdORrXivZrDu1rCaJYrGWEVLnrJWMwJhTnzPL3yw98q76Pl9nT8ns1cevR8vfp+Xf3vz8X25e0+h8OzzbkjmZSMdjRpKQghCl2fa871HQAAjp2OwoAIAivG6ue6eXXJx7+e4fW8H39fyv3+Dx3t8VOsVXOfeKNYpuatYquYXMLK7mnfPvcfT98+V9X6p8/XSnPZrEkEdhYXLpghcOhHYIwuVclNCgGCilQgAkBI6UFgoCNRSGqAAUCDNGgUuHG+RN8TrvxXr3VUJqKkpmpVNKWuKazW46xpz05zHH3z4nr5Rvfd5/Zr4+jr+L0+3+X0+7ebjk68/ovv8AgZvJ6XY2ZSOR00aOCQggNv3PM7HQjAKB2FA6BQQRGaq5bp59M/LtzuHs8ZPr/Nvbx+XfS+Zy+vCnWKdZpuKdSrWK7muyLMbKtZTP1LxfU+7/ACfX6vhnoXnoQZdFy7HTQuHQjR0I0WsFCMLBBDRIUCQFYIAAAAAADUUgUEErhKSxXm43yZvznfflPTqFsJYSyaeapXNKWMsCpc9tBirk3Pmevn8/6eM511cfTXrro5+j7X+e9f17ycl6PN7r0fJz+bu7GjkcNGjHI5EEB0fveQsYDQoAB0WMSkKCFLXjVPPrTy6ZeXo87x+r8+9Hb5X9Dw+G93zqN4rSjWKtZqua7mNzCyNldzXrHa5ej7v836v1T5uuznnvZlY7BmVjsEdjsLGjGiuTUEKEaJHorAEQWJAAEgCAACtQAENoghQNALVm8vPTkteT9W+D21BqC1lma5olcpNLOkUFcsbrn6zwdZ85044NY2cu2nj1xbtet/o3879D2vPn0Pf83s+WmTRySHIDRyNAMlKR2v0fzXYDsB0UQAPUAhSqFKojnVXPpTz6Z+XXm8fb4t9L5r7uPyr6Xy+L38tVldzVqU3ENSu4VQuY2V3m0+l+P6n3T5Xs9n5c9Nz02FyyVjsLl2OxkkELksKEKGANCwECKwQBEgCAAIKcAKKKACkpBCUXNm8rPTk614v2b5m9RtjLBZSvOnNPImiVS0W0FFcrU4Oscveb+euty6zzrCLlr9HfH93QnP0Xu+ZD5/vcjSUCMIcjkIIBSkes/U/AdCFOmhQKAdAQpVClUsZYY1Ty6Uc+uTl6PP8AL6fz309PlX0fD8/93y8+s16zXZVrNVxHWY1FmNkLiNx1+fo+4fO+p9c+Z17eOe9iyx2OwZlT1GjSSK5LCmhQyIUagAkSFgiQQEFAAAgGAQ1QKKBKQQmsWdcib43TXiPZvPbG2MqmmrzpyuaIM6UtFuHU5mpwtY4usWZ33+Pbr8+hLlTVx6fevkenH1x7Xr4eB8/7TkcjRwQ5AlIQQQoUv0H9f+Rdj0B2OgAFAAQpVCzUsJVnVXPdHPrm5dudy9njb7/mXv8AP8p+j8zz/o8cKrua9Su5hrMbIsxuY3MNYZ9G8f0vuvy/Z7fyTpsa2XY6dy9RpJJIIayUIwsGQNCxAiQRWCCIKBIAAAAKAABK1SgKZRl503x5rz3fXj/XuKqWKksppzRKSqaJaDDpydTi6zwdcs8noePp9Jy7dHG65YR6Ty9vqHz95O3Lu53575v3HDkY4cgkpHI4IJFmrL6v+5/BPR2OixhYBKoFUEKVQpY5sZpRDG6MdKOXXHy9HDx9D536t/K/f4vnX0PlZtYrshrNdzCyGsqyFwkhrEbnqcvR9t+f9P6/8zp3ueejcW3IkrHqSGjZNZdCMVjZQ9FYAiQsQMiIVAIAAAIAAABQcCjSCWuOXnXFuvIerfm/RolFJZTUpqU0SmdRaz2c/Tk6nFuOHrnhZ7nH0el59+3z6ac6WbCPd+Dv7Hyp+vx8b5f6DB4/oPJyyJZspZSNJzLkcjyWaj7N/Qv5tLQsaOgdgIM1CUlWUc1ZsZqMsc1S1Y6Uc+mbn3wcvV43ft+Y+7h8q+h8zzXp8cLIazVcw1I3MbmNzG5izHWSPf8Al+l91+b6/d+J2JjZcOpayySNHcljsAoZEKLCkggKwZEQqARAAWAIAJWEqAFBhKKlol5GdcDevA+3fJ60Vyymp5tubZNOaiuSznavJs4tzxdcsR2ePb0PPv2sdOpjc81SrOvqHy+3VxNnq8PiPifro8ujycs5Z4s5uwnJJJSOZeRkpftX9E/mb1GgFMKEFMkKVSrJSxzY51GajKRXnVOOtHLrl59+Fn2/OvXr5b7/ABfN/d8zLvnVrMLK9ZhrMWVcxuVZG5E6HPv9l8X0vsPzOnp+GOlc3ajskjGyXLosKGRALCwFQhYmRAVCIAoRWAIAogEoE0AAlcAlx53xpfNd9fNfd0yarVy2RfNXZtualx28zV5NnGZ5DnRZ2OXfuc+vXz06fPWzOyVS2c9/XPldsnTHd1w+f/G/UrOiVyzzbM23OrSyJjzHEpDIy+1f0L+ZmhAOx0AIIBSrJSqWvG4Y1GajKBUMapx0z8+vP5evxfX0/MPfw+V+753l/V4q9SFzXrMdYiykVzGwsSEvs/P7vunzvZ9I8DsTGzWZU0bLZdFhQiuQGShCihCwZApICALC5ACgQBBRKAINQzTO3EV5+dcNfH+vfzX2dIKyyW+XRF2dRXNXMrkVx2eXcTzrsY69jn16ed78a3Y1POoqL2fN1+nfO3zO2Orz14f5f6JZpEpZ51ZLbm3RYtmUsnDkII+y/uf5uojnTR6OwEEqhSoJXqKVRCahjUJt2SsdlWOlOOmTl6PPz1/Ovbn5X7/H849vzcPXnC4hrMLmNymSxBYkDVjr9g8f0fsnzenqvPOgxbY7BGjQsKaFyDsEdgOi5aCFFJAKELBAKSAwAFAhVHpyhvDzqfHu87hHMzrga18/92/C99izW6NC2ywMtvOTlVx2MEmzPTsY7dWb6ONbc61Y1dmqVSymvYeHt7Xy2nv5+X4/r8LxfSUpLPNnm2Z1bLdLbLOJ5SkM1wSfYf2n86hjVWNwmoZ3FZXJCUWIlZK5lZLWZpVndGOrLOnO3WIy08+tGevNx6fC+nfy73+f5n7fB5b0+KvWI3KsSRuVYIrECNfW+f2fZfH6vo/gva5Z0pJJ02VYho6bLQsZKyVjR3LGjoQoCyFkUQIkQqCRYW1JRY6xR6PNn68ZZ66vJ67OXWmORNec6a+Z+/fnd7mty2qFJkrBLy7nnTOU2569vHXpTW/N2Z3fnV+dEqyJZZ19F+d37vHN3p83hvmfpauPZTTynnVmdWYt0t0tss5Z5SyeSgj7F+y/nMZqrG6s9MPH1YsehNSuQhNVNpZJo1y1a4ad8LNZox0w8/VVbfrnr6cZ3MM6pz04jv4T1X5/7OHgvT5fI+jx5N869YhrKSNyrlAK5AL87+icPZ9L8vX1vldjlOhia2ZJTWexE0aRsjYFqWyXpYTuZJKmkqaGsxIEbkCsyYrclaY3xsjRa7Ya5Y/Z4sHr8lnL0dD530tfHtkjjL5bvr5h7umK6ulmsbcyY7ecc5OfMua6OenUz06c1tzdU1oxZyk2oUss2ed/WPldq9Y6useA+Z+khjREs7nm2Z1bm3Z1dLbLPKUssnkSmX2f9r/NXpGWnn053m9nL4+/nz12MTucztndktDe55u10+bbcU53lvTN6pyOu8OcdqcehjNmbVnXI1rw3qeM9HLyvXn57r58O+WPfOjWa7lWRsQkVgqZjXSl9lz7+r579f5r6XzzbjJZTWLaqy+KtZq0zaiOhjPb4562ZsSwssmkrJU7GisiKxEE5xxNOF11txe5ydPN2FlQ3ywe/wAHJ93gu4evrfM+ru4dsEcK3yPqvzn19Xa4ptyViXmnLmcsaW+nN9Ka6Odbc705t0s82MqVTTzXL0uPT6n8zryO2O3x6eC+d95DmpTVnPVk1dm3Z1dLbmziUPNlmkLL7r/Rv5ZLcNI87k8fo5PzvocPh9OubKyzvnu82qJ0pyimXWOP2zX7c8L33z/pyt8uNjxdzjru+Xp1fLvfyVL5bvPBerl5Xrw5PTlGydk0mrR1FIoqEepanXmu5m97letzbczLvPP6Z5vSRNeVOpmucu2jWPYcOPsvLz73O9BbCdSRgA0VCA1rjnxxq4m9X5vb5XpxstkQ6cuf7fn8b6HzdHD19j5n1tnD0cvLz274j168X6ekVy24awnOObMwzd7fRm+i1vzrZm6s6tmiEqljKTTledeo8nX3vi1g786/H9DzPh+oLOannduNW5ts1fm3S2SzylK4lks0j73/AFL+TljojP4+3K+R7+H4vp4serHO+S9c2pXvHQcYHD664vXHh/Xws+p05vsnL68X05+V4fM9Djp9C+d6fVfP69DjqlfPdXjPTz8t25cXfCVal0y22TJEyRK5sLGdFzqjbi2FuWlKdZ528pNmW7C+SNyazvvH1PHl6HOOouu6kO5WsxSq5quVTsdk5bMdDnujGskY1nLtjVbaOq988Ps8HF9/zL+Pp6/zfsX8O/GxfM9NeH9m/Ld9YNXmW8o5+ZmLZerN9JrWuyXXnWiWzNJqKxlJSUlJpzXv/ndvT8LPrw8h4fvZ/N6ZZ1PNsmrcbtzq3OroulszZw1lmORZEfef6n/KlABT5unP+V7OR8/6HN5eznY9uadVqT1jHqcrrnzvfHhvT5/H+vy7/Tv0vs9WjOV5OGjly6/LevhvpcNb+amuN1nk++PO9eeG50GyTUaEuTVGrM0xdJomdmZ0MTdnOiS0VldldkmbpLkukuSxJazbvnp1jZvOrVs1ALI2VXFGsZtYpuI3LuXNWc+s+fWfPqQyds1mMZVrOL1eHl+z5zx26Hz/AKb83p4EvD6b4XbXK665HW+c6XzJyomvTa6s1sW4smtEujJSk0mozRClavOpZ19T+X31TPQ1jk+D7HZnMl1ZaWWks6qzu1HJKJJDOr7zqzu5zjNfSP3/AOLjz0sUKuG8Xz/Tg8vrz460c+ubPeqbS5L05+ted6uD35msWWba1SSwuzYywSmsdcreedqXSbMS3Msk0TN6XM3yaZnRJYTktzL7nVc69407xf0w7EjslZKx1KyRKxMqwsnqSp2OxWK5hc02U6xVrELFrJEuXQ59J46TWcE0SiskQ1jP382P0eVTV/l9Wbj281dYdbgtdtGri6Xm9Nc7TBrWW2FsbRqJUas6cpLBpTThTRLKW/G/rnyevJ7Y955d6uHXvenwWXNlzn5dtXTgpcnH0bevmrm79cVbJLunOGdWucda3/b8kOWq+W44tPn65fN3q57dRljNRlhN0zefPapqpqJBarEVTVU1z2+VpzdTJrNWpok9DynX550ZwkGXZJmes2MySSMaWXN2s6euNXXnb1xXc54iXazds7BJWFiSMpY0dRAlcvUKildlbNdkLITNWdVY2RKHLHO450pZ5tubJmHblV24rUfPrysXzfXVbUbYqlSixtjLVbVbTrVFuLV5+7i1c2rW1CE0poglUvofN1+i/P1i6Pa+Hvhx6fZ+r5XX9Xhu6c7enOVjsdjp2ABRAEIx8vTDluHLVXPdHHrTjoVbvE9YeolhndeNPSMtOOtGeled0zpVNZJvnOnL0orPrOfWKNSVz2eeetzzqzhwXM0ncWJYkrJWKAdzbZdvGjphdMYU4657NtzsubdR2TSdy7HVms3aOyvJSysdjp2Fi1mNkbISRlrSsjCzYy1ileNT57lBcrpk3InPl4XRgu4rGlNCpRSWFrlnLJolTVbWese7zt3nbvP3rMtcRX3Ph6+u8t0469zyd8We3W3w7Ho83W7eLRrld05XdOctxoDR6AWAAcv5/vUKVS1Y6V40Dp2IjNKWE1PWUtGOuXn2z46550zLm0x1i3Ob0zzemeX1xy+3PDvHQ5zr8WvMvNDOrM38m7ndMlyW2W2TZnqW3NlkrMsvGb8x31m1OhnPQmbElc2s2pazdrOjeL9ZdlcRyY9SNjubd5s3HrMLIpGIJCyJGM5mWCuITVCtNCaDRNabMKeN67y63PNlK5pqNBi1YW6+d1Y0K1JXNkpKKms64dXmbvo/N06OHa4ddPk9l++Vi2bxdN9Pv5ul08enfG/fK3pzncvUlqAqACOD8j60Vdk9Zeox2Oo5ted141XjcZqKi0Y6Z+fbNNY2sdY95zXOHpjndcc/pjndsYu+Ezr5N3Nty3mqZ1Rt5tmLdJaW2TZdk0nUrImbOue6ZrS5NZLFctJXLZdktYncrWcus0aNLCrWc+5KZ0ltzVpTZIkmesmkbLFpTB0tGkpWV2xskmrDqcr2eaJwN6wdLh1qm6nm6c2/NtzoVLZlPNk0K5Rpyk05XNSmnLLOp5sLfoXnS5enqd/LRnpdrHO5evn30z1ejfJ1NeHXrz375XdOdvTErGhQEeP+D+irai1ZrE9YlZKwFmxlhnQtt5265iwzqvOqs9KZqqK7K7KyuyKG8reRHDRkrALI3MN5jcpHYXIOxoWFlctNqR3LslctJXLsLGhYXOfU43VyOqclxGzLqUaRrRlHUzaQqyJsozaVaSS/KUU6Z9ahU5LEgtawVmvm2YX4sLcetYN2jVnnWzF289X5s5XmtXLJXNDRK5ZZ1OalLPGsvR9V8uO325Rlg1W3Cbg1VN5p3rmtGeWlw1Xhp1xv3ys1izeJ6yV8/wDz362dzGakzK5FisVjNQmqnSJ1unh39fI0rm6s7hncGoTSIrEiRRWRsrsrSCxsURImLtx43t8tHTEkklkk0ui9LZLEnZNLEmzKySTskk6dy0KzpBKNOTtxOxLqzBK9M2mXYs1ZkbMmrl2km3EDLq5Ng05mrCusXS5tGaMaDn7vO6Mizjrc3V43scU5cW9crow70TW3nelzurGp5rm5K5W08pS2Z3OWzFydZ9Y8c73p4GToK5rPjtnz1SySesKWGdLOpzFlxded15yZ8b8j9HfvkSwllZKxSwmoTVbdc3TOmm8du/MqrzuDUJutqE1EharIJBK0jZXZWzGxXLuRI1zPTw430vBLXO+TfjO7OdMaZL4vSxmdkkmOx2FggFisEVUSUisDJqcPpcW3QxLQKbOT2cjs1Zm3OYHP6ay6VanR5NWZVpg3cu7dmbsSyKNMWtc3ppCty1ztsd1CVYvfxOtw16bhNnO5tXkddcrpcutbMXrcdbeerJZTTmrcrc6smp5R6Z+s+WdTvykjUhQClhnVGetGOsJqVkrkITcJqGdLN43zfoz1m1zoz2EncyQFKljbCaioCxVCljbGagKlqRYjYIrEkbkIWJhWU3PD9HLmfR8WrXCbNfTnszno87v53blfZJGyUxI7kpoWOhCwQsilJAmka5e55rvcXR0MKqhVNlG5j3duJqzILk3MO7Ct2M68VWYN653SRrTl0uVS8rpeZ0vN1celVsDE1IMaZ2+bs8r6bzu9xspeP0vG665vS68Xv+fezFk1Zi3Z1zuk9T5+nuuXLo9MadyzUJSQlWQMUsZasbpz1pnSE0ljKl4Hy/rXXlYxTOrsmjuVKlQKlgsWkqFKqFjBSqFlVlVk7myYaOxVFBlWVJxe2fC/S8voevi24xj7c7U6nF0eetmVjMrJMlOmyI7ALHYWCNFYARR0IFVnmu18X7L0ubbiYOs2ZwS6MvJ+rXRw3czIVns43W2nW55y6tOlK87d6XO7czPpzOl8/wBdZ7MvPpTprsjjVubsk1c9aksmvW+V6Tha1itmbbNXZovK63i9NYrftfzdTc+j0mvSwsW1JUQQQClIEWbDOq87rzuE15X5X3b7zmzCarahdyuWKkqGBFYtKWKxUEhqKosQSGoBcTS9hpXUCFiM+pwvZ5a/Z865kuSzZhpzLYlZOSVjR02QkjR07mVjQRjpoI6EAFWbU+f+rXmu99VxzOOP2b2IRs5b8V6Nd/Gbbmq22Oda83qMzLJJRJbYnm25aJdUb8WZqt6+nX083yta8+66GWyWteti+S7zga11uWu959cHfTm9seD9V9P4+n3ThnT0xk56rxZ22tRzZS3XNly6JCVZpBIlMnCXx3yP0MonrEkhNZnap0suK5tFtxVdgIBK6UsVViuUJlXIkbI2Oy1mxiTKIGe6qrm9+fO+j8exLksktZnFiO5nEqkjRsyJWMdjuZAyx00EdjAKYrAgnN3fDenXZc6V9f5WzMkGrVq7i8sqy2yyVWLOp1KWdkz5F6J4zV9Pl9M899Jq6+qdDWXlI5uzqspLTypKpSIykrFKppSxzbADNWbDOo51XnUZJy2WTuWizVKsIqZEKa8t877kc0llclQm8WfRTellxGas1iMqJsuRGbUC6WRGxFdkUdzEdhZJkSSSsilFI4HbOf6XyNDnclszNJpNJEiaNlpKmjJWO5kNl0wLEt1LdLqu1bKe5eW1bU9LCy22p1OxiIwCFCAiilSKIw6UeR26bXo9S/dYQAsiEQxVCzUrmhSAYpWOUmlBKZrBTJizYZsMarxtSyseQhmxlWRikeT8H35TM7mDVU6NOe9OVq6Z2znKyrOywoZrspsrQSmq9NGa5qFV3LsmzYFjZsZkllyWZzm9HB+j8zpb8sybM0kk5dFzqrRrN+po00al9l25dZdV+1lltW6XaSokikCmMq0RStEUy0maWgymRc5nlpXPGVcpQZVxrlMlZy6ve45fSnL1HWatmOlKU5ABtQy5/Cylu0mrG2AEAKK8pSojnUM1xJXDCVBks2GdQxqObHNJVhHnY415fxfchNqLbiDSUTG75bbIq1IWFmmGyXNNldzWZbMHXOTpjoc7rxrXjUFdgKxyWsskkkLnJufPPo+c9Hk7OM+k3y7np53aWJC3PLVFEucyy8+6imaKDKYjDWQx1h1nFZlTPZRVFUpVqU3NdQ1KyrUqKrI1EhKiROGtkWF57TPP7BOf2PE9DtZYKU4ABXRABCWrnc/KxiSztstkAwUCVq4lKBLGaWaRIcIAgyYoWas2ObHGoYvivk/prXODVc3G2bNE6CJabY7zXZGzn9Mq4iKKguM2poyw7mTrz+hd/D6Lr5tEsanrMrLNZ4fl9fg/n/VlZpmQprxXq5/MPoebz/bjy+/LN2516xDfKGuZDCVkpWtkC2RMJZlubYW5u/F2ZX5t0XS3ZuvKc1rjZhbGrLQlhKro05TldNLbOlrPpu/D2Pp4dbtizQpwAAAASorjNmc3nnCvb3roascWvFrzVNSJEqY7WCuJTQpDVyuAUohBBKZjELNIIUvzz4H6+idLrzuc4rndbLiDUbLEptx7wbyZvA9HHN0yoqjDqWZlO5fM/S/b8z2vXyu5dKwHXO4dvA/H+3km6iOo7Aplw7zyOkx6yWXRqk2IZmg1SbZiVYrq650zNlySzQhFqTsaNNGJbcEjsLLEKaQqdyrLbLUdG83dcbO2dvfG70Y0dJPQAAAAAjFOZlw5vLceet/TNu5Op1bqzsAljnUcWOaSqGshgNSbY1csoYDtIEcAQQQpVkZKX5p+f/aSkzul+uV7EZrPdGsKynS6Tm9+WfUvxc1z531cq4jJj0x6zsuftXo+X6Xr5xkp2FBzsb+JeX6fn9X0vnvpvJ20VCWAlSyZ0M2XOtzlZYyy/XOdgjsdlcprOy85FapJ6hDsaOipXKppAnvM9ZiYvP0nZZ0zd1xq7Y2986e+LtnYDogCgICJVlmw5fK8Llvzs65sb73Oet68tnTNu5PazUlTpiBWGaSxzpQSkIJRskJqKuJrImOGADGEhClWRHyf85+60sSTG3frnYjSuye8xyjqZumMm8VVdiec9XHmdZnshJ1dY+xdvmd7pwNZQ0KdQy+beT6Hmp143bNWs+pxj7H6PnshKpYkFElY7J2NJajR2PRo6lY7HTsEKEdA6B0wpo6AAo5alZPaVjAAAAGIhFWZky5fO8bl04vLry8bwNc3q5fXXtvLj2V4djpnbuWaKUESqzSdSqVSpwK1GoyLJSkqlU0pXLIayhjmgcNHYZABBl8Y/PfudJrzAqstuVc1WCPWZhc5d5wdcZtYpjh+jlT0nuJ5vpHfwbuvKLJTozk0npJK81CZdCaOuXYDp0CuWCFMNRghQIAEgNGKgYDoAYUwAAAAABEZK4rkz5c7F4+OnHx05OOmHHSianLAUsapOZu17xk6562cesxz9LOfbTdqzqQwmnU6nU6nTV0x0oJVBKpqU1KWQBLDO1mhJGhDAIcnxz8/+3z2WWbMrJIBvKua0VhrMmRKNzD1xh3ivWfY9PH7Hv4LdYVjsdSZdT1LrHYKWNksY6SAUkKLJDJAGoQUhCQFBYxo6dMdAIBRBTVIpUleUJYZtOblxrJjeDO8eOmLO6sWOFedW5s4aziWZMmOIVAjEaqrLpz+s53fJ159Fz69593OeqbNL7ZklCSyqY9JjqSiO0mjOpyyGJa86hnZKxyOAEcjPj3wP2cii5Wpoi3Cqo6zHWa0jZHWbESVbxg6Z5nXEevOjrzq6Y0SWSWpswXTFmueu5ZpyquVTubmboqq/FrGkoWs6ctuL0eV1Y0yKwlhLGWIxySqSTuXZO5dOyyxpFaqrWaXyMURzac7rzqEqlUorVEYjUUiNJXIjQAjAIZEiJWiWFZ7cPSc7pMfWHTG28+xM9hnpJsq22ZMlUlla1Y6krlnbIlVUuWazzcpqc1bJblYzO5Aj4f8D9ldcyKNZsLEvxVZVvELIWV6y0TNVldmXeef0zj1nFvOfebM2tNObn3M284d5ivTyimfUsqW8dPCnpjpYdDCWs3yGs6Jl2acOlxvQ5a289WZ0xKpCnUkaSuZpIaAVNLEIy2xXQljLpjZKjLFa5YqgHRCWJUtdtdhc2I5HIpYKSBIaBG1ERiWtcupi3MnSQ0t1nbc9Fjo3PQTVpdbasqY1ayidtlFZLOT2zj3rRz1djVuOm3nrXhdcA0+H/B/YmsXxRrMbLEtNGbVZn6ZpuI2K5dzGSFBl1nl9cZ95psgAktgsrsy7irblCyGkN46EmiRanQxJ2Gsw1mdlyW5iS46XDXQ5Xbzt+bOV2pBBCpJJJJMAGWJUsVvRJGrInclRE0oRWZ7aNKtIjlcVaU6U6sS7LVhbiTklISiSGjpiEoRlgsEgsFrrPq59obzdrOq56FxuuNdmlbCbU1sq2lZh3POeuc3topV0fPr0ni11Oe9BDOoy/Fvi/qVqXMxspsNSyL4uzaNZy9MU3MdQZViBEmbUw9MZtyqxIrIl0XSQqnUti0VkbJ7xvxCrLNEjsq1mO82JfmCWF+Fktqbec287dNMmEOpSNZJKGBJJCAmQIVZCqGjScQKra2qbqqyrWVZOJyQqvSApqzO9GNShxOZmjkEVjJssJQSxgWMsZWK2NtdUW0VTtHebNZ13O2TTGtdll9j1MunP6s+7XpVpdm9rza63O6Fhjcc6+L/ADP0bSxm2lZVYWWxqwZn3jLvNGs16kUjcxSQFVzm3nPvMKo1I2ElpfKAk6dzOJ2aJmwkmmJIWQ1k1LJmZJmyLMo6Q1myy/MsWyLc26XRlfi2QSitJIyQIxLBams22fcdlkldUXUpbs2/mvktuJIEQqJLOksZYrFYrFGCCFzEJBZJIlk5RUIKRG2C13UVrWuq6r0srXqbpNLOixVVqwqvSGl0dPDflPOozpKPj3zvvu5EtLSFlOslzbJdmxqjWcu85tZrsgkdZlFhMimXWcvTNWpVcodWy6oskdkbm2NCXS2zMklVkkqLGwwsLJJORlW5DUViuSpJsxelx1v560YTUURgkgUAqlqu6bat4hqIiKpS2Z1fhpmbmbEkAglQitYrGWtqAhKhWREiBCJRKJwxwUhVG1LFY3UQahWa2nUW66vjXJqLksgqdapNEDUZqcfKvD9s1JSOpoyOpTcFkkslglGpm1mnUruVY0lFhNIGfWc+5DWatBLZdEmrKyQ1mZbF0k0lTQsKlZZMiKlYrmVjiNR1IoWKwsmm7nehy1r52+W2LYmFJYgERIqlVJAFEFIFmkkmkiQyIClQgK1qaioMCBBYggRhKDknAAACjStSgim3Jq4tbzasKtL6vjRJoNEl8WkppyyPnvk+qWKxhZIbMUr3I3AW5qKNZqRakbCySCTSUKyqzPuQ1I2Mti2TRmWrNJWWSTQHUkaLUkSZEjRYItZKEVFgiRWFl0a+etmLq5tMt0WSyUACIhANQEFaIiqGMmkhqkSoJWkVpapWIyaAiCskBWtcsFSoIYxkoAVxICLVLWXWsrVGlRHWkOp2Tq1bCyWSzl854verGisdCSZkQshrMLBmyVFdkbJXKRUWCWSWSxshqV2LUrsWszksytlsJJNGhUkkkxjskSmWLUVhcpForGgJCyNFkiyL8NeLrxbs2yWyWZMYhKkSqUFaIwQAY0agoAoVSwWpqhYWiTZCKqJE0kERmq2o0liAQDGSyksgEqmo2wmorStGrRbTVNtdRpKWqnNQ8XrjYIUMuhJoWQsjcxsKcCQuXYBYI7JxbEgSNkbKqp3hajJxOJxNHYI7JkxpMsymkkaKjULksRFFTCxWBGwACyNGbfi6cNGVs1MmTGEJYqCVLELGCACUlBCVEVrILC2IKkSg0ckhhKwlSoQgBWShjlBqlFQmorCWu2DVC0W5qpqm2pYrb5vQVFI1NkQqSTRkbmOkUjcq5QagjRxMcSsnEhIrI2V6zXVe4rGlmVsTiVhYydSSROSySckh00LCwSNKkjEIVhZGoUkVSW7LTlo53Rm3ZtsWkiROBUsViqIipIlSxgGJQiBEiRWDULYCQGjWUMcrhgMQWkOGCitQcoITUFiRtjLBqK1rTLRbmWC5OfV0IqEmMVzNLIdkUjqRuY2QsVCNmSzSeRSqcjkYWQsrshqV6kNSySyWySwkkg1HUokzOSRMkjiQ0VisQUqSRELQFZCyFIKcWRozdWGnK/K3NsW2JxJWKVNRqBBYiEsSIlQANAUsLUsSJEhSSSyiSygGISgDiU1IasAVEVTUSCwWKxE1AUta1zUTzzpdKDRhZJlgkyaMhqK4RGo3KsaSWSTyBVFJFkhRcxsrshUNZWpOLJJlsSHYU7mUkiQJIkSJQ4ELBFSqNkRC0jUbIrG5VIZIsi7LTloxb8r827NsJjlYxSxaiQI2xljUVgIBAABCpCIqhArJBKhVFSGOWUslkSAaoTUVgQWDUCIrqERthLBQ8s3fFs0MunI6aSGkySKyFyhWRuSwJLKSQSKxK0kWIIrI1CyNDJqFkkkWLNJDuRGNJAOpQyUTkcCKxaIVJEQ0jUaQrEKxwyRIsi7N0YaMW7K6W2WcSGpApKiNsIjUViJYiFSEAAIiCgwEsViRUlZKJK5WSGrBUJqJBa7YEVjdRiu2CqUs8jNXxfLYMkjAkkgR3LBIUkLlBqIlErHIABJJWThisEhRcqo6yqkkqsJySklYQ7GAwoJJKJJPJoqVKkFRIpCo6IKQhoxgiJl2V+bdm6ML82cNZyyWUNBSVEYjaiCxqJFY1EBKkSg0AItRIrFSUJEolElatQBKLEr1qJBYrG6iVlbSBPGW6c22LpbIklgBTSUy6YIkjYkKLIhY0kSydCMlZIlIUIIrFRYrFqOppJJxPMmhaIx2AUIE4lJOGAhUqBEajURWJREFhQCRtSSLJbsrs27C3NtiyJrOJKxw5VAqIELYld1AhbEgFEFOGIQhKKKxyuGSlaglQrqFsagsKhapYrCIWqo14ZdGWmLs2+LJZWTgR2McOxiSLJSCwFYkLHU4aSJEkY0YkLALHYD1DWZSSJRORjHQCAWNHTlkjykOABUKkKRGo1GwAQqVIVkRWtJxZm35XZtslmVss4sJStZRKEpEViV21NwqJEhURAMBCGBJWoSlcCqC1EWlUVhqxWFtZC2KxiFsbI6nhZq/N0Rqyui6JxMkMlI6ARoIkVKo2RsaRpsyJSSWZIkjBJIEqcy6LCx2KxjRk4kMaFgCFMcOnkyQ4YKAoEiqNipWKo0ESNkSNoKxCslE5bsrYuizNtyuymspZqQ4JUsZqFQK7qFIiRCkiVCACVSlkNXAJYqpYWwtjULpLArthUCJXbXqQrwy6M26TRm6crpb8pxJJklaNAEkNEKyKQ1FTRIWSGTiRIkjSRNJDSVNCxWJFSsZMkOSQ0AQGOnDABkpZQwQUkLRCkRsjoqjUSJEVRRUqiioJxZFst2Vubblbm2RYPNlNEqWIiJG2KxUEFIikLYhTJQyQ2kRIrFY2wWFVtQtiQqCw0gtVU1TueKzbpL5bo1YujK4uytykTBGSEkiaCpFcwqNisdgAxk4lZKSxJEkmTJI0BWRsVRpXLqY0llJZIkFaFBIIAGSiSuHBQNABVFI0tIUiNREiIWxuY1G1MlTlti7GrSzC3K7Ntic05VAsQAILQQEWlUREaiojUEqqKpURWFtawtrtrIbVrCoFNubSmz/xAAxEAACAQQCAQMDBAIDAAMBAQAAAQIDBBESBRMQFCAhBhUwIjFAUAcWIzJBFyQzYDT/2gAIAQEAAQUCr/MraP6qUfhFUl+8RC8Mf7iEL2MkTZVfzn5QvC/aiIZVfxcP9FZlVlQ/9pFqUCh4rFx/248siJH+z2wOtgnck7klWcj9xeEbJE7uMSvyequeXcipXnU8MfnHhmMtHC0u28Guy5rfCY0MY14n8ztUQ8VGN/MReZeYkfL8MmTKn7r90Lwv2oeJMrP4uJfoqsqEz/2mWpQKPit+1z/248sWRI/2Up4J1idclVyfuLzukTukivyCiVuTciVSUzJnw/cxIij6epfpONh28lV+ZsYxjMCrZqW1QjWJVypcHf8AMa53CrHcOsdoqhGqRqHYKZubDkTkTkTf6osQvH/lDxMrMuX+ibyVCZ/7SLUtyj4rFz+/Hlj+0SP9jJ4KtQqVT9/OR1UipdpFbkUityMpjm5GTI2ZM+5n/v7iRxFLqszgofoGMaGPxRuP1ULk9WTvSV4K6I3Qro9WO7PVkbr59UQuhXZ6oV0eq+fUkrknckrglcfMa4q5Gud53/FGt8dxOuVqyLut+idUnMlMz80i1ZQaKMj/AMrP4uv349liyBH+xqMrT8uSRK4SKt7gr8mkVeQlM3bEzJkz4z78iIlKLnKnHrp1m40rCn02AxjGPxSpsppowySZ1sVNipM62aMdNnXIUJGsiKkJyHKQqkhVpHcyVeRKuyVdnc8xrsVcjcHqfn1JSusL1hO7K12XNzmMqxKqSqG5Sngt6xRuPijcHqPivc/FxcfPGVclg8qBEX9eysytL5dRIncpFW9wXHJYKt9KobZExMX4MmTJkyIicVR7btlVbzximMYxj8RsBWR6I9Az7cLjj7efbj7cfbj7afbT7afbT7cPjj7cfbh8ePjh8aS4w+1sXFs+2M+2s+3s9AyNlIdpIlZyZUs55q2Ux2MyVjMlYzPRTI200U6U0Q3RSnJDrSxVrSKtR54mTzxr+IMgL+vkVSuXVx1uvyWCpdzqGTPhMQvwZMjZnxAgvjgKP/IWcO7kJ/MvDGMfhcehWCPQI9AegPQHoT0B6A9AegPQHoT0J6E9EeiPRHoT0J6A+3n24+3o+3I+3n25H24+3I+3D41D4tD4nI+IHw6JcKPhUfZT7Ofaj7bgfHlTjipxbb43jdSzoaKBEX9fNlQrnN1cTyZMiEIQvxPwiKKcThaWtqcJDap5Yxj8K1FanpUemR6ZHp0enR0I6EdCOlHSjpR0o6EdCPTo9Mj0yPSo9Kj0qPSnpD0iPSHoz0Z6Q9IekPSDtD0h6M9GOyPRHoj0Q7IdiOwHxxQtFAhEiiP9hUZUZcy+OVq9l5kyIQhC/ExsRAgR+XaQ67apLSnxVLpsPLGMfhIx4wYMecGDBgwYMGDBgwampqampoaGhoampqampqampqamhojQcDQ0FAURIX9hUZVZfT1jWnvWbMiERELzkz7mzJEh+0EWVLtuEXP6owjpQ9jGPwv5WfZgwYMGDBgwYMGpqampqamP7BlRlZnMV+uhkbMiERF+LI/ESmimsHCU97kpQ7r6f7+x+H4X8bJsOY6huKQn/Ax/ZSJsryPqOvinnyhERe3Jkz5fhEUU0RRwdLFI4iPZdv8Af3Pwv4WTJsbDmSq4J3SRO/SKd8mU6uxGX/8ABVGVGXEvjnq21y/CERIi9mTJn2PwiJAicdDrtG8Lhaelp7mP+Dk2NjYciVVIndJFS/SK3JpFzzCir36j63xv1F3y4683UJEX/wDwNRlZl3PEb+r2Xb8oiRF4z4z4z5yPxEgiBbx3qwWsbj/8beHXa/hX5XI3NzcdRIncqJVvkivyaRcc0olz9QIrc3OoVbmpVKqLS49PW4G/3ja1d4RYn/fsqMrM5StpRlPaTkZMkSIjPjI35z7URIEDh4b3aNey6n8fhx+VslIlUwSukipfFXkUV+WSLvndS45qpUKlzUqGTJkqftL4f09yGs+Lut4xkRf9/IqMrs+oq+lt4QiJERkyZMmTJn2oiRF+3A0/hHGR7eRk8y978LwvwskyTL6661X5hIuOdRU5ecydzOZNjH7JMqos7j09bgOR3jbVd4xYn/fTKjLmXx9SV8zz4REiLzkyZM+1iEREROJp9dmcDH/j/IvwsmVf25bZxvN1UbNjIzA15YyZL4Pp3kdJcVd7xi/iL/vqjKrLuX6eZq9t94iRF7MmfGTPnPhCICKSzKhDrp13ijY0+mx/GvxzROJf2+Tl7InHBgSMDQ0NeGMaJxLat0Vfp7kd42tXeEWJ/wB7VZWZyNTWFefZWz4REj4z4yZMmfahCIkTi6XZdorfqm1pT/GvxyJl3DK5O32V5Q0m4+MDJD8MYySJLD+nr7qqcTebwjIi/wC8ZUZWkc9X67ZsyIiIRn4yZMmTPnI38CIkREDgKeaiLOPdyVV5qfjX42TK0cxvaWTkbYnTHHHhj/dowSGMZURbVeitwHIZjaVt4KQn/dzZUZcSPqa4/Q/CIkRfgXlfuREJEPg4OlpanBR3q5y/xr8kiSLuBfUMlzQ1dSA14aGvDGMZIZwHIdc+Ivd4qRF/3dRlVl3PC+obje838IiIyZMmTJkyLz/5ERERFkFksqfXb1ZddLiafTx/8hjLmGS5pF7blSGCcfDMEkSRIkMkSKFbpq/T/I7K0r9kIsi/7llVlZnI1MR5K47L1VCMyEyLEZ8ZMmfK8ZEIRERFLFjT7LqJc/qgo9Vr/HYxlSOVcUi7o5Lylq6iH5ZJEkSJDJImcDfdVTh73aMZZUX/AHMiqyszm6ulCtTcpOLRGRCZCoRkZ9qEIz4QvERfvA4GnvdIhDuv67/X/IYxor0y4pF/QK0cOXskiSJEhjJoo1HSqfT3IZVlX7IRYn/cSKjKzPqCf/FOgVKBOkfMSFQhUI1Dcz7V5iRF4iQZ9O0sUDhY9t/OW0v5LGTjlXFMvaOVfUsOXskSJkh+JIf78Je9U+Hvto057KLE/wC3qMqsrv45j9dWdIqUidIlRJU8HyiNQjUFUNzYz4z4REQvERfJxdLpsnJQjwUHSs/5TGNFenkuqZyFEqrWXjJImSGS8MmilUdOfA8jlWFxvCMiL/t6jKrLn/reLetOmTpE6RKkTpEqJKkf9TtwKsRqEZil5iLwmfuRLWG9enHSN28UKEOnj/5TGMnHKuqZfUS/o6tsyZGybJDJeZjOGvOqrw19sqdTZQl/bMqMqMvJYjNZcoEqZKmSpEqRKkTpE6ZKiNOIqmCFYhUFPJkgLyiBwFLsvkTXZd3H6f5jGNFxTyXlI5CgV46TyZGxsmxsY2ZGyZSnpPgeQOPud4wZF/2sioybOQn+hjGhwJQJUyVInSJ0SVEqUSdE1cX2YIViNXJSqfpjLxkgyP7fS9L4RxdPu5as9qv8tjGTWVdUi+ofHI0MGTI2SZJjGzIxjGcTd9NXhb3KpVNowkZ/tJsqMqM5CeWx+XEcB0yVIlSJUSdEnQJUCpSP+pCZSq/EKhGeTJAicFR6uPR9OL/j/msZIuKeVd0TkrcuIddTI2SZJjY/LGMhPSXA8gcddbxhIjL+0qMqMrS+LuW1Uft1HAlTJUiVInSJUipRKlElTwRnq6dchWFUKVT4o/rqW1PqoXM9Lfjqfp+M/msYyayXVIv6ByduMbGxjGP2Pxxd101eGvsqhV3jCQn/AGdVlVlzLCm8y9+BxHAlTJUyVInSKlIrxJkWRqOKhX+aVU+n4epv0XH65VF10f5zGSRXhlXtE5O3Lun1zbGxsbGP2sjLV8HfnGXW0YSIS/smVWVGXs8R85/BJGuSUCpTK0S5GiFM6/hxwU6ji/oqjmaLSn6jlbmWa385jGSRd0jkKBydsS+GZGMfl+zjbnpq8LfZVvV3jTkRf9jMqMqMv5+X4XuwTI/tIqFwysdOSFuOh8VKJSo71Ppi26LFH07De6lPaWxsbGxsbGxsbmxubm/8JjGSK8Mq+onJW+VfUdJ5G/D97IvD4S+OLuto05EH/Y1GVGVX8Xk81M/jmRfxOZVqFepkl8lOnkhRJ0iVIpUsS42l02VWp1UeGh6XiNzsNzsOw7DsOw7DsOw3Ow7P4bGMki7pZV/b/HJ2pUWkn4Yx+1+OPuOqrwt78WtbeFOQn/YVWVWXEvitLaf45MnPBVqlasTqZdNbOhSI0xwHSLC27rtLBev/AI7lq34/sOw7DsOw7DsOw7DsOw7DsOz+IxkkVoZV9QOStsnIUNZeH+GLw+Fvji7vZUpkJC/rmVWVWXlTEW8/iySmVKpVrFWuVqxDMnb0ijAURxNT6ft9r417+S5uulddp2nadp2nYdh2HYdh2HYdh2fxGMkSRd0srkLc5O2KsOuY/LH7rGv01eGvi0rbwpyIv+ukVGVZHI1fjc2NjJkz7MkplSqVKxWrlauLNR29EoQILy0fTtLWmcHDu5W+uO673Nzc3NzY2NjY2NjJkz/FYySKscq/oHJW+TkbfXy/wo4a9OKvNlSmU5f102VWV5/HIVc1Nzc2NhSEzPhyJVCpVKtcq1irWEnN0KJRpFKJBGPPF0uqylLSPBt23FZNjYyZMmTP8xjGTRd0tlyFA5K3K8Ouf47Ov01eHvixr7xpSIv+tqMrMu54jdVdqu5ubCkJiZkcydUqVirXKtcnVyRg5FGgUqWCnAhEXmnDepTj107+etpff/U4DJkyZM+EL8WTJn25M+/Jkz4YySKscrkKByVucjQGP8fEXmr4i8yqFTKhIyZMmTJkyZM+MmTP89lVldnI1dYynmWxsJiEZHMnVKlYq1yrWJz2KdLJSolOkQgQiRM+eJp9t6XP/Jc/UtTWZn2IQhe/Jk2NjY2NjY2Njc3Nzc2MmRsyZM+GMmi7pbLkLY5G3Lmn1z/Hb1Oqpw18WF1tGnMjM2NjY2NjY2NjY2NjYyZMmTJn8WTJkyZ92TJnzIqsryOWq/p8IQjI5kqpUrlSsVaxlzdOjkpUSnSIQIxIr28DT/UcbT9RzfNVu7kvYhCF+FmTY7DtO07DsOw7TtOw7Dc2MjY5imbGRjGV/wBuQRyMC+pDWPycVd6vjL4tbpSUKp2G5kybnYdhubm5ubm5ubGxsbGTJkyZNjY3Nzc3Ow7RVBTM+yUjcUhPxNlVlzP45WpmQkJeMjmTqk6pUqk6mTVyKVEp0SFMjAjES93DQ0sz6c/TTnJyefahC/DgaJImx1MCrHcdo62DvO47iNTJGQmbkpE6mDvRGtkjM2MkmXVTVXtbJevYvIFSn8uBqYMe3BgwUZunPjb3BZ8h8Ub9FO5UiNQVQ7B1B1RVTtO07zvPUCriqimbGxkyZ92DBgkydXB6r5hXyQqEZCfhsqSHMhMhLxUZWZdz/Tey3rJCXhyJTJ1CdUqVRyyRhkp0SnSIQIwIxEvGTPmPy7aHXQvajo2v/wDg+ms+1CEL8GpqaE4E6RVpj+GvE2ZHI7ShPYjI7CVdI793WuY0yd7CTd+6E6PJJpXsWeqRK5Rc1lNXlNsuYSLiLKkFmURwHA6zrZ1M0NDU0NDUtazg7W9kW93NlrcFO5PUnqiV2iV4K9PWfDvB3o78V+UrrYpTyQ+RRFE1NTU1NTBgwYMEkVEVycv1UZlJkCPhlUkymymf+VSvIv54U/1TSMkpEqhOqVKxOsbORTgU6ZCBCBGIkLznzksKfbdHIfrPqKp1WufahCEL36mpqOBOmVqZUp/OMDJjJzKdPc11Tr6k7zBcchgpX+tG55H5lyWZVLre1hzbiUOcyU+UcynXlMislaksXdJF3D5uIfODrydArY9KemPSHoz0SPQo9Cj0KI2SRRo4KSwUaupC5PU/FS7wVL0lekbtsV28O5kSuZnfNlKc3KzhIt4MpxFE1MGPwYJIqQK1IqW5Ro4KVMhAUTA0VkSXzRRTR/5WK7OSn+kciVQnVJ1SdUnUyKOxTolOiQpkIEYiXnJn28FT2uClD1PN/UVft5P2pCQhL8GPZNFZFRfMhywSkSbYqbzD9JUrfFW4K96oq+5VJ0eS2tq1faTrvadw1b06NWoWlrODsoZVKDQngqT+Lr5LqmXFMlT+Y0hUhUzrOs6zqOhnpmelkKykKwkR45kbCR6GZG2mhQaKlLI7WbPt0pFPjJEeMkfa2PiWxcOylxLi7ay1KVDBCkKBoampgY2bGxsbGxkaJUx2+SNvgjSFAwYGiqicSkiA/wBq7KzOSyyUWicsFSqVKxKsOWSFPJTokaZGBGBFCX4uCp6259OR7b64ffcaGhoaCiJCiJCRgwYMGDBr7ZFRFWJOBOI4iiYwTeCrNlxJl/XZOjORx8HB/apspcLKRcWMZKjxaRCyUSNLUpVGhSTJsuC5KsMnpm3C1ZGzZGyFYisCPHkePFx4rAjYEOPIceiPHxFx0T7ej7ch8ZE+1xFxaFxqQuPQrEVmeiR6NCtERoYI0xRMGDBgaJIY/ORPzqaGhj2SKiJwKcSJN/FeZP5K9Ddzsitx5cccytx8x2dREKDRTpkKYoiXiD/Bnwiwh12lar00eHpu0+n6PHOoR4eA+ERDjIQPttOR9npj4yCPtUBcdFD46LI8aj0ERccsvj1EfHo+2/p9DHzkySZNklkdMlQyO1R6bA7cnalSzLmzch8Lk+xxPsyKVnUokrapVUbHU9KdA6I4k6qgVbwdec3C0dQXEbH2UXD4FxQuNPt5HjxWArJCtD0grYjbkaJGkKkKkdJ0nUjpR0nSdR1HWdZoaiiY9mBxHEcBwNDQ185MmTJsbGxkZIcRR8VZfFxPw4jgiVBMqWakT4xMnxKZLiB8Vg9A4nppI6mjQ/Zp/HhsXsyW0O2ulqcpN+n1VOwt1AhbRkekPS7RVpKJGgypaOa9NUzG1yvQyRG1kiVjtL0ZK17IxslFRt9T0NMyORsbkpkpGfOpqaDgOlsO2R6WJ6aI6CHQR0odIlBFRqJUrE9pjt8npkUbVZo20SFJI6jqOo6jqOo6zQ0NDQURRIxFHw2OaOxCmjZGyNjYyZM/hwOI4mpqajQ/GTY2NzcUhSMjGjAyvIqvMvdg0Q6MWO2iOzRKxRPjsk+MY7OcB05L8HC097s6/U8jP9qdRqpa3ClFSFMzn8uw34Y/C8Y9uBoaGMY2TmVJNko5HEkMc0U6yTpXCIVsnadh2I7Edhubm5sZMmRMUhTHUKlYq3eBXpC8I3R6g9QeoO87juFVNzc3NzsOw7DsNzYyZM+H7H4yZNjc3NjYkypT2Ktu0848ZM+M+Mm+DYz5wh0oslawkT42DJ8SVOMqIlaVIji154CniBx9PatcyEhS6ijdkaqkKQpmfx7GxsZGYNTHjJn2MkyTHIkxyJTJTJzKlbBUuipe4KnII+44dLlsEOZSFzORcrkXJEL5shctkarFUFIT8oXjJOZXqFZzm1CoU9kRmztZ2sVVirCrCqiqirHcOqdp2nad53irHadh2nado6p3I7R1iVdDuUepR6kd2O9FekbtCrkamRftXSxdvWSrHajYyZMjJibIP8LimStqcyrxVGoVOCLC39Lb5wuMhinP9UqFFzlP/wDRoVdwKV3kjVTFMUxP8WTYyZ9r8ZMmxsNjySySUiUZEoSJU5E6MypazZOwmyfFVJEuFqMXBTIcDIhwTIcIR4fBHi0iPH4IWuBURUxRMGPZkciTJwydJ0nSdR1HWaM1ZhmBZHUwSusHrRXR6kldjvGK8I3Z6wlfHrxX560lejv2fcD12SV2yVzI9VIV2z1J2tnbJCumiF6Ur8p8hAq3kZKr/wAjnRJRaOxoVwK4FUTNvGojPjPsyZ91X5jb2XXT9EW8FRXpk5SoLWVuToSTjVnTKd4QuExTFUFM2M+31A7g9QKsdp3Hadp2HYbm4nk1NDUcRwHTQ6Q6R0joI9Oj0yPTI9Mj06OlHUjrR1o0RoNGTY3Ow7UdyO5Hed53DqGxn8WCcSvElLQjXybjmSmx1GKs0KrsSTY8mzFVwbqQ4jQ5NCqikmaZHAawb4FUR8SHA+YnqJRPXtEORTI3kJG0JDoKRO0JUJIzKArpxIXiZGumKRkyZMmfGfOfZvtyFrWczUcBwGn4xFjoxZK0FQlAUpRI1iNYVUUzc2MmSJg185Njc3OwdUVb5pzFM2HI2NjY2NjZGyNkbo3R2IdVDrI70d6PUHqD1BVusFTkGn65s9XI9TI7pCqMUmLIkzDNWaM62dbOs6zrOo6zrOscSS8SpJla1KlBxISNTQdElRHFxI1cCamOkSpNHyiNbUjUUhwTJ0hpxFVaI3B+mZKhknQaHmIqzRGspElFlSkTpuIpTi/uE4Ojymqo8tGbVxSmnQp1Cpx5UsZIdOpTFdTgU+QRG5jIU8mTJkz4z7eL/wDsfUFpD9a84NB0hwaMtHYfpZ1pnW0fKNmKqKuKsdwvOxt5wYHEcDQhLUVU7h1jtOxnYzdnYzdm7N2OTNmZY2z59reCvIkstISKcExUkRpoVNCpoVMVNGiNUYMezJk2NjI0Y8YyVLdSK1rgUnTcJKZqjVEqUWVqCHJ03TucmUyUFIlbkoSiKvKBCupCipE7ZMlbtH64ON5qRuYTNYzJ28ZDtZRX/JGU7vrO1TKjQ4FZ5VHMF9wmp0OUnShb8/8ANPkaVRa0qxU46MirxJPj61M7q9B0+UKd7CYppmTJkQhePpOG6tFiKfuwOCY6I6R8o3aOw+GY8ZZsNkX4Yv4uDBqamhoajiSRcfBusxGKvq4XJTr5IVCMjY2NjYyZ/ExoT8OKZWs1IuKFWgU+Ree/ZOrI3kzRSJ22TsnQdO7jUMjwydCMipQkhVqtuU+QjUFLYaiydsmVLacSFSvSKXIxlLtUhqBXt6UivQ/RTtq6dW6Sn+8ZS+MR2r1VNWjzOrff8nH3rI/Uk6crTn6Vcp17euTs1IrcTSqE+CaJWl5bujVvEUVVqDoyiJYF45Or0WHBUPT8VSk4KNQT/DqmOkOkdZqz59kV4YvZkyZ9mfwsyKRt4x7MEol3TzGo5xrUP+uCpD5pxKaIkWZMmTJkybGxsbGxsbGxkyZMeydNSLziIVyrC54+VveU68dVis3FxsrhxpWGat3xNGUf/tWjoXMbhdcjVkqO5OwpSj0XtvKnyMHU7N1KSy+uRO2tpqdC42tJ3cydObJUHFVpMqU6Mp3E06Ktrnot9K9apbKQuPrM+z3EyPFX0KNX6au6UafCXkreH0/dp8Xb3dnLvJSyLwoyFTmyUC8nStqNDlres+SqRvL2lYPq2nSdOtGXhSaFUM5/Dg0Q6Z1nWIT8syZM/jyZMmfOPKEajXnJUhsqtoiMcCQ4ZI08EVgTNjY2NjYz/CaKtGNSPI/TMSny1W1qqpKsrtXVe3ha3NAdvyM1T4/knX5L6VhWJXl9w1S35a0uYS5K3R9wyK7rlWynf17rgeQ4+cLDlHSXE8mz7Hfs/wBfuWf6y2f6vQF9M2iF9O2RH6ftRcDTKfFVKcFwtbMOCuJFH6eSJcPS63wP6qvE29tSpfbahGyt5P7dSRTo0vUekpROW52HEXD+srg4/nry9rXFKpMlx9xJ8hwd3c23+hX7f099GXHFXVpFxpSpqRK0izplEWfH7CkKRn8ehr41HAlDBnBkbFIZkyZMmfGDBg1NTBgwamPCkKZ8MwOmOmP4K80vCQkJCX8txL3jaV/Rt/pCvbXL+nNnU+kqFaFraq1t8Ci2TspV40uEjSUeLqI+21j7XVFxMj7Sfaoi4ukfbaCPQ26PSUUK3pI64I+qP8jUfpnk/wD5inUfH/WPOcq7N8jVVOjIUceZ0diNosKkkYQ0NI1izWJrE1gfp9sf38YNTQ0ND9jY2/Bnwn8C8ZJSwSaZnB+5j5H4wYGh/BuzsNkbG5ubG45CZnzjxlnd8uaZyH/50/1RSEL+GotnRUYrSuxcfcMXF3B9prEeHq5XDsXERFxNI+2UELjrcVjQQreijrghJIyZ/Lgv/pOx5S+tPprj7MhQhD3ueDtNjL849+DH4HEcDU/Y2NjYz5yZIyMiZsb/AC5JlX9J2aELhMTTPj2bDmNmxsS+B3GhGopj8fJgUjKfnLNjI0mSpouKGadlnqx4yZMieSNKciNnXZHjrli4m6YuFuWLgqwuBmLgRcHTFw1BC4q2QuNtkKyt0K3pISS9m6RK5pRHydpE+82CHz3HxH9ScYh/VHFof1bxSH9Z8Uh/W/GIl9e8ciX+QrEl/kS1H/kWiP8AyNAf+SCX+SKg/wDJFyP/ACReE/8AIl+yX+QuQJf5C5Al/kC/H9e3pQ+r+TvZcfZ89fFjw1WkqduoGMfhkiWYtVDb8mDB8oz79TQ0NTBj2aD+BedYjhkqUlMVCNPw4tjdSBGsbG2DOR48TlFkosqUnJQbpON5Kmo3tOYnstjJk2wdh2m6fj5RuVZovLydjcw+qLeBR+qeMZb/AFPwZH6r4BH+5cJE/wB54tD+veOQ/wDINiP/ACDaj/yDSH/kIn/kOSJf5Grn/wAiXJL/ACJdD/yFdkv8g3ZP6/umS+va5L67qkvriRP63JfWSZL6spsf1TRH9UUh/VER/U4/qeY/qWqf7JWH9RXB/sFyPnbpj5m6Y+Vumfcbolf3Uz1FzI3umaXbFa3kz0F4z7TesXB30lT+nL+s+P8A8a8rePiP8UWluWPAWnHwjCMfx5wbolHY6zGDIpGTP48GPxYNTU1NTu1NlIcEzTU3G8pywb5Gng1M4HiopOVMVz8OvgdyOpMVbtKlVpwl/wANWadOmuw4zg01HibSDdhQPt9uehoHoqAraijlatO1pVKpC5IXWx3ol+3L0JVS5g4uqsk4jVQ3rI77g77g77k7rk3uWf8A2mdF0xWV1I+2XguLu5H2e8Z9jvmfY71n2G+Qvp2+Yvpq/Z/q99mH0hcyS+ia4vomoL6JyL6HWI/RECP0NSxH6JoIh9HWqcPo+zP9SthfSlqL6Vtkf6zDH2DUjw0RcJJC4OZ9imfY6pDg6hR+lJydr9I0Yu34mhbKNOMfx7DqE6+CpdM76zlQqNowOJjxkyZM/wAhs/ZwqGUyR/1FPI2Ko03+okSIS/VNZJyw+zDjW/VUnoOsOuqsaV46Uq0J1zg+C9JD3Xl7C0hWdStUlhHVGRKm8qEsqGCdDcqcXGoVfp9TP9ZWV9LwkL6ZilS+no5/1e3ml9MJEeAnFQ4jBHiKZPg4TU+AiUuIij7JSmfY0j7JA+yxZ9mhn7JE+0Qx9ngfZqR9liiNhqehpnoooVnEVms+nR6dHSjoSNTriRjgxFnXE9MmKzqEOPyQ4+KI20Yigl+LZIdZIneRiS5SBG7dQjmR1HSjpQoJGPZgwY92TJkyZ/gZGk1/0IVVI2G0z/qb5NjfV/8AaLm03LLjPKuYtk/iUpoclUjJLGUqlGzq3tbh+Ijx9L3X17Cyo8hyEuQq/c7u0LL6io1yEqVYdCY4s0Zoz5EReRRWcI60KJgTNj9MjriaHUKmxRMDpn6kaZNWas0Z1yOuZ1zOmR0TPT1D09Q9JMdnPGridTZ6WTFZSZGwRG0ihUoo1S/Fsh1UiV0kVuThAuObwnzNaq51qlUsLIpUdBGxubmxsZ9uDBgx78mTJkz+SE0KR8MkknGeTJkf6X8MaTSZOHxKPxCeCbzGsZ+O3R5lOVpxFW+qcZxdOwpe6pLWPKWt1f1KnBXBV+nLtqH01cnFcbWtbuFGLj6aB6WmejpnoaZ9vpH2+kehpnoqZ6OmekpnpYHpoHp4Hp4HRA6YnVE64mkTSJpE0iaRNUaowjVGDHvdGLapJGq/Fk3Q66RO8jErcvSplXnolXnJyKnISmTvMFXkPiPI4lYVYVFQu6cFTuYyFJPxjxsKRsbGTJn3Y9uDHtyZ/C/0kaopZJLJ+wpjZsbnYfv4lDBMdVoqR3Vai0U7Sdd8bwBb2kaEfcjGTridUTpgdMDogft/DwY/i5NkdiO5DuYolexRV5WnAq8/TRV+oJsny1zUJVq9Q6pHRIdsz0rPRZJcdsVOHFb3FCPfeUpW/N16RbfURQ5unMp38JirJm6F5ybGxsbGTP8AByZMmfLQ4shNoU8n7n7GfH7GTfB2jnkwVIjlqbKT42MIqFeEF6tHqkepieoR3o74iqo7EdiN0diN0diN0bo3R2I7EdqO5HcjtR2I3RujdHYjsR2o7UdyO47juO47TtR2I7Ebo3RudiOxHYjsR2HYjtO47z1KHdDvEiXIRRLk0T5VkuTrMlfXMjtuJDpzmejFZI9Gj0iFao9OjoR0o6B0DoOg9Oh2cWT4unInxCRPj5wIuvSKXKVqZS5wpcvCRC+jIjXTOxGV4yZNjY2NjJn8GRyNjYz7simmNjFMjUN8mTYbMmTJubknkqInmIr24pqXLX0T77fRI/UF4f7DdkPqO5z/ALFXR/s9WJH6twL6wpH+4UhfVVNkvq2mhfV1Nn+20iH1ZSkf7TTP9qgL6jUlL6hkR56ch81UFzVUjzNQhy7Ici2K9bPVs9VI9XI9VM9TM9RM75ndM7pHbI7pHdM75HqJHqZHqJHfI75DuJnqpkbqR3yO6R2yHJseRxNDrRpE0idcTridSOs0Nfbjzkz5x7HFMlQjInYxZOwHauInVplPkKkCjyxT5KMiF5Fka6Z2GxkyZMmTY2NjJkTMjkSmdh2HYKYpe3JGeRsbFI2E8+M4GxsyZMjY/klHJJaknkmhZT/c+RPKaeZ0skqQolNFSBpgcSH74ynDDoMwQ+Bs2FI3IXEokLwhc5FVyZ8Y9mfGTPjPnImNEkJCXjPtwY85MmTJkZk3Ow7Dcz7MmffgcEyVvFkrQdtg63EjUnAheyiQ5AhfJkbpMVcVU3NjYyZFI2ExyJzwV7pQUbxSFXQqyI1MkZEX7GR/cYngTFLBnJIbGzJsbGfDJxyVKZgcfGPH7iROjklSIrV67LrOs0wUl8aZFHVx+V5TwLxkhNohXaIXBGqKZn35M+M+MmRiMjkbCkZMmTJkyMch1DtO47jtHVHVHVO4jVYpmxn8GPOPGBxNB0UyVuOg0ayRGrKJG8aIXxG7Fcirncdgpm4pEpE5F5T3OrA9kd04lvctlKrki/GTYyZExj8x8SiSXhmfORkok6Y0a+xPJgcB0yCNTUdMpxw1EcSETU0yOBgj4wIQiNRohXI1cm5ubGxkz4yZMmfGfY/GTc7DsNx1B1DsGx/JodQqR1DpHSdB0CpCga+MifsyZMmfZgx5wajgOkh0h08H6kKtKJG7ZG7I3OSNcVUVQcyUifyYGjVMpwRSKbNhv2Lw0Y8RYmMkiS8fsZMmwpDGskqZqOJjzGRkwYM+MGpEwJCMGpoaiRgwL2Ko0Kudx3CqiqCYjH42SZsKZ2DqG7FITEKIqZ1nWdR1nUdZoamDBjzn2ZMmxsZ/BgwNDgdY6Y44IvBCrgjXFXFVyN5MDiamCCKZFmxsbeULw/GRSNhjJDMmTYyJ+ZoY/ka8xYjU1NRISNTBgx5wYMeV4fsybCqMhMhIjIT8YMGPcxjQ4mhodZoaCiJETJn34MGBrxgwYMe7Jkz+DBgwYHEaM4O3B3kbkjciuBVUzZPwiLNzc3N/OfGTI/GTfBvkYyXuRjJqSpjiYHESFEj8CMGpqJCRgwYMfmx4UsEKpCoKYpikZ8YMe3BqaGhoaHWdZoY/K/bgwYMGPZkybGxsZM+7BKJKJJDMimKqKsKuRuBXAq53ncdx2mfGTJkz7UzIxmDU1NfbqOA4GpoJYMCF4x+PHnHtwYMGBEZtEaxGqKoRmKQpGfx5M/hyZ9jGzYyZ85M+cDRj2ZMmTJn3uJKmSpDpGhjxsbnaKsKud56g9R70/Y0ZMjGvZjyjBqaGhqOJgQhezJkz78GDBgwYMeMGDBjypYI1CNYVQUxTNzYz+fJkyZMmTJsNjYzJkybGxkybGxkz7MflwOA6Y6Q6Q4DXnY3Nzs/Jj8aMGDBgcRx9mfGfYn5Xtx4wYMGDBgwYMezIqjQqwqoqgpmxsbGfZgwY92TJkz4yZ8v2ZMmfGTJkz/GwOI6ZKkOmOI0NGPwZ9uPx59uBx9if4c/xVIVQjWFVFUFMUhSNjPswY9uTJkyZMmfw5MmTJkz4yZ/jNDiSpjpnWZM+9fwM+5mPCEZ/nZFUFVI1CNQUxSFIUjJnzgwY/Fkz7cGDBjxn35Mmf4uBP8Of4GfY0Pw1+bJkyZ/hqQqhGqKoKoKZsZNjJn2YMGP4ODHuz7Mmf4D8pif83BgwYMezP48/jx7ce1MUxTFUFUFMUzYyZMmfZgwYMGDH8LP8hMTE/wCbgwY8Y9i9uDBj8ODHjBgwYMGPODHuUhTFMVQVQUzY2MmTPswYMGDBgwY9+f58WJiYn/RY/h4MGPGDBgwamDBgwY84MeMikKYpimKZsZNjJn24MGDBgf4MmTP8XJnznxFikRkJif8APx4wYMfxMecGDBgwYMeMe/JsKYpimKYpikbGTJn3sx7cfyc+cmTImJkWJmRPxn8GP4uDHjH8rBgwYMGDBj8GRSFMUxTFMUhSMmTJn3YMGPZgx/ByZMmfZkbGzIpCYpEZCkJifjP8NfiwY/n4MfkybG4pimKYpm4pGTPvwYMGDBgwYMGPyvxkyZMmTI2ORt4ixEWIQmIX43+df2aYmZMmRMTM/kx/Cfsz4ZkkzJ//xAAuEQACAgEDAgcAAgIDAQEBAAAAAQIREgMQEyAwBBQhMUBQUSJBBWAjYXAycYD/2gAIAQMBAT8Bn7kEeyGahISF0LsIQyT62Ikyb9CRIe0DSIEdmam3iCQ/s6Fp2R0SOiR00j2HsymyPh5SIeEivcUYw9i931IRqOovaTxg2afrchCFstn6sgPabGIWz2XYQhkuzJmoSGPaBpkCOzNTbxHsSH9ko2R0iOkLTrpxbI6NkdJI9F7DfbSEjxD9EtvGSx0TT9IiEIQizO2achzJTJzMhSMjIyMhSMjIsvaihI9hsb9ezM1BkhiIGmQI7M1Ntf2JD+xSNOBDTPboUGxaYopF/neW3iHcq2/yMvVQ2QhdENT1Iao9UlrEtQUxTMzMcxagtQ5BTOQzFqC1BTMx6g9QcxTMxTMzIUhscicjUl6DY2NiIGmQE9mam2t7Eh/YwRpR3oUBRKov83vtoQtpPKTYvc8TLPXeyEIQtoxEMkjAUDAwMRxZgxRZTKkep6ibFJimzNjmxzM2KZmKZyHILUHqD1CeoTkOQ2NiZBmnIjIjIyJTNSYmansSJfY6aNNFCQkexfwEIRqvGDYi8U2L1k3uhCFstIwMDjOI4jjOM4zjOMwMDAwMDAwMTAcB6ZxHGjjMDAwMRxHAlAlpsekzhZws4mKDI2RbIyZmSmSkQZL2JIl9giBERaRfRe19xCEeLlUK28TPDRZD26ltnE5YnNE50c6PMI8yjzKPMo80eaPNHmjzR5k8yeZPMI8wjzCOdHMjmicsTlicqOSJnEziZRLR6GKMUYGBxo4zAxK2aJQEqJskS+wiQF8RCEI8XL+SW3+QlUVAW62Qtnrj1znZzs5mczOVnKzlZyM5DkZyM5GcrOVnKzmZzM52c7OdnOzzDPMM8weYPMHmDzB5g8weYPMHmDnOc5kcyOVHIjkRmZDkSY2P7CCICH79V91ERba0stRsXqzxks9aulC+DZZZZZZZZZkZGRkZGRkWWWWWWWZGRkZGRkzIyGxv7GBAj8NCIiJvFXtH09STy1G/o66rLLLLLLLLLMjIyL+xRFERegn8C9kRIiPEyrT21ZYaTZD27L+NRQomBiOI1/oUSKEf12r7KIi28XL2jt4+WOkoi618WijEURQsjoNkfCk/CtE4USX+hRIiJd6+hCRES28Q71GI8fK9RQ+TRRRQoigyOg2Q8KR8Ol7lRiT11EhrKZ4jSTVokhr/AEGAhH99hvtRIkRDJO3ZH3NWWes38ejExMTBkdFsh4VkfDJe5UIEtaKH4i/YlNskQngyLzia8MWNDX0F92yy/gIiIiLrb676IkSIjWdQYxvGDkR9bfxkiMRQsjoNkPCkfCpC04RJaiiT13/Q5yfQxnhtS/Q8RDJWNDX2Vl9xCEf0L4KIkUIR4l+iQzxksdCiKpfGiRR4fRv1ZUYj1Uh6w5tjY+lk/U0p4SItSRrQxZJDX2l9qItpfCREiLbxD/nt/kZesYfHiQNL/wCPQ1L2vofQyQzw2pfoasc0SQ19tfYiIQ/fddD6nte8SJEQybt2L3PEyz1/jxZFnhtT+jVjY+mt3sySISwdmnLJGtCmNDX199i+qIhd99EUREI1HUWxnsmxfylKXx0RNCVMX8ok410vqkM8Nqf0Tjkhoa+Xfxr7iF33s9kQIoQjXf8AHbxEsNFs01/HuPtoiabpmhK0TQ0Pd7sYxkkQeMiErRrQp2NDXyL+hvdCH7d2+hCIkRCNd+tbf5CX8FAqvkITPDyPckhrtMZ4bU/pkv5IaJL7tCEN+vavZvpREiIW2q7k9vGPPXUfz5KEaEqNOVokhofQx7MYxkZYuzTlkicf7Ghrvv599d9KFs5eopFl72WJ7WWN9KIkRCG6GIvPVlL5KEQdM0pnuSQ+pjGMYzw+p/R7oaJL6q+7eyF0piZZfVY30IiREIRqP+IzUlhpuRor+N/JWyNKZpyskPrYx7Mi8XZpTtEkNDX1t9xbPosTMiy+yiJEQttV7eOljpURVRS+UhEXRozPdE11MYx7MZoT/o90NDX3C3fVZZYmWWXuxESIhC21PV7eOeWpGHzEI0pUabtEkPpYxj3ZF07NKdoY0Nd9/UIXYrfITL6URIi2W0vXabz8Q3+fNRF0zQme5NdL3e7GaE/6E7GP7K+lduitsjITL2REQhCGMk6Vmj/K5fvzUI0ZEHaJol6dLGPdjE6ZpTtDGP7Zd1oa2sTEyLIiEIRL2GeLnjpM0lUF81CIujQmM1I9DHs93vozp0Rd7P7Vd6ijEa2TIsixCFtN7ePd4w+gRoyoi7JokqfQx9a9DTne76r6H9UhfAaHEoiQZFiYntN7a7z8RX585CERZozGakd2x7Pr0Z0yMtn0vt39BfQhfBaGWQYpEZCYn6khsg89SU/noQjSlTE7RNGp6F9D6mJmlO1s+l/S32lsvgMltFCEyMyL9BmtLCDZoKofQIRFmlKxmpEfp22aUqZF7P6W+8tl8BjEhIraPuf1t42VadfpGNKjEoxMTExMTExMTExMfhoQjTlRdomjVjtfZYnTNKez+wvZC2XwqFvE/oZrf8mvCBiYGJgYGBgYGJgYGJgYfDQhEWacrQzUiNU+t7vbSn6kXu/sF8JssQuiHuMZ4dcviJy/DAwMDAwMDAwMDAwMDAw+IhETTlR7k0asf7630MXoac/QvZ/ZLu3tY2IihdENpOlZ/jIXDN/2YGBgcZxmBgYGBgYGBgYfEQhEWaciRqIkqfU92PbSlRF7P7Vdiyyyxs9yKF0x9tvEyrTZ4PTw0kuqjExKKKKKK+KhCZB7TVmrHu6crQn9LfeXZvosbPciuv8AoZqrPUhA0/b6RCIsgxk0SVPpfXpypkX9mu5Y2L1FEXUtmaCz8Vf4L26rLL7NFd6uhCEyD2kjVj3dOQtn0P6KxssvqXasssuxIS60RL2/x0cnLU/fgUUUUUUUYmJiYmJiYlFb0VuhEWJjJomqfcg6ZF7P51l9hssTF0rsXtZZ7iQl2Y7ajqDZ4GOOmvhUUYHGYGBgYGBgYGBiUUJCiOJRQhCEMkai7unIX0t7MkzIiyPSu2kJdv8ArbV/lUf00lUfg2WJkUKFj0jjMBaZxHGcY4DQ0YkYkYjgOA4mJQhDJEkNDRRRRW1FFFFEfQiy/lX3GajMjTZHpXbXYvdbNkFnrL/oXt8CyzIjIjMhMXqPaKKKMEakUiSMBaZikeuzY+ixjGNDHtW1bVtRWyYmX3rLL2ssv4MjUGzRI9pF7rux2Z4NZTcvg5FmQpEZmnMhP0LvaJZkclDlZihrb3KGyfsZmYmWXuyW1GJiYmJiYmBgYmJj3mxsyLL3ssssssssvtSNQbNFkO7QuhdmOzdep4KNRv4sWabIMiJFbMYixslMhK4jZZOX8WU2JMj0saKKK3reitn3mPtWWZGQpCfakahI0SHQutLsLsL22l7GisY72Wiy0WWWWWWWWWX0ogyEiMiLLH0yY0zSePozExJ+vojjMBRKKK2Yyiiiiiiit66rLG976WUV1V1IQuyzUQ4mmiHQumivgLZiVyijPFD1xeIHrnO0ebF4qzzQ/FM82x+MZ5qR5xnnG/YXjGed9aPNS3rZIiRdCmLVFrs5zlFqIuxmJiYlbUUUUUVvRiUUUUVtXU+iy9r7NFFFFFFFFFGJiUJCXTRRRQ0SiOBGBFC+StmR/wDqybZLUaOY56ZzxHqohrqJzQJa9P0PNRZLXTI+KpUeZFr4O0PxTbJa2R5rUKKMTEURRK3syMhSFqUcpyGRfTRRRRW77zH8WtqKKKEum+pocTHdfIjsvciY+hq6Y0OPexK2Qtn13st0UVvZZZZZZfeZRRRRiYlFFFFFdt9dlllmRkZFl9V/IXttH2NNf2SZ7k9K/YcGhoce5RRRQiyy9qK6UhISFvZZkZGRmZmZmZGRkWWXtZZZZZZe+JiYlGJRiYmJRiUV8ayyy+lfGroh6I1JEfYswUiegSg0OI4ldqiiiuzRQkLayyyxsb+MhMsyMjIssyLLLL6LL2vpoooooorrroor5WXqcxqPMWpSoU3ZHUFNMenGZPw34S0mhxHEcSuriFpHEPSOM4zjOMwMDExH6GRkWZGRZfTRRRRRRRRRXRfYsyMjIyMiyyyyyyyy+9fxr7S679RosyMi1t6oWo0LXHqxkNRY9MemPTMTEoooYmXvRRiYmAoIemTiOJQkUUUUUYmJiYsxMTEwMDAwMTAkiXoWXtZZe172XvZe9l/CfVXbr40X7sb9OnJi1BSTKTMT+SM2jM9GYj0x6RxM42Peit7LLFIyJKx6ZxHGYGCMEYoxRijFFFFFFdciXvvZZfRfwr7Fd++pFi2rsX2V/wDJPsKTFqGZ6MxRiepe1IoSJIXyL7Eia3f0VdVdLK7iGV3ZP02l6vt5CmLUMy10t7Lpr5TRND2fafwb3se99XqWiui2L09x7Xte3oMTLLPcsbLPTrsyoUhfynQ5UXffyZmZmYxrdFFFfGvq1V6fQWV+bKhsvZSKv2G6LL2Vip+/Xe3qep69PoWev4W/wtlstlt+xUhREWh+u0KiT99rL6a7mRe1ikKRVlCQ12bLLL3vpaPUsyL2mv4j+hfqtl6b0xJnqUyiiiiiuq2ymU+xfwrEy967T2Z77JCTW3t2b6q7PoVs/Y1PSXx6ZRRRRRRRXxq+fZZe1GJRXTJbNFGIk0R9dqPU9ezZlRkn3bLNdVLt2WWX3L3xf4Yy/DGX4YS/DCf4cc/w45nHI4pHFI4X+nC/04f+zh/7OFfpwr9OKJxQMIGGmY6Y3pr2Q6/PpLMjIsvpyF6j3tilQpdNl9N7+xbMy0+1RRqRs40PTHpy/o4tQ4dQ4NQ8vM8tM8tI8s/08t/2eW/7PLL9PLx/Tgh+nBp/pxaRx6Rjon/CXomWkZ6ZyQOWJzI5kcyOY5jmZys5WckjOZlMuZ/M/wCQrUMNQwmcczikcUjif6NJf2X+H/73b+dZZZkcd+xi0KTRlZij22qvVF9V9nV1/wCoHPP9PMan6eY1f08xqfpzan6cs/08Mp6jtv06pKxoaGhpjUhrUP8AkP8AkK1DHVMNU4tU4dQ4NQ8vqHl5nl5nlpnlpHlmeWf6eWOBHDE4YnFE4onFH8OOP4ca/DBfhiV2PQ9C0PUiS1vwcm+9e1bWX9DKBTEe5WzQtnu9r6ZP0s1NVy9F16enyMioxVLroxRgjBGCMUUj0P4lI9C0XFjoyRlEyRlEyRkjJGSMkZRLRkWZGRkZGRkWXt67WZHIZsvvKLZiV9LR6nuOJQl2X1SlSs1NVz64xzdEYqKotr2Fq/pmjNFosssvay2ZF9FsyMjMciyzJFoyLRkjJGSMomSM0ZxM4maM0ZrazNHIcjHJvv4ti02LSRikORfxV8Flbe/Q+h9D2vaclE1NRz7Gm4RRnEyRaZqSSj6GbORnLI5ZHNI5pnNI5pHNI5ZHLI5JHJIzZyMzZmzJmTMmWWyyyy+3fdsvZJsWmxaaFFLokevza7z7L3aLJ6qXsSm5dmyzIyZf1tl7U2LTkxaLFoowRRRRRRRQ4WcY4GBXzqKK673faZqNjKKKKK+HW1FFbUUUUUUUUVtXbplMxZgzikeXkeWYvCnlUeXiccUUV3qMRwOMwKfwF1Laiuqum9n2Xs4WPSRxHEcRxI4UPROA4ThOA4GcDOA4WcDOE4DgOE4TiRxHEjiRxI4kcKOFHDE4YnFE4onFE4onHE44nFE4kcSOJHEjiRxI4kcKOJHGjBGCMImMSkehZZZZZZfwb6KMRwMDAr4SYuivhX3K+FZZe9/Drv0UUUV8GjEcDjMDErutllmQn8h9596yyy/i0V2rLLL2ooor4NFGJgYGBiUVtRW1FFFFFFFdFFF9x9b7r667Nllll/Hrau7RXw6KMRwMDExMSiiiiiiiuhCX2lll/Aoor4Fl9NFFFFfCooooooxKKKKKKK2S7l/6K+3fcoorv0UUUYlFFFFFfWv5ll/Q1vRXcooooooxKMTExMSvurL+gr6Git6KKKK+4r5tbUUV2K+hr/TLL+LX/lN//wBCr/Y7L/8AXF/qV/6lf/i9/wDkn//EADARAAICAQMDAwQCAQUBAQEAAAABAhESAxATICEwMUBRBBQiUEFhYAUjMlJxcIFC/9oACAECAQE/AdP0NV9ib2gIY91ux9CEtokFu93syJp+pBGmiAiZrkx76ZEQhfs7HMlqDmN9TmkT+o/hFT1PUjpxj6bre+nU7tIRBWz6v8cNJfwh9UeyNVktorZ9C3Y+hboiMY92PaHqaS7kDTRDaZrkx76ZAQhe8r2zkOQ5Dl1OdEtb+EYyl/yFFL0K8bZ6z2+i0+TXij6qWWrIY+lwpGrEcBaZHSOMemcZgYC0zAwHAcTEooXYRQkRXYYx7v1HtBGj6kexAjtM1yQ99JEdkL9iyTHLqch6l+hg5f8AIUUvTavLp9++3+mRpy1H/CH37jH0z0+xqaRwEdAWicQ9E4ThOE4R6Jwj0R6Jwj0TiOIWkR0haQtPsPTHpj0zAwJxopsUCEDRh3IwIQIxKJmsTQ1vpkdkL9jIkx9DlRbl/wATi/7FV6FdNeFk3SIKklt9OuP6Nv52ez6JyJbJoyRkjMyRkjJGSHJFobifiJRMYmETBCghaaFpowHpj0x6ZxHET0jgI6JDRNLS7i0yMRRKJo1YE4kolCj3NPTKGIX7GRLduj8p+gtH/sV8eOuljJd2kIRrfhpaemMez6HI9SiiiiihxMTEooxMDAwMTExKEZGRfQ0ymLsRZpyoWohaiFqo5EOSJ0ycUT0ziIaZCCGiQhe8r2jJDQ+x+UvQjpJepRWzH4a2rdjI/lMR9Lp8mqon1UstVjGMfRZfVRRRRRRRRRW1dVl7X05CkZi1DkOQyG9khCZKQxC/YMe3q+pj8a2YyTNFerEf6ZC9XL4JSyblu9nvRiYmJiYmJiYmJiYGBiYGBgYGBgYGBgYGBgYGBgYGBgYmJRRXTZZkX+zY9o/O1bsfkoZIkPt3NJVFbfRLD6aU/nZ7PZ+KiiiiiiijExMTExMTAwMDAwMDAxMSiiiiiiiiiiiiiiv2ctn6CXbdjGPxrZjJEvgWzWH00IfPSxj93aMiyiiit6KKKKKKKKKKK/ZPaRQkUMY/MyRIirmhGlDOaifVP86+Ol+6yMx6g5mQpCf+BMey7srd7PqrwMkM0VbbEf6bDLXT+DUllNy637SzIcjIyHIczIyFIixP/AXvFbsYx+VjJDNFfjt/p8cNOer4H7CzIyMjIschzMtlGyGi2S0XH1PRkWRf+AvdLoYxj8jGMn6EVS2guP6SK+epj89jkZGRkZDmZHcxsjpNi0RQSIk45I1I0QYmJ/4A96KK2Y9q8jJDPWSQiKt0fU/jjp/HtmxssyMtqI6bZHR+SMEuiIjX0z0YmJ/q69oxiEu+7GMfXXUxjGRVzEfRQz1omvLLUb9qxjGzEWkxaYoi6kRNSOUTUhQmJi/YV5nsiK3Yxj3rxMYxml6Xt/p0cXLUf8L2zJDF6kKEV0LoQhH1GmNUxCf7KvI9kR3Yx+Ot2SGT7IgqQjRWH0l/PhflZJDQ0achdK6UIlHJGrAiJi/evZC3Yx9FFFdbGMZLvSEI1vw04Q8L3XjkMkRZFl9C6kxH1EP5JKmIX717JbsYx+Kt2MZIXee2hDPUjE+pllqv27GS2gy+lbLoRNZI1YCEy/3b2Qluxj8j2Yxku5p97e30Ef8Acyf8Ddu/cMYxMixPqXQhGvD+Sap7J/u3tFEY9jHZjGVtXRXR/Ixj2aJOjTVISPp/w0Jz+fbvZjJIRFiF0LZC2W0o2jVgVQhfuntAjHsOA4jiMe9bVu+h7PZvuT79hCJrDQhH3D2YxiZBi6UIWy31ofyakaEL909tNC7CdjRKJKI0V4mPZjJH/wDS2045NI+qf518e4YxjQ1tFi6kIW6GrRrQGqF+7RpLez1JQJQHEorpe7HuySI/8nt9HG9VGo8pN+5YxjQyLIPqQhdOrH+TUjsn+6RpenTeziOI4mJXSx7PZj7EPTb6b8Izn4X7FoYxkltBi79CELZboas1YElWy/bvaJD06XtZ6mA4DgOJW7Hs9mT9BET/AIfTpfPu2MY0MgyD3oWy2XTqx/k1YfuXtFC6mUegpF2OI4EojRRIe7GT/hbRPqe2MPj3bHs0SQiDI9+ldSH3NWBONbL9xBd/GnQplpmFkoDgTh3Gt5DJf8ttCOU4o1pZTb949mSGRZB9K8GojVj+4YiHXZfTdEZHqOJOBKJJFEhi7vb6X8ZOXx75jGSQiDIu/IzUiaka/cIiu3nUhSscbJaZLTHAnEn2TIoiu5D8dBv598xjJIZBmm/LqI1YjVft4i8V9SIjQ42S0ycDXVKhETV/GEYe/YxkkRZBkHfSuujUiakf28V7FCJSMi7JRTPqv+VbaccqXya7ufv2MY9oM05C8k1ZqxGq/bQXsUWNlkZEtSomp3ltoL8//CUrdl7WXvZZZZZZfsmPZjIsgzTfkZOJqR/aoiu3kQyuhj3n3Jd5EVbIuoSlvZZZZZZZZZZZfs2MY9oMhITvyTRqwJKn+ye0UJeWit2Me+o6W0DV/HSiiyyyyy9rLLLLLL9oxjGRZBmnLyMnE1Y/smIgvJRRRRQxj6Nd/jtFdj6p06+CzIyMjIyMiyzIyMjIyKK2r2D2Y9oMgxO/JNGpElGv2SILyUUVsxvq133raC7pH1ErfRZZZZZe1ll+2YxjIsgzTl5ZxNWI/wBikQRW9dVFdLYxj3ZqO5CPTJmp6/o3sx7QZBkXa8klZOJONfr3tBCXgoorpb2Y+iXoM013J9tMfr7Cyyyyyyyyyyy9297L2ezGQZBkH5ZI1Yj/AFFFFdKILw1tXk1XUdo+jZr9lXnosyMjIyMjIyMjIzMzMyMiy/AxEGRIu/JJE42TjX6hIoaH0I017fXeyXZI13e9eZ7WZGRkZGRkZGRkZFl7WWWXsx7wIEH5ZInAlH21+wRFGI4kuiJFdVdT8er3ls+zNR9/YtbMe1l7WWWXuixvoW7EiCIkRMTLL6r3Y0SgPTMemiiitqK9lRj1RIGJNEuiAvbMl3dkFbG/Ul6+wUTEwJQHEa8VliTZHSOE4TjaMSihIiLZbLayy/A0NEojiYmJiYGBgYGBxnGOA4lFFFFFFeFFD6YmmJGoS3Rpr2+o6jtp/wAsn2j7FaZxnGS0yemTj1VvRiKBpaRHSFpD0v5Ho2S0CWjQ41smRYhbWZFlllllllll7tFGIoCgYmKMSiikUiSQ0UUUUUUUUUUUUV0WN9MTTEahPdGmvb677Vt6QNX09jRW1GojUQ+tIURRIwNGFoUaEiVYmSG0arJPeImJlllll72WWWZGRmZozRkWKRmjNHIciOU5UcxzDnZfXRRRRRQ/EiAjUJj2RD2+t67T7UjV9dqKGiitqKKK2ooox3ss1DURJdFl7IRFCZp6qgzmiPXSJfUZMesPWZKdj3QmWZGRmZmZmchyHIchynKcpyGZmzNmbORmbMmZMva/DfgfiRBikTZMYtlIzFMv2k3ciKt0ajuTJd3tRRRgYmJgYHGYHGcZxnEYDZZkZE5E2NFGJiVvkRmchynKco9U5DkMzIyLFtnQ9U5jmOY5TlOU5TkMzMzZkZCe9FbUUYmJiUVvZZZfTZZZY2Nje1lllmRkZCkRmKY5Enst7LFM5DkM0ZFl+WfZbaS72Se0YWPSOLscTFpslpNnFIjotn20haEkP6Vs+2Z9u5KmfaC+maPs0OQ5jmPUJag5F9FFFFFdFllll75GQ5DkWX5ULeyyy/Z3s/DZYmKRkPZeGzIzMzkOQyRfg13+O14wYyzTkWKR26b8DmOQ2xjL2RW76H0X10YjiNFFFFeGtkyy+iyyyyyyyy9rLLLLLL66KKKMShxHErqXhorptmZyHIciMl0a771tqPskMSERmXZYpF7WWX15GRezKMSjuWX0sfUkUKAtMWkcY9MekcRxnGPTHEoraiiitqK3Ra67LLLL9nRRgcZgYldEUYmPUtn4smcjRzfJN5O9tT1GR3yojMssUjLx2X1PayyyxsvosTEzIUxaiFqo5UPVRynIchyGY37Siit8TAwMRQMDAxKMTExKKMDAwMTExMDEoowHpjgU9l2E9qMTEryV1Iq+5xkI4nH3MB6Yo1tZZYpCkZFll75mZmZmZmZmRkZFl7UUUUV1WWWWWWXvZYiijExKKKKKKKKKMSiiiiit62RRiVtRRRXRRXRW97UV0Yo4x6ZgymizIs7MwHAx2ry1UGxMorpoorezIyMhSMi+qyyyyyyxMssvx2WWWWWWWQ7kdM4zAxMSivFXRXSmKW9lmR6lHoWXtRW17UYnoWXtRWye1Dih6Y9IxaLaFMUy0zFMemOLK8ep20kvkS7ldFFbWWdiiit8jMy6L8NmRkWWWWWX5tMju/b2KXTYmeo475bVY1tkZbYmLPQyMjsYldFDQ9NMekh6bR3QtRi1RTTKTHpj02ivB9T6qJDwUUVtZZ23ord/oIEd6MRr26ZFp+o9P4MSiixSKUhx3sUikzArZSFJH4scCjuJsTLXVQxxseiiWi16DjKIpNC1WLW+TODGoEqRez20lc0a0rk2R7F+KjEreyy/0cWabHsmMfuYajQnGY4NbRM4jn27EdRn4yHHEssyoU2XGXqOHwV/R/8Ah3LkZL+R4iaE/wChf+Hcoz/gva18mUfkzgv5FqxOWI9aBqSjL0RXXBOToloyRH/bg5spyKFt3LL8VFFfpLNKe6Zfu7Ia38MwUu6O0RYoTi/UvSQ9TTrsQ169T8Z+hxyOKRx/2YR/7GUYLtIWtGS/Iz0jl0vg5tP/AKnPH/qfcf0fcy+D7iZ9xP5Oafyck/k7n5HcwkcY0xJsx/sr+yhxRRHSUldnDH/sThFK099Oai+59xA19Tk7I018jiOBTXVfjrorzV7HT9f0MZOLtH3H9H3H9H3Mvgbt3v3PzZjIwkccjjZx/wBnH/Zxo40YRMYmK+CkR0rVnF/ZKOnH1Z2f/FCiyt3GxaZgikNIaRSMYlIpFLw0YlFb37Fe6ooh6i9vTMZfBxy+DikcMjhZwv5OH+zhRxROOJxx+DGPwUvj2Ny/hlN+rFBLrbMjIvx0PqoxHErayy+iy97LL2vw0V0Y2NNeWxMi766MWYS+Din8HDM4JHAz7f8As4F8nBE4YnFA44fBhH4KXTZZaMkZIyRnEziZxM4mSMkZIyMjIyMi2W/guR+RUjGRkVOQoeJj7F+avBRRiYlFFDGumuj0LvfsUUV0Uf8Aomeo4Jj03/BVeNGi+xjfoYTIr5Fh8GemcsUcyOdHOc/9HP8A0c7+Dml8HLL4OSZnqGWqXqn+6Y6xhqnFqnDqHBM+3kfbyPt2fb/2fbn26+T7dHAjhicMTigccTCCKgf7Z/tmWmZ6Zy6ZzQOeBzRPuInLOXojFv8A5MSS8rK3svyUUV4KKKMTEwL8nodmYGJif+lUJD9ewvUaIaX/AGOKB9vp/B9vp/BwafwcOn8HFp/Br4QVJbUUVtpSoTLIsTifgVAqBUD8C9My0zk0/k5dP5ObT+Tn0/k+40/k+40/k+50/k+50z7qB91A+6gfdr4PvP6PvD7xn3cj7uR95I+7fyfdP5H9TL5PuP7Od/Jz/Jzo5onIcxzHMcxyN+h+b/gjpzfqLRX8iil47LLMjITKMSumy/JXtkIrZFDXYSsoqvQcbILvQor169TUwRKbk7e3cT3sWozmOc52c7OY55HOcpyM5JHJI5pD1GzkkZyZnIzkZyM5GcjKRlIuR3O53O5RRiUUVvSKKbFpNi+nF9OhaSRil4rMhzHqUcone1GJXRRRX6Rdz+StmiAij02/gj/ZGKXXKVDs4oT/AIJ/Std4mMl6lMrwVtW9FFFFFFMxZizFmLMGYMwl8GEjCXwcUvg4pfAtGZwzOCZwTKFFi0pMWhJi+mFoJC00il48h6g9U5bHIyH+Qo7WZGRZZfTRRXkv2Fbp7emyezF6kdqTOwhLrbobEKSHqV6CqfqLTicMThicET7eJ9vE+3ifbxPt4n28TgicED7eJwROGJwxOGJxROOJxxMImETGJijFGKMUUikV18cTBFLx2ZIcxzHqDkymzCxaZgSTQuxkX0WWZFll9VFfokULsJ7f+EY14KsxRgjBGCMF+ssyRkZnIOZmOZmZGaMzkOU5jmFqxfqZwY4xZg16H5IyL6bLLLLLLL9nfTRXjTKEtl2MjIyMiyyyyyyyyyyyyyyzIyMjIyRkjJGSMkZIzRmjMzRmjNGaM0ZGRkZIyRkjJGSMjIyM0Zo5EciOVHMjnR9wj7hH3COc5zmZynIzMzM2ZMtllllllmTFqSQtdkdczTPxZid0ZFl72WWWWWWWWX7Gtl4q3TEzJfyKcPguBcC4DcC4ix+Sl8mP9mP9n/6V/ZX9lf2V/ZX9lf2MQyyyxyY9RnMznZzs52c7OaRzSOWRyyOWRyyOWRzSOaRzSOaRzM55HPI52c0jmkc0jmkcsjkkZyMmW969vbFNoWqLVM0ztvZZfTZZZZZfVXsF4Uy72TL6LFIvZieye6ZLqocUx6Y4Fe1v2VFFefKhagtQzMkXtZZZZe1lll73tGGQ9NmBiNdN7+nnsT8CZe9liYyy/AhocRwMSve0YmJiYlFGJiUNexsszMzMsvayyyyyyyyxM054nIZIqLJxQ97L8D2XhQn4b6Eyyy9rLEyyyyy96HExKKK9lRW9Fb2WWWWZGRkZFl+1syFIyLLLLLLLLLFIyMjMcxvey/GheFMvprrvqssssve9/UxMTExK863ratn0XtZZfvbLMi+iyzIyMjIciy/LW68KF13037OhoY/MmWWWWWP9hZkWWWXtfjryJl+Cyy/aXs0NFfsK8Vllllll/o0/0FDiUV+7sssssssvpor3V7X7K+i/E0NFfvbLL6LL9/fsr8F9dFFFFfvbLL/Q3+jooor9/f8AglFFFFf/AAOiiiiv/gVFFFFf5vfkoooor/ML8V+CtqKK/wAzssvzUUUUV+wrqr/DKKKKK/WLooor/Ar89bUUV+lr/wCBrwf/xABLEAABAgQACQgGBwQIBwAAAAAAAQIDESExBBASIjJAUFGRIDAzQWFxgaETNEJSYJIFI2KC0eHwNZOiwRRDU3Byg7HSRHOjsLLi8f/aAAgBAQAGPwLYC8pOUgnwBeRm1M53NpuTFAh9sxrdybHXlJykE29czSq869/hiVfcQXY68pBOSgm3LlCq8+2l8USLvWc9g351OUgm2rlCarqCINbuQdK/UNRNqIJte5coVrqTExQ4fvOr3DG8xYtisWLFixYsWLFi2KxbHYsWLFixYtzNixbloJtfdqr37kxInuJzNixYsWLFi2OxYsWLFixYsWLFixYsWLFixYsWLFixYsWLFi2OxbayNRdXV29cUWJ27LsWLFixYtjttdezVmoQ29g5dyDe74qUevbq0NO3E1nvORBjfip+r5Xu4obeptV+KlEbv1d79+KK/qSibDuX+B0bu1dieIqiv612BcuXNLEnwKpEXt1ZqCJuFTfQYmvXLmkUKuXEi9QlfgV69gq6s3sxQWdsxE3a3cuXM0q7lI1VE+BHJvpq73+GJy9TKC6wpcuUKu5lFG1+BGM1dvbXE+N71U1lZCoq86jVE+AlHdmrIg1u5B3Ab1T1lRVTnUcNr8BKPdvXVmJihM3uIbezWlF53JmJ8AxF1d792JqdTUF1pRedRw2vwAojN66vP3lxRYq7yeyUaqiV2+pk7tXht7BztyE+tddXnUcJXb6kRe3Ve0hp24mt95yEJvjstGqoldvPXsFXVsr3UxQm7iW7XV55FG1E26rd+rufvxRInUlEFXZiNEJ7clq0iGniK5bIPiOo5dgKS5xFG126urMRN4ibhU96kiG3rXYC88jRKk9tKLqzfs1xYOztyhjPdTYK86ija7bXV4kTwxK7qbJP5jtgqKvOyEqIu2Zauze6uKJHX2prsNedRRKibZXVmt3qMZuQevXZO8ROt2w1F51E6hK7ZXVoe5FxQYfvOITOzYaii84iiVErtddXfF3JiYnuoL2bEUUlzqCbWlqK8lqCrLSXFHjLv/IVd+xFFJ86lRPgxN5Bb9mY9/upMc5auWk9iqKLzqbhBNqrq0NnWriQ1iaT3o1CBDT2q7GUUXnUqJtRdXR3upigQ06kmJDT2Gy2MoopLnEEr8FxH76Yo0T3afrxIz97tjqLzyCbSUlq7O2orlshhGELSJJV8V2SovOoJUTaS6s1O0a3ckh6dbs3iQ4af1jvL9JslRRedRBK7RUXVmdlcWDQkuq5Rg8BPYZP9cNlKLzqKJUTaC6u9/ZiXryERP5kddy5PDZSi89ISuz1JavPeuLCsKXtcKq3XZSi89MSpcvs5dWkQ27kIippSkneK1KK/N/XnsKSYq07UJKt7KXLl8dCxbl25lC/KviuXLl9hLq0Nvbigwf7R9e4wTB03ZS/rjsG5OekLUkRM7OZnFzSKalYsW2curq73UxQYfuNnxHolmIjdgXFRFqMWfVvJ4sIX7OT/LU7FudsWLFtgLrDnb1xYVG+0vlQiRPecq7AkmLIdNqLZ24tQoniNgw0mxtVdvUsWKFeVYtybFixYsWLFixYsWLFixYtjtsexbUYadkx7/dSZFd7Tmynv/UytC5RSqFsVsVuTVMU5ahJCqFjRJNtuUk5abi3JuULFixYsWLFuRYttaxYsW59jd6khIaaURyNIMFO8ljlyLUJKXxTnIuScXL0LahYty6fBlsdi3MIvu1xQG9TM9f5GUT+Ab7XtioUKtLY4j/DFEf4CNxJIrsm5f4QsVabjMfxEYt8TeIpOVNlWLFi2yb6jUprOSl3UExSJ46Yq7BsWLFixbZl9k4LCTtevh/9MlfhGut3JFS5UoUx1Kl+cf7sJiN8bir8JV5deTUvyal0xUx52OZJCZRSalTOKSLFDNKoZxcvzGEYV1PcqoT23bV6E2knUKY6lDsKrJS+PNK2LyXHYzStiSrI0i+KUO5nCtMrqJTLkkUzlkgqTVEFX0iyQ3oIj25PaZr0LIpVsj6t5TOKwVUrDVC3IjOnJcmSKQmaM5UET4TqUopVquZvNJEU0kKOJ5UkM96KhlMdJxVmWzeZqpPdiuVUoklPq5RGisc9GOTeT9OyR00+4q9VNB3AV2DtVGt6ndZ9ZARnaXYhlemYTWMnEylXKURjMxErQSN6JYkJfaaZCNcq7pH1cN6FIT18D1d/AdDhwJZXWoiwnpE+yo3KcjYs7TpIn6VEXvHemj+lZ1IVQtLFY0VNB3Azmy7x0RVlJJiIj+JgWANWfpH5b0T3U/XkNyeozk2tPX5OSaCxcGVUenUNg4Tg6ovvKIrWMye8RiPbDTsKRcspYZlOT0c85D0mDO9DF7LGRhsJXQ/7RBzmvVUbeliiPXwM2DEXwM3BYq+BDZF+jX5LlrE3Cf0BrY0JPYcoycOEx6pndh0kJpXCmp3IZ2Gr4IZ2GRFM6NFXxPbXxOjVfEpg0+JTAv4DJZgytbuRsiaQGovgWa3xM90zJkhWN/COiRcIVGNqq5JmvwmJ3QH/AO0zcGwl3ekj1OIv67z0a4A9n2nW/wBToWfKJCb9HxcIVW5WVCh0Pq/oXCneEv5ENkX6Ni4M1/W51vI0VUpCUcxIV0KMyfvEXComfFVMls1sSclcdCu05bBdDipRdw1zcPd6BF6OVyuFPFa+NEruIcJFVyMSSK7FRJmS6Ar2r1K0lDwJrO5iIUgonAsnEuxPErEaVi+R0i8CrnntL4mh5nRIdG3gUY1Pui4D/Qn4REa1HK5H5CVJM+ilVf8Anf8AqJ6L6EbDavtRIq/gIsdkBnY2amdLk51eTYtisWLbbyk6hF1aynRu4HRP4HRKaKJ4l28RZxGyKxU4FYq8DSee1xNFV8TokOiZwNBvDUVwmPBhxHqiIuU2an1eCwWr2MQo1Nn0M6nOUUziiz5y2JTJX2VVvLoxy9yHQv8AlOhcdF5oWan3ir2IVit4FY38JWI4u9fE0V4nR+Z0TeBSEz5SiInIqqFYrE73FcKgJ/mIeuQP3iHrkH5j1yGett4KetfwOOncv+WppRV7mGhhC/dT8SkDCP4fxKYLF8VQpgjvnPU/+p+RTAk/e/kUwSH4vKYPg/n+J0WCp913+4/4dvcz8zp4afcQ9aYn3GnrqcGn7Q/8TJg4XFir9gRYmGRoLf8AHUT0mFYREX7UVTrXx2hbHJSiYqOkbypfk1opRS5JStUKrklJKV5rcOyc9i9R9Y17fCZnPTxaZyt+RT2f3RTyhFPSfIaMVfA6GMvD8SmDxeKFMFXxeUwVv7z8imDw/nKQoPmaMBPBfxLwE+6dNBT7p63DT7qH7QRO5G/gV+kl4oftF/7w9fi/vFPXIq/ecesRV8VNN6lnL4FGO4FGOOjU6PzOj8zQTiaLSzTq4Gl5Gn5GmvArEdwNN5eIWilGRVOiicDoYnA6BxJuDuVRMtGwG9ojsJVcId22EbCgtYnYhRNnVpjouPcScUqUpjsdmO9CbXErO3Eiair7vWVWaCRcIZ3Q/wASbYKN7jo0OjOjQ6Jp0TOBkshM9I77KUQr5kq/6i9f+FS9O2huMprcru/IW6G83F1NJ/E6WJ8ynSxPmU6SJ8ynSReKmlF4qXi8VNGJ5nRxF8DoIvyqdBE4Hq8T5T1aJ8p6u/gerv4HQqdCpJYUvFCeWxPE6VnmZ0ZPBDp5/dKx3fIViRPlKrEUtFKwnu8T1d3Epg0/E9UbLxJtwVjuwpgsNO9kz1aF4NNBje9kiyS7jqVO405Gn5EpOVOwnORN+cZrEQonPURTsK7CpfkVxy6idsXYLuxSkuJaCVudos7n2etD6lqq51EaNi4RnRupvU3l1VMpbIK9y5SqLNDNJ3PxN3+E6l8j/cVRvi2ZoNl3TQ6KH90lKHPthmfBh9+SdGxF7EKKnAqiL3IfkXU0jSJKqqV4lF4lUyV3oe8UmhVJkixbxQzV4lWGiUTkdRYoWLFseaku4395UsW525pFE2RPlyxdmObSciaE7rjRsFLiK5cuL1uXlq9wsR1+rsOky29pKMmSvaTYpoqaPKtyrFinKsVSZRFKoWNEsWLGiWLFsdixYvqFzSM1FUX2SrlUmux5Y581LEuOYkqN3iI1K7+Y+wnUURDR8yrCHNmZ1iULFixYsWLFixYsWLFixbkWLFixYtrVyr0M2alKFXryEUkX2NPUZNQRYlewonM2LFixbZ9yrkKKq9xmsXxNxnOVeVYoZsz2jOM40i+1E2tcuXxUaqlGHUhVylXO5di3KsWM3FR3EqVLl9oZjpGkh7JZpotKw04nR+Z0XmZ0JxZxZxZxZxZxZx7R7RZxRqmgpo+Zo+Zo+Zo+fM3Lly5cuX525pF1x2LavbH1oUUqVxX/ALg6ci/9xF+er/2Mr//EACoQAAMAAgEEAQMEAwEBAAAAAAABERAhMSBBUWFxMIGRobHB0UBQ8OHx/9oACAEBAAE/IVGqEkEiG3g4i6EHwczYIUU4C6LmrAVYHvo7CaHbi+J+INrKUYbHZiXhiCWjixQ+w2hxwXRcXF63l/5ayhEBY9h7jiBLuNBDHcsQ65K36QyaZ+w9rPgbGwRBB9zkICVl297RE83Wf4X/ANElguJBMUJ+8JbRpCoVRAfRwExvWD5FgguBYXM3PRxdEE0wpoIiVCzZzZw8DwoE0hdnY5sTGiD6wXA30PDeL/p7MKPc9o3ib5GgmJjKh0xI6WbQeVz9DxN0bKw9sTZRjVCbaCQvnkhP0+f5Gp+wggoomCAToQpsSlyJr2Q+QgWq2I8kTkWJG+RN5EeRBIgaUgULEUWwu8jps4nZC4HAQ2zTzitzDfBcHD9mPY4j4zA+kMJ5uW+l/wCliJDWzfIWhELEoeS6NBxH9cxQPMMN47CxoS0KROTQdpm9HwTe73s8foPYgmJBCDHsRnkXua3so+TZycOzTyKlyaeR7cnlGncT5FXkmcmvkSPkT5E3kXeRcbojmxV5N3IoKJsSdwvuJR7CLkTwERwX5FNDkOhIiQjyVPkKfIZd+BqDaHE9dDL0Xof+ipNYlveO6HeRF7wLRsdysKwMUYbws1QhpjdzkONvcJt2kXLHU+QlWia1/wB+RiCCiiYS2JEKA/8AA/AcoQQgnwMj9h5TPHRH2aZGIsp7i8bPYLXsleAoP3s90L7jt2QNnLk28lNJmnpdPZZFDmRI2LZqbF3kS9xtbL8izbGG4HG0XLf0H/mrqaI0vI3vAoE2GXYvLKas2yTiZRsbGy6ExDgMMbMeiVo8AJ1ibpDv6D/2D9IQ+RMEFEIPQeLfbETDZ/QWqCBBwCBb4Wg48YTZaFeBt4GBzTD2HiDIKg94erTEkJvhja7Gu5jnknPDE+42jvAkaJOBuuTyg5eRlRulHPBMNrFL/rG0NUzkytWK0T2PHuDd1u/IgngcYTKUbGylwemFhCCHwLOcf9fySiuWWz79v5Hp60MeCCCEzIoqShIUCJAvQTeOoKGTJkwgNA0MYRgkMk0QDV/+B4bgWKWXv/Ipm7+o0VML/wAChDcHtcGvgSBpiHoTFhsv+t0DaZxY6UNj+5jxMN0ZMbKNjZwUo3BhMWvFTfBqyMpEeEbV+NDdbfkYxMEEIL8CfAvALxCJKF4BeIXgF4D1HqPUeo9Q/Ees9A/Fg9B6iScTXwR4I8D9T4nxH6npPiMGgQGgahoEhHgt2EthYtEcSC6G/wDVU0Ghk3JHsGGG3jtkRSlGyjy0NBzE3Re/ctx8GmJL/Oz3CM+eaYY+gITAgkhIQQREXWBJJJJJBBP0MLUkakEDUaeBr4GvgaeB+AaeBPga+Br4wSxJ9G/6S4fGLUyq8Klr74D2HG6AmXJSlKPjoBEOIZyNQbt4EaLn7Rz/AAdqtYYx5EJghCEIX1liEw2kQUg0MMMMMsMssssMMvGgsr/pb9B4jYcpZ95F01YY4iKJlKPFww28IUwNi4RsQ7s5+Zxfszt+Fljwa6BCELKxOuEITFRqPMEblMPDH0saIQhMJ9F/6h9ZhDHtx6jZax82omUbL0iwajY1yElS2LlFjvy23xz+o9Z5YxoeEwQhCwhfQhMmWHiJ7hTyNA5CF08Sdwx4Yx9D/wAFl/zoLp0EkTc3fwuFzpgxSjfQLvFGxzx3L13PJTQU80kqywKbfv0MY+gQhCyhddxYYeLyDO7lwXy2MobKfokpX7ltSyT6FjGPL/27xaXin2JDFEx+iUQbGE8EUYaiFKCDuXrEoOESH+x/Y+f0Ox9XSxjHkhC+jYQGHi5Ri7k7oIXTaK9Sp+B+9/k/ZoI2NUuUJCkbyj6H0PL/ANnwOY1m6OB7AdNnItxXiw0wTw2FLgmUbG7ghxwrnxvg27Tt/hfr+h8OnQxjGPBCF0rrtHJnfxK7wWtUVv8AeIqX6RY2XyOd/wALD5C0Eofvj5Q6KbpSM0l+k/rP/TNEcxOm1NldDY5+ZzNBsFp0jsIuhsTubma2TbXph4dEnzzf4xK5eGPoFgvpr5RwURsS/gv8PbLtCrbtHtKJSDZZFGOrtLuIASneOgx9FxS/4F/zb9BtGsgzN2exhvZqbZaJ4MsbHcQmU5YMLR4No4TGzCKPcV8DYn0PDGPCFkvonJjOnW2NVGLEo9TcNbH+MbmtiVCUOZNo5yXRrDbm9F6L/sexqNTJh5wtBvGizosE68HsXIhvC4OaY++2ehhHP5VDXvRJT04T6GMY8LBCETqeCghdCTQGDYjjoNeCbF146NOUaXcUAtW8s3ilL/pb0X6zesGkr/CbNzWo6E948MEPYujh0a4b0U2FN8FNHaTpxE8dRryl/wCw9Q7CxehjHhYIQvoIJh0DbrE2Jb8YIcBfgncTeHwNfBZGrbgcxLJPDaNlzcUv0bi5v+RSl670PEc5zHnyRYGHz6mmQ8SF2JiFUQnvBMX4FPWqLDu1N93z+kPjWhMWLljHhYIQvokKBINzQi+Cwn/IRq032NlGE2cTmb4WInIKVCFbzb6b/s7iCZzEUJ3bJhraF4w0WD4KUZSieFKzcIWitH5hBw/YI7Tm0+FpDo8nRC6mPCwQhfQbELJnORvRR0chBkxdgaFFOZoLUcx/BjEDXSeNO5vRS5uaUbzf9PBEk8Wm1xEjE6TRBxYWWHYthFkJ1FKvngpoNGUZQl5Z5MVntMc8m/cCWEIvU8IQhC62M2x3iqesCyG7yNfkWj2PymgQ57w2FGLezIv0FK3vHpzc3F6KUo8v/UjWym8D3b8D3l0ijFDnAo9h4ExOnaJw2GNRsapFn2D/ADISIX0YX7/we4VghC6XiiQhCF9BMFL5zET0PYfhFGhoSr0UxKzgepY5DYBHrFUhrGnc3FzS4vS/8m/XbRtx7SDQ8t0ej0zTg0m4noo2XCB4bCY5uxcHAbgRKi667gh6l2e24n+n6nxSwWUXoeUhCEL6DQgmKyZo0OQtruhR7INYaC+hcd0d0U18MSYra3ln0UuKXqv1bi/5T6Oc5x7Ue2PWekanwX7B2SqKCQSsabEJ7KMUTz7FjG2MWil8vBDWW2fAlr9z2MxYXU8oQhfRY0ILiucoaL7F2J79je/Q6IKeHYS4s0Gosj+DkJATvhos0vXf8ClKUv16XquILBuml4nqPSXuLwipJFD3CVYqmJ3Btj00y9ka7HaoaijaoeZFNsWd3fpv/kIRcLN6VlfSaEEwSM1vRr0Lwb2yqkfcajnM1Z+ofJwwKTynipalcFei4vRf8G/416tRyDRih6O/Wek9Yzk2L5Mc/Rr5hoRRlG2csVyaBOiIcvqghLwkNYVsvdX/AEfch/77IWEXC6WUX1YIIIUjlLrQcxpcHYOY9cWgfbG5Go37G94ajXD12EjIQmBOjLi/629DazFd6Lb311++of4HNnxo8Ii2QNTZzjyp9hMtvQxTPeBAd7R8f/WJqCsLCxepIXSvoNCCYIGVT0a9BzlizvLrLPgY5sGKXGiqBS03h1lxSlzf9a+jmwzV5yXzfrwek9BzaL9hiEgvA7wccylcJMcHsHnoWbXp/wB9se5CUr93/eCp9wQsJ4XS8JE+sgghcOQpoHv3Bw/Y1mPSbBt4WGGuLRjkO9dsPSjAqGUv+x1GhkUyTgQg0UxevoOqeg5xi3Bp/IatcGse48wfEbRLTLRqMDw0tv8ABb0vwcve2LCzellwvrMTBCwXTNbQezUTO7pAmGxsb8ZXIOUyiJSVyFzcX/Xanig58IHlCDQ8KmL1ZX0npK9qnciP5NfJvEP9grRdjR5CWvub+kfcaX6s9LC/79cIXQilxfoT6TEwUmaOTRRaE3CHmnrFrY4w2vIw2PuU+FHodUjPGUl4bLov+Tfo36jejQbC6WGHmEIMU6IvWc+ieyBqm6TfOhALRTciKloeCxNvavS/rR5fVv8A789CfQil/wARiCYLxZaFFoWC46cBtHsN7GNj4wes7F/MLU7sdDn/ACb9e/ReLBuxN7GN4LoaGhoRB4nQSTE5HNvRe9y1svsU+H2N0ox0vA/++cO86X9q9/wfYpFKUpSlKUpSl/wHkopZQomUWh4MFbmG9DDDIaDGxhjGPSrpnISouGiyv9c2jmOQ/MKPLh0wY4YIHhpZsgu0KSIcDwLY3UbYrQn+z/8AcJQ7/wALT9CoctRdd9dNPl/gPJcUKRr0OMDmRB4TbwbGNlLk1b8GvvC1G+TgKnP+w0YpuarwMNjeEUpS4ZxICRNcnkiVCrjRqWsvp62cR59hvvsU7VrC76Kkh+S//c3zPl0D5HyPkfLH5Hy6p9J4eCC6xcYLaDdqNYn5GG8G2MMeHgx+9sV2iGLgt1r/AFDNBykXKMeG/oMUIxefeHcXAa+CRV2DoGhBapOFon+uIc/wzQs/0f8Amvof/n/jdseGJnUzVoSaDXkuORj5Gox8DHhjGbIlPJHO8g1XUv8AUNrBtPtQqZSjF0Uo4ICV3PcSuyjO1GpETQWY1grzExCffLOvFcT/AEGM6X/L9ofM+WT5Y/MXv1Q+eIQmIQhCdTH0FEzepwKcRzA8GPpGPD9iJssU7eOy/wBc8RznIRdeelUUKUo5RMjT3nJs5NioEJo4SaFpE2aC6LcIzuXxVQr9Jcfq/I6paz47HyKExYmE5Yisiy8V9djyxRMVXCm0Km0uBjOQxjHhjGNHSbVvaOZF5s0C2sr6t/zdTNZBhEroKzFCkiZ7zQ9nLspdnoZx6IJEcTHcZJ97YVwwrEf0df8A13g5yIooIIJ4omXK+rcNjZSjwUXDdNeh4Q1q7Mbo2NjYy4fQxixPHcqw2X+sZqNZeKO98i6IiMUe4ndnJs59jw92RmiZECRqdjuMQ8vgJR8KDlG4LPafpz9hlzZSfw/+PyPIQQTGGExdd6BSlLkpcNjY2PItEEwVCnA0aE2yW0Nt3QwxseL0pxj2j2sVIQ7jVL6IBegUpSlKXF/waUuW1l6PwiwwWE44pIET3nvPaNbQ56zj0SJkhJg2JjUeU1fYRW/M/al/6I82331/z2N4UTExhhsExf4aPgyy8BlFdBoXTXob9BjPA2N7H9FqF4JG4a1/iP8AhhSlKUpSl6gUuWx4UU8No2s5ya7zobwguKknj9xyFB6jG4NEOxpwwKUokce4hFLTg59DaFX8J/A2UomN0FCF0tjDDDg+fS79j5CxKu4qwnkEoiw+MWJaCWL0c0Hz1PosJTPaNahB2J8iV9xUK8dOk5RIlEovoshlhv0j8yPJTvgVlw2SGN5bEka2QCf0DSg5JnsPfl1BtRqPWSySKUqERu6xYh/vPfjt+Fr7DWqyt+XhSiYmMNghCzcvA0E+iLbCbFi9zyhjJ4LPJF5hHcVxeJcEcmIc6OvQ3wxuMtyDRCdAQExjWDyQ7oLfcWNBRLue0+RPkaLufMVdz2FCoisCwUuGhorBiR5I4innFcNS4ZG43FCmsmmSY+JYZnBDB7j3HtHsNY4CJpJZDjBhvZRJ9zZ2Zj+T+ceWl+rR6A1v6hilEIboSFhPNFgYbBwxCBTLEMG5FDl8jfgLSGnk7ics+4i8+2rSQhGCzctvIbNhiog5HHYwbYXbG1WxiVRYvwPwR6hoFrsV46I4EZCk5HO0HaoXFsSJDVdyTkT5EUJHyI4JKGwNnY/G0YUV09aj9c+g5ilR6D3Co/QcTaWzMamVTceyA4Ikj2HvLdygqcOsrPCkKN4UuDPMCjublHwTbf7fkRqTUa/72GLhCwToywuhY5LCBJIoIJGSQiQa2xLqIiDkjYRory20LpzGMqoYh4J33v8AT9h6lxNEc4bOXZ3joy2hFPSFVEQYdPZAliBIIw+xL7D8Q/EeoRGtCJGtrER5Nh5Jp5H3k2FO4HuCi7jaFYdlFoPNYgsUxo1mYXRYY0OowQmCCzNLxNuFoNybGT3+jgkiXc5T2nPgPc4dEOx6OhtKFwYpSlPCoJksVVD8V/4n9hLV/wCp/kuEJCWQTAkJEIQhMIQaNBpxlg4PZwRuGNOxa5C13HAWQfI7yVHZ3G7WvD5/sb8u/wDRu7FDw24PJoTG6xKOzFiGKNYuijY1tjS5jsac17CYT+CnYTQnBjsPPg8VinZkOKcimL+By9x7wxdkzwDwBRwZSFBiBaPCIdunTkQYhY0UEg0YtlhXYK7CXYkLP3RsNwuh4dbLsKmLydoHciRy7Ldxrj32esn26MYnBRsuylxSmhf+v/RM8YpFfaP/AAP5L9ZdLE4mEw0aMyqK6e1ilKcSy6GUCWNUH9hIbspu3b9Dxt2jO9lUcsNfu/L9eGOfuPw90Qya/wCFwibYwbtkNCUmhIyj2JyPrRssUa7YfWxDfAhhKKiooKOhGAjN+Y1PEqBUgnwLwCUEOwvsEIQhZDyBcKUQYJkpvikQlEiYQoihF4xY5jQzsxe6KLg34hWxMcDlmPX0WrRV4uGIb6G7Pu2fc1Pax50IS2/cWfcSuEeW2EHWzxihdhFapDWQNjsguCbiCfaj8GG7IDYlUKR8I2XpEuLiewczFDQX06Fu3RiQzJf0FSZpwipFsPVpCy0XA/Qh2FLFG+TJYcmJ9hSOwEgtFKErsKXYS7CQkCfBDsenCs9OEeBeIQSQJfAk8E+B+mKzExPoCHhESKhFBISSSJSRxbikxaJMUZ2EMa7HYDshaw6vsGNoeEPtTuEP0GVuhcBsomPSDVPKEIRJpLSLNib+7/RM4nLkl+n8FiefAm4HHGxrcjUIRRqGmJEwHvQLi0Od7xkToI/ViMt/I+19gbmXiMvIdsNDDLy94QwQHhngEOwhdhJY7mk+Md/sfiOxCE1jpIwgSCTwILOWTJFkSFISFgywEEEjwXouZR5iyw8FGKiYoblFDcjbAhqiKeQbGUpRo+w29jsgx2Gew2grsOwWIc6w01jgeaePkFNz2t/J/wDDHrNpSL4EMpRo8ihJhcLRS9bDRtjMdjYwhCIcGNIgQKERBIlEeDucHtvZARIdIUOeGhsV3CQk9wvIJBKJBJ0NBpM1k+5HuJfkXQh9xD7k+RL5EnkS+T5nyLCLLxpD5dJssMNoZDeHg2vJAlEpI08kklUPssx0jG8lwowwSsQuGzlCDg7KOwiX4HnRzYNdsh40B4rHkZtKaEfLPANReRJJNsUc5JXil6L1h0IMoWbQww2UTIEZb2ku4nyJEoT3CF3FdwgHxgqNixbFeR7w55GWBw3CmJjDCY4NQ1JjvNIWdrGJJYlgTCpcV5PYPJov3I8i9j2C9j5j9x+wwjyMGvkQjyjyj3EeRK7iU+Rb7jHcS+4juIYqaI7RFbE4Svv0ZtDtiQ1iZcXp5ZDjljVtK/Q3n7CDZp024Qx+C2fLV/LN0IiUgvDwXhKm0JhN7O8nuwpZRPqhfeKCMFRo0NoZDaGg8DDh8FwfwnRjyeSOxs7wwBsd6EPJLlMVgCikrQhYVIgl7EL5KQwtix4J8ZaLzXqEwyD8PtwNZXkYhDyhvca+5p3I98SX3HS0xqFO4n8lpyOdywY9wxux4h7mNT5EJrZFsbooiuzwZ9sXIt9zvgkY0mQIkIUQpS5EzuJlEyu+CX7n2EloS3uPccjawM8NkOEUgfb2i/gdxFvviQxJheiGsCoaJxMX5PkfMfuP3ErFDcYaiRHjAR4E+Bp4GA2D0D0sJC9R6BeI9GKBCGiGhHkjyew9h7iPJHkh9yw6wTLhZhohBrPChrcHAa2fEdNDTEeBlyx6eHglQh3GMO4KrUY9MY8nmuPaJoeuwwXy2d0TvIb2G+Jj7qi5uhVtncCXk4sZ2Q5PaOaOQZ3MS+4s5YLhRMVYTEN+6n8CFP8AnYfeBiVm0ahIb8oeBqnwMNNnNDe+FT7ntEFlNod4JQrRZSGw2GwwcuRdLE8RhloQQMPYPzD8h7xiiIjwTQaDEPJALGThME7uM9x0+TARIUCBAnE4isPwEIogaYtKXQxIaCRrQ1fKK8D12HhwWIuwtrWMaFvL9hPs48auGsR/8MU9PYocCk1Bge0K9nLpHCtFbHRJrR41Hu1NozRAQxa0EOyKeRxqHWqPdGMawojgxZKFwTEIRv1tp+X9D0yiHcY0aGrR6xphryRRfEHxCAlD1gT5I8mgmN4JCp4YeaMuE0MsY2GDB+YeSH5Dyh+QfmPIGNmzeICq+jGxFsnCXgF4iSCI0VEkkEjVlhwJjQKNIc7aRMBFzgYIvAtX+xrt/IjT9wle0F3ZfBZ62cQoMQj2iYk8/YQaqOMRPA+Rusigmhvf2Fnl8Dk0hr1ThfsEvaS6FttjkAVhm1DStSER/wCpRF+sTaSvkSVZsWS7wHGzZynBS0kFZ3qRiwIbDGxdHep3+Vdfsye3MwLZBnbEPOTEQ1zCHyhsmvZkaGgsm9Gsb0csKUuHhomKUuGMhCDGs7ynlNIlnBNhGkMg6V7MYDH2GCYssryX5wpSlLmlETJkDkW7Qp0GDpBDbYujUqGoPijlSc9sKp9kJ7V8HcbGF4O+vsLubCqNvAxfdTmVR7yQ05fYcSmg3VfYlFFafs74Ma62VdDCRwXsYfUJqu1LgGs2ECBI7Br5fcU1FD0xOzE2ELGbdlX2HI9vtRGS5tD5tyfhjdDReD9koWL7ic0GpvWcT2ngN6X7ioFIn/PyQpwX50J85ZOiVDIp42yGgcDuOcDKuCZRvoFw3hS9Dw8KTwIaeExB0aC40TCTLaU2RDI9cCK6so+l9mjYsQQ6UWNr8I9wfYIs8AxpoESQlzYOUzb98KHamK/Qa0+5ns9iH4wtJ+MfPmTVJ7ByIuSCTSuyNConBVPwmJCTZ4ekcXiNRU7uY638VEu1dkSXvUCimgVjTTQ8DVT/AALpiNvUP17Cn6TjnkH3mtmxwsNMGr7LJxBl2g+S+TGwEU8J6DE8Q+FK3x+Ax/QcewQWr8Zs0LTYnCm/gLbur9UPuEGfALbrXlHfo/Yt8M9keRQS9xfoQxswv1PiNMrQ0FmUpWUuGhjLkeBsaozIQUQYjiw2wFNISsWBaj8JMTiYTicosrKJ9C+nQYF2DT2jJXpl0DnEkMrexqhkEd3rHOhPYg6JuD9VRCKLfN5BYvcXCTDxCfbwJN0Un6RLAhTjKupr54JxFEtf1OLiDW3o5X/t8C/5ufYXHYftvU/kRG5L7pS/eOa378LqIaE5l4AW9mqjGG1/aP8AYcJq9UNsiNeBKZQXaf8AoSGzwC/Iktl4qOiD2f8AKhBr5DDaXKq3fpCW1+Ixc2jLzW9Xzr9h9zx9cG+cabj/ABgpJfGH/wDJNuKEbNt81SJpfOgFSAcsjcJC5KjJwOUNB/koL6SiCgSIEMYPMJWbjeMPoxqjTH9DleghwQEFCWXHIQbB3EukASEhCQhYWV/gV40amrzo0IdqtlTw9jWNxPtP/R+AtG4TFd/8w0jU5Q+CGvTRttMeEJ8qofo9wLx32idz8z/wdmfhNij+D/2eVg7tfhfwLsPkK1N/lz+00/pMfsGQiaChEVJad0NK/Ozv9h4gW5B2AfKf1mxFKvsQjoXUdy77boiErsKg27FSTYSuwaOw2OxRJOwq7IX0bNlcsTBoxqMP1Iwm7iQTTJllLlQNlMbmCE2caKwme6i0YW8EVtGzTEoSdyw0HkwJeFqVCSYvcjXcaRsoaFgns7DJT3VxIQJiYmIWaLoXSjhnfCFwm/ecL+afyw0v5GOfkKJvKQnubhb1+h2i+L/kT5XxP8nc/pf0LmWEP3jnB/dbZwv4J2ivtOASXwix0P2fsPLxT7lixz6Nh48w4zS3f4IrWu6zEMU+EcdPGJsN2e43lCE6NjUvVBLwNzYTdBbLkVCBCQ/IcBHGLerdHz6DgmmhBaj5DaNF9iYg4xbKXtEPT0M9gTHBeRXVhBpjXYXBiW8C+xNiULyFfDOQDfGh6t7En2P+c/nFaF7CCk7DfwfqTJw/5x/J6h478/3HNfPH81m/4O0Xwwj+n/0d4/hJHPfPT+jul8udgv5Y4X79HBn4QQ/gEUbcKPbS+WfoXqP1pP7x8j/v9nN/bVnO/YbZ3z+GfwKe74/oP2hf0nH/APN7s4P4I0eV8pAjzPhH89L/AEJ8OS13PsOynyv+Dt582O39wHCv5T+RT+iPu/if0DnKr4/qGuY+39BBevZE/wBkUHvag1PfuvxRHz8lCTh9GqH9IQxCl+hcNGPEXmVZeImNGMMNi/BRBpmxaVJoRsNTtycPdCY96FJyIm+r2J9LaLVxBhXN4ZzGnk2JNBIuEY++mvY/UxafYSj0/sUELlVRMX9xM1PlNdyode8hbPvg2Tp9MbpwLfY2+V6PVROu35wXeC3wz2NDbvH86E7b+wY8M11DUfLohPAaFn3Bfr9X+hcSxX8Gg434S/2J8Cu7XyXeT4V/ArsvkuHXyz/gr0/mv5HIg7H/AIPZ/FK/2Ofsf6TlvgEfxyr9h7l/ww9y8WMVyZHl/lp4sf1kP/Czw/dZ2DljhP8AvwMc1x+oNnY+zO6KOxR3OL/1P6P2IIjzk8m/IuKz8mu+ETEzKv5C/lzDg1FeRVeXgazz3ev8IbL521/EXCnZaEsQvptcBAYsJ1gV/QJlxKNTUSzOmEDwvHP7o5yYhY4bfuVY9eGaBtMY1/JcDRpfnuK616HO9ndDS6qfdMtKvwLa5fssNfyGw3UvZZ3T9DElabukRtBr4Fo09nYyTOP50OVV9d0I062URnLZ24+5D1Vt/f8AoUZvdr+Rnn7zYvH/ACxJ/uYq832OA/FFX/1cCLaWq2J/37CFwVf9+f0EQijv/B2+452V4fzcD7P5+n91oSp/4q/yNZS+4zxG7lhLj84+Er4ca+xk0MuF50XtA8lDCJzxWOOCZ94v5wwkKmLzQqOxyGpWheXoSZvGs2eG0JK7dy2S/UsyX3/sMtXf4/8AAlw/kZNfxf8AoiPY3/yj/YeUn9m29skl/A0adflf0LHtrIlzU20tvb0W5QDzRvLb/LEvgPg/YL1nlf8AKFlwu/D/AEQ+12NSM7v8BPsLVPgahJNQ18X+o0kn3Mv7O5jyIl28cIVF8ejgDqpc0aLuJQnuO9gqkCVMZaNcDPAuh6UomUpSlxOiE+giDezT04ZT5+RcgbXCa9ibqk15E8Nl9yr4NaP2GSRt5Gj2aO34CFRqvIpGnH4Nwj64QknK0Tpy1sXZMGWOLsFBaXn2LyW21DkRcGQbNEef/g3m9FH3lvks0FFanP2eyJcPgn6fLsIcmvy/9Ecl8mEbT38ihuv1/kX7fiH6uRo0X6L+tsY8/vV/ggCO2V/IhEfgj/gdxeiEVUt4TUkly9AuU6l73Kl+4N+yfka3TYj9MZPpvwglI1rxuaNnkDS49+4lbPnGngR90L1HhnlYV7KfwMe/DwkopOZ+ULuqn8EmrQl2+0NtP1hLT5+DZpfgV5U7JPg47pCY8WXPWxdhJfYXMX86Dr1EDP4MG7E/B2L6NH3B3oUbRCraSN+EO9k9iK2xKxeAXhOMw4ORofQk8UvSCFKX6LXQlaTtQweRvsaOG0/GPbDbNrg0aOXHI3TWjlvbxi6pBqNiWSKa2vcSXcGxl4ENVw/BIthsp/gvkoCl/wCDN6GfyLgaS12RbqDsUi/SQXpJt8QQ5Yvgj234LdmNC4IrGafEEdiYjT2E9efkrxr4IeGLcalpmvInMVbZoevI3tSK+BiXBb7MbamTJyKfAuwDyrF2mPKxo0xDvKcuz3EeEu9QTuwp2FGcLJQmdMLFY7jZ21HbRcBfRo0dzvGLcun5EuIgDSetjJJg/Yx99iEQzQiiuhasUi+gFxcLEFgpSlL1sXwwNo2qRfXwRHAXkIvQRBcdyVJMI2RR2HcTQJU1G2vgUJpeiYH32R7oJuWUvSxzXJ2muLyHUZiUMNSWvDK+bTwKcW3G07T0/wADZ2/gSu38Hr/gS+z8CR2fg9T8CxtLASwS8J6z1Zg9AXgHpHpHqR6ER4RHhEXjEzyUEOxCR2Ep9CkDR3O9HLIcIPuJphZcF9m7YwpMoK2uIsNNs3YkzgR3w08NopY1jXRKXDQ8oTCiCXSX6DNoFzbFoJUSPmwvtYoNSpacqH2gkevyMIbgqNcCI+35LjFVIUH1INE2qeiV7SmMVLyFpClKX6sIfLCEJ7J7J9ekeSHcae409zuCO0DhV9yn9hYDOKaX0fvLCY++Bk+cbdgtA57Qkl+SZUbf2OjhQ3+hw7L5If8AIQ6VnfygyZwVp50VfQqi4hCEEIhCEmb0tcOZzlzgJvAoWmEWky/YQKpD9EA0F8of2DRJkWSO2Ke1HtRQWPXkGe4vKe0957T3ntPee8957Sfc9+H3i85Xv1BA8kSSSQT1UVPSARigaIaj8gjCHaIpw2/hH8Ej+cGcEgfBXwcpPuQ8BB6xHsJXYSMBHjA/QjwNnY5MIeJ4QNak/lDXZB3IPqRXLgdqndUJhYogisCCy6XKLhxkpMaUuLhzggZKwhhcRuWiiNBjQVLY4EoIZ2AzL8Q4h32F5j7DC5iX9xrgu4U91a9Htx8NEW1v4Nv8IpqBv/FhqX9BpuXwOPH4F+PwFkBDj8hwpdmgffAdwgZ5X6n/AN3IPrGL9A291hUCwWsdJQXlREQIrDFAavAx3E7uU7j8h3aI/cMfP5CfLf3P+XmT0HqH4xquyH6GpEhNFRrCDNCSogxMM5BHPIcboeu1ON+wPOU7uwu+HAhByJGyfOKKz0FkqMLldFEUE+SgnehS/eRRZ4BCIY8Qke+WIwkHPoo2N5RsFodERj5NnxY7ZDB65NWionbg0iwzqG0J6HSNuFb4a7HeaeYX3CgleHlcLihcOTaFioa6CphS4aQw+kyQNB0ORhIJCMW82LGDWZRodkO2iux4h3ho7sc8ed413QU+4p9yHguhiyaIfqxVyeeMdyotgpSmxoHtC8lREoJ6IXYmGHZTYlGJniT2LQ/QSjqLPs7LWBMd6DmDUbeDcVQZMuMCUHVhoaiGyOCZzAh9yohiRk6ExMRQog34EOI9OQugCR9GMOsUO4/c+Yi08iBWCsC4grKXCLiYQawaGEeBq+x2Y8B2D8HmnlznhT7i2FvuK8iRsnzgWS2TwbmCL0xLDZDULlg5wVLHWbsSMTE/I5LmOBoItwuKUU6bB4/EkGixivmLQsbSOhr4xQ8No0VIaEdBDFIxI2EwpcDU9ohBY5IxQtFgYRogxZdyRosUmMBI6KiMN2VgnA7wew5GsTCuFMMuC6I8bDEIMMIfbM2+h4XjlORD7nkFoWRYpi3GwoanEEpBZMDwaU5GFtYdHiAtoVCCJrgbDGuTtnLZPE4NYkbIeBdjEJGILrGAtxdDGuWggkNDHPJ7BFe+G3cubYbysc5Y2ONGwwoeNw1lBUxLEk4voVrlYdEhZjcXOijvmEIQaGHgIfYZ9I0N7hvce8V5E92E6wMP1x6E10zkYYToo9CExVnUc1GOQsXkZU0xQE8MEosVp59FY9BPJYJhhM2QxlKKRQX6ONPoU6ORBcFz4DYbYpsloIIXEy0PC8WdJ7Q30ILHcXDGiD6AsSHgE67nvPaLeUGmasiIPMonMExYmo3BFAgfFH2HsaZGImLBixEdjSGSUViKzxFZEaEIhCHAmUaGiEwmIryU6MEFHg+g0bYR1c+ApEhFwi5uXg1hEwYeFBoaLguqK4ZCDFMcy0WmNQ1dz3nvPYe49ok8nzF7nzNGXoC1KNUaGRYrwQZ2yQmxbIbdH36YCQSYpUQSEjg5FiEgiYQmEzOkNMjWLdKm4aJmlKXJR5TEylyXLY3hRpRiMvAYgy9RqXCw1SxUaMGyLRtCZCcTeRnkZ5E/kXsfMYn0cYGPY8CgVdA5JhBIXB0MMvF0wYYTgmLMgmJlEL6IadIpYYGo9xbKIKhG3EJmlKUv0wbyZsEE4y8CKCDLRjQ0TCZpRYpcMaoyhi3jcNREaZWUiz5nzLcWHJxhCGhogwszohBCZHnIEEJwQTFlcCYmMIWJhMj64EJjXGP8nvLYkUUEasNdAaxS9QXJRvBjXSLisyl6IQhCZpczLGhoIFPA5YWrBRc0uUFsg0QeVKUohYTghcNDQ6wSFE8KdFiCQnhCCYmXMzCdbQ1ijUTPZjt0AILKYMTFL9ANcNEGhoaH0ssVyXovXS4YxsbGMexAliGbyvAsVuExPFGEQaw0NDWUIWhY4EKIY0MSjCGGEEImU8ITEylF1vNLh4pROhy+jIIVg1kYaIQYx4pclGMaGGGXgghdF6BRPF6HijY2NjYylGMeXaUtynlBPDw0NEwsIWGcCCeGUEyJQTExMTvQhMTKUQRQQvUxjHobH0XHA1Euk7QXRVoxoYYYaGhrqpczDRMiKUY3hcUQQpS9DGMbGxjY2NjYv0DTOTgTKPY9FHvoQsXoTEQgw+jkIogt4nRcITEEy9LIQaGGhkJ1GLpOvaWEF0NaQaGGGWWGITppetjZcKUTEylKUpS4bGMeGNjHi9ECYhYgnlMuGiQhM0pelC2QmDwTGCFkkQnQIQgiiYiYMToDLLEJkgiExDp+kivBFYriYPAwwyyw1ijxcLljfSmJlL0XFGxsbGxjGNjKXqUEdsrpebh5osrC6YQhMELCFmdEyhFxtkmDyX1IToOBT1IogigsVLiYMMMMINDQ1ilvWpiEIQWaUo2NjYxsbwYbwxSOKmSomc9K+jSiYnlC6HiYToSQhfUomLEwhOhsvMmExBomEF1pAEFmUo8QaEGGhoaGh9SlxCdEw2UpRsbGNjYw2NjD6mmWAmIXpuUIQaLh4TKUQumCRMkwmUL6aEy9EIQmD6UPA0QhCYovoUERFZVLhjQw8DRCZJ0oTETLwxjYwwww2NjY+kO48xqw1ExdZEpBoa6VhYITExCEJEJgxCExRMTFmfRpRPqhCYMQaGhoaIQg1imgiukAo0zUo2N4f0BDL6ZFKUY2NjY2MPBhhhh5t8hPYwYaPwNrA2CYmUue4hnLmDXROhYXYWFguiZeELgTF13CZehPreWMfOIMg0MYxnYowYJhMJhg8QTKXDYswmTQ0QhBrCw3obKNjGMfGDGNjDYw2GFn/9oADAMBAAIAAwAAABAll+21coy0vFeFKEWPhMii5lhFfY4z8ClbECSUP/P+0Rstvt7zeCRg/wCgAglao+Pv/jdFXI4iKfVMije3gyDEoQCz3NfPgh3EAdr/AHRBLTe6w4pCj3m/IIAI/KE8y/l7gldeomCvQs5OO1zyFxwIO/Y3nRi7myWJlfRf+1i2pPzS531FhJaW09zHx2L213ifX8zvZNKkvpCmAPv5hk8a26WP9Eq7NiXqVYtOCFWnv+VITSai+ostAW80fHEORjS2BGNIzO1nS/qZA9IItv7v8L5N39ZvG6WWUoia5pRyts1+1422Xb4otL6S6Ct7ViJOn8FKJYH6JfSOt6StLlo/po5oHIbUmKugGharHXpKQWv4TG26uTZfRwnvQSSeO8HA++YYkBVnyJE4OAY0RgLUUsJaEkX2YV5KpGE8hsM9/C2G/wBumlk9svf3iovSik5WPPJUKRLSlNgN3mpvBrepXAZ7162EcHlqISeJnRuw6NhOUyh/t9vdt9+/u/lrM6wgdtGfYo2SC0G/uL6NFp2vChHWdJsP1DiIgNJovre5sfa4pfWeJ/vv/wDLfb/rt/HTg1Fxt6ogKmDSiPxlkF97NKO5Q+kJkGHR3ZmSyVesbM5r/wDD1jb5tDVkNtyFVL/+Q07GWtcb6P2v47DYgmb02luq1DWMTx3YrlaiHZvGUJDtkLzeJt17YQJQaTaLSTaS23+1PwskeOvpfUldB/pOzjDNdRePsFIOBsmvwuvfC4vlOsxXyuNkvUY9ZKBDZJJFaYIp8O2HlztuhnT39yU+om5ZeZcdpeNuj0hKTc2zoMwkcn+vzcM89hTbCDTRINvLaSYgBCQvmEEU4VDwnzEAK73ymvNjfc91dDcr4MmpJiw0H++oqox9W9kpRYAQBI4jJJEk+2ohBV41omC5Y0tl5BTgADYRZa0WOVkdz0j/AHtKVglpjF8hh1BjdpMWkACCS/tf7XsvXLfoCfxbTK6zotW8XyI/2QAXZ1Q2HIfIpOTBstahCmokErUYKZhPm2ACKJdIqr+0kk0JPYcBBLaBejDxv8HENEqHVyUnmJmXbTmJJGnybGJI8foYD73ML40yQD+dvbeCSSygwElfIwxJLem3JA6F09aDjBDIrBnSU8u8m6cH6O5JxSdQfc7/ADzSzxBgVyzRVNgS0AAACYJl2d6zgSDqG/ggBO4Bv9ZEVzxUgnj5MTXkmA/TF5Q8mL/j3RmsAFmHQREh826+2Sf0+F92Dyq4Q3MknfZxyAtYWZjM0B5G0OMRflzYAEoLs2V1UEcbtFkU3aKonF7bb3/bbe4QLsgwEoDQLlXnKWjZyXRLrpXd+Q2NESFRvQ1EI0hzV1CopMgACHW2EhHaALfP/safaWVBUGaSr/0QKGlNqToYTMZO+Nrj510EAEd2dn81+6RstgI/sAlRShEz6OVNNsFuyS9bWEqfDpMIaeeJjyGR68Ws7bardLbwkQdZ4WeRHe2qkXDQHbNA+D3IKaiOBb0fLNcqy0eIM6LGJTOxIqCb25twaam2injgdmixNzf9EW/SF/oc76e1Mh1aIhPfRNYf6wEjLtV218Qr3M4X4OcSH5JF8GIXm2BbJl9kGQTPt2t0DVwnnReh73Yhuy8qb5xpKy2WW2xDbAS/zE9kis6Ha6cILNX9F0+rl8gg90j9PomgTdvEbVeJqa/ZYzCfkjbAJGm37STa228vE2b7I3ImKx/mSMVXyRfFX/e3Y3BwdN4PhSJRWYg92VR/u+EAeNEbelngSTbLPYSJWSb6qYN1flfhHYbwL6fqAh6RbYpXLwBzntdjm3iOKHqioefPjTSYn3qs0yX900kHLbZSY7p0hFoawllfmuI4VCFjqHJwgMlJl99oixt/4AWOAKNfT4vi1B4+Fgn3NxAAAEthb92wIwluLkV8CV4ye3FzuTIwODXm2GKv/qpczXnlQ8kiEp+oq3gphxYQ6L1AEFtyAAsPDW1g1N2S7BmNQJV7sUXGIpyP2208Jv8A65dyFgRMmljWuGg0EsIa74AmxABRhrJSOtbbfkkYjJe0eQT2nnIW2tSwyu25x2zTeD75plECDYHGxslNntmRmuvammNBRKe0k7aQwLnOK4y+VifReHMHktiyTJUH7TTHbeKsqW4wKFfZu++IKVDC2n7niZVxAYL2tt/8k3wXt59x9u3qUawdm5tIE5w8Wb889+gkjErPsPug+hhRRmdu7Ep/w0wp/CIJU+m2/W0l2tFv+Cj3YvvziMe6P2PauYzdYiLQ4P5ErBj/ABcP1TkW9FrcD9A5WwDtUAavd+tp/k9f/wCLWujL1VB7tNQ+l/jl1WcWwaHGwSEkCuNG0ib6sgGygbMdQna5lSbAg3+fJ/8A+/t7cv4dPQUuzBFm2v8A7ecxt5KnqW+pCq1MQKjwqPjY9V+yqQJoPMvemrvCQ85N3dtt/wD/AP62vqv6n1k0ml/eRJaxIsUFl+9asyia3QtlMwxBj+eATfMpkoe+OUN4hWo26n/zSjC/9SlSJjSMhx4uY3BkH/q80gQPlDX6pS3572I/MJDlWplxCpXg3l1J/kVMP3X2DJaqW02538wqd2LbAlYSx3+nyLMizcNnNikpzfhIMIje3PXg+uBvlun2lHZ5/fPz39D2Tb6IcHnp0AamsOBAfOligfkzLT385JcwfZ1Ud70QVBfeGZu+KLLsz8vl8McqGR/NCxaW/wDzJrdJZDlO8WpSjpF4eI5m2/8AsCh10vytPNXKr6ACFZr8JlLJ5YZyAT1AqY8xlt+AFLk3B/ObXkxT5iFEo1Y+kns92Db/ALvTVZ+8A19aZpJSMwwcizfnYcL6tEMUueM9rBQgS5UT1zbfhxB0lmGx2o6ButARNv8A+5+qMGo8kniDKn8I9Zbazzt4cpPATWTaNgJIRVqMasZZLVliodA07d4x9rPaXxkIFg86eY64xFN94fYletYPYZhVrvFel1oMySUTONCyALjnpOc8/S2torYMi4oaV8ZOtZcjJdtLJ9wvvVeUMgbB2Ii+UYnHSJlLbJsZ1/7VhZDfaU1ovbwT9m2o0U9fTRg30rfZlH0snBVODzOXTu7tNUTq0danbqEVnZW+ywDSJPKDeQ97SUcfQQU8HL/jXzZX9YS5z68U7xE+VqLNTJELGarCAr5ORmjH46y4d/ZPUzFSroNOU2yeefchjkiOUzeLTKU46vNf6NnmWTqpqxA6jU3tWz6TQ/6HxttYbmHDiRsuMhSHmuacbr9SyCv+V9xOFnrlRkM+hDTZShTaD2jTK6uiqNGgqAto9QHW5JgK3Un6/dfngi9U3VaHFYYOtuxrMZR+Ml5D7s/8I2FHEHrfLZI8N5ZRU51m5yBgY/xB6WJDQPBcwQLWP7M3eyfxvCHmqDwGv9vkoWlBZ4u/cQu7DA90RHCrYwJLB7hIG5CMxAXF4/PWSNOZfZjyuwiFY1Tg959Xww9ApkCq09k0A29Fak5XglIiNXTTbHPXgsXXou7a5Kq+Sum5IaafkhlDl/lljaepDTlj3oKcs1LIrTQFyAJrVtcLG4757CSo5LuOu6LTJ33g/BjzfnIU6GRKVIeoiImIjt9i4aNatKcIrvWcGwnlfFSx7KYotwUoN65qWVEnv27Jv26c3Hd0OaHjX8+R7+uKgM4rrorH56ugWH2OPy0DNxj934697+v4MuIqcvBNEbXfvufhikjdvOEOyTWYox27KHd0IsMTrf28qFYjr15tSH2Wgq9Vr4X18sGB9iQuCfhpfPw8qmR0Zz54zD7mlj/LvYOsNQfYMKjdYwxcozk5DSt5LorrLzzebQtierpMKotAwv3Eg5fcn5Ph9o9vUWAer4R+Gc0M28igokq0dIspC2SZOtjozGRdR28JChFzkvDWxoaYXyborUfZ6vw2Uz0n2JiEiTGx2KmB1z7Pp+Ca5Au5LYP+ydXjiibF31KJ4gQzCZUYu8uBMfKhpZstY18lLH7GfCojH3GIyuosKaHNi3LeqlA6rWQN3WFIbm+tOvj6oW0Hp9M3WHKMPi+wO/BlobRDBJUDVKGPnRv7j1JNr6d2T21XIbce4jAInotTE4LWxwAd67XvYohPVxZNFo7GzR4qLnLfBHeyLR/4vET9QPvfSv6A82/j5EcYqz+c4z35sKrpEDqHIXUtH3V3QXnl2BQjDLNLByUcfRtv/wCazsHZz59M7naBN/PX/Nl//wCCVxPeM93iKhmd6WSU3Gsy4CA8oVhSeFgq8HPff/8A+10qmdjtB8/t99rJcroE6fk6mI1xdmjOs2Osa8Nn/hy6Jccz9LYOf196G959Ufls3vyRagdgu2wpbxkgqQUtvkkKw9/28KlZuBU497cXWa5GeQ8fwTMDwlnQQluhyt//APbTpKvd2uGJtFpeYrSNCNNV3P8A79MFqNssanOB+F6NuXz/ABdOqn2Wk3Ixc2utv/8A/AMJKMVFUZy7u0t1Fx3566pCpv5OQjqi8k3nTiqDSBl5EB47FAFevAFDqYDG7LoOP+ENVL6YHYUPN7wxaAqiCjqcuWyJEyE+K6F+mumJ9QFPHUuFJVzpQ6s8XDmE+puiKFeXfojp2AdHeW+a+PZqPVLbafPGiHOrzPXFUZuQTr7+obKeu2ab16Z9hzD2TpTZQ3aYTuPzdaJEwhMWbXXcBN7mrtS5D0GUFl/vqrfgQUp7ZULC51q8IqyF4OBIPiTn/feWQ1TNmTPth4m0y669SycwjSNQtSR6dmjXr0IfWVlPDmNEzwjcLVVLprumH0fHPry2zzb9+FDYjj7c+Tva3OZzNJ7YEaNq8lNJDO1/0cE76JQwt3bQntzUyu4/Y45cY4LFoSbyfT20mzLzywqC1TSR33jeVnljf7QDPiWwDNR/k7chUWK0exbIlJRhs/TySpNTmPHsr7yPsaWoBdqdXKhl+xCRbunvZWmBHS2lbAZiaetG71jRI2Oa8OZdf1hLpwGPQ+RqiFe5mf6NIJeSbbd2wFEgAluAqdf+C3dZ/LQYKZTcAuhMERkabzna24Vz+zavwQzVjdujESfff6Tzcr3YMkITZNNkEpRaNBGVXuvCzoiq2dE6yRaUHyfm3KB+FDzb3lVx4r/eBPQEWxsszmDLbU7sBFWdjBnWIwT94EjELAO10fEcYlO9qfOerARe7o/cbJbyLUhrkNV//G7TJNEOWq+UTMotBXwPAhWy/wBWXXRZFDlLXmLjbW3Mnizpgl6ygT3rWKEn4yHR2lmIyAeNz66ITzcPTBBaVI9YSsssL36Ir1uWQN3mGSAii7DEvDxD2vy690USq+L05ew34iZgkAbpAL1Ls0LCAR6AmKLFA0z9oCD0pOBWX/l1Ov6pcmkKtNDCSlJ+1cW12p9o2/2Ac1oBq3d7Iej6gJTZb7TARTsa33soKAWsj6raQ55bRpUaKEFcaTbdhLVUbaWQIVrfe66qhuhI1YXQ7SNV12LbSaJLYaixswQ03Oy7E98ZbEPaozjkbXfdo41Y7Njy/or5nScJ/aZnq/GQ04gjNHTg0SQQjbBJuf8A7R5PPqEybJRcQRsZjw40onRKJtJmyTvfEkxd299tnGPMxyiEflxiVfN7xwcsoQkiEKrbcQ71B1N4567E7BcJJ7wVAyrzQ5Flv4bd8w++uyl2WtAYKE5A0a+FTpkEq/wk80CpTgYusexxn4Evq1CUNgTmMfrP54cvgn1vMh1svm2gBKyutHxBX18iscSu5t0rC19s2xMZPKJyUzsShpb3AqrYoxp2/wDeznuM9mQ6kfZbZsQIYZHJJTHwQmscu542yQyWL80ppArQpU3RmXD1bPa166Mxlxtc5TJJfy/VAPS+dp7tI3lJY/Vd7c33ouDt6HXP4mbidzVxuzLE5tNj/8QAKREAAwEAAgICAQMFAQEBAAAAAAERECAhMUFRYTBxkfBAgaGx0eHB8f/aAAgBAwEBPxDsHXBuzodnoRBeezyEJ+ijffGp45WiYmec9aSWRuxhxPvDQbo8Mbo8i9C9cmYiYkTFqFqy8Xxv4HzYxjy2NH1n1nlRtdEUxoQfio8gdgoqiDdjY8I7EiYgpfDgv6sds72xhhhMYuRRetmrPDCSgh9DCZR5BEFh458y9iENY3EPZlBruvOFEPA9j9HRlwQUaITJqQsmImIX9O1j4PZdxcV5HS6Q+xoaF4iG+R27L4Ax+RjZcfyJUmeybUFPb/0Nl9e2JBDDDDDYgEiGKSLndiIIpfkaC7ZQLBILtCFjRdNImmJoWvwQbgw3R5at2Ieo3WPez6OwTsUaEsmzVi5LnS8XxfGc2izKCF2zrwH3kb8Yi15xGiDbfkbGxsby4lrEhRdI6F6QvMKpC66GGGExMpXB951dDWz7CYuu3sFCr3qSn3HYJGu0mifQux9mJEMSLFHgUXmoZUYcZCl2WDjdjZ2CdijxImJE5LZixE2f0d4Mh3aMTsR7FojyH1Hb8jcGGxs8jY3dW9hBCdFB9sWoUXx/P+CYwwwwxRyFaQjGsbNjV2JxONxrwLQaRmqeTgQGTQ/sdBvkWVt4Y/uQK5U6OvGuPaRVPCLK5UekujuwgnY0JExLmtWL8D/oHzSpQghFOLnkJ34P1xsbGxjcG6d6hYhBBD6YQ3UF9FQxaExhhhikhFhiBIJCPggkgkSkncakeDrnsIeLCkdT9I7HZY0exrHDyxi6iWLiqICQ3Hse2NHrYIPzkJi2ZOC/Deb5Xg8fFRZB4kdj2Bu/B17E0UYo/TG4N3lSieFzA+b/ANdi67LT2xIohCExDFJjQMHjtYweVBBPyR8kfIl+SPkgWR8nHyp7PtKez7z7B/MNo0w2ZNfQ1Z2USISISCRDQllCQioMdhohOE2ZP6vzr5J2IeKH0kJTsbKJlKNlKN69ZSnY88+BT4q/2X0TLz/P+CRQQsJjab8jRk2exk2D+Y+w+wfzF/J9x9x9x9h934ogIEwmL+RP8n6z9QvsL7i+4vtlKIkC9QneynsaMgWuiuT0erJxmT+uewmCHnBqiExsTy4bKXaUZ4GUo+F6O4I+y/10JBF34fz/AOCEIWGEyjY2xtlZWVlZWUpWUpXwFYv8PBRQnE5YnE4nE4mE3yJhP8if5F8wi9I3XsxZOfj+qYnwmIQQ8xOtsoxOFG8bGUpdb1s8sp0L0Vfihr2PTf13raEIQuhMo1jHzf5LnYk8SFExMTFggsFggiiggwy3xnCc6IpSlKUpSlKX8c2l4J2dQkQ8U6Cl9bSjY2UpSlHjFEFEELC+eh+BHz/Qnl86hCYhaY2MeMb4XaXaXaE+KDMCGIXBCEITLlKXiuK4r+rp5ycIJ3tbhT6KURSjY3pPKUbGxsXnKii6O7+8Uah9ixJahCFpjGPXr5XYIorA/wAEOPA1rtCDwNePFqYtWJ4sQn+XxwnCjKX8FKUvC8plx5cXsTLdpFLiEMbGN6mPvDdGyiHcLk10foToSuE89fz/AKe9QhCFjGxlxj168jEwiisj3hHrhr8rPJfrG6dEGE+sl5ELULViF+SfkuNlKUpfzThMXYp4nZjdYvFspQveXWNjYn2IKJ1l4qxjG99i1XxDWIQsYxjxsb5SiKzQTPA3HvKO/CU6R0qHdEPLs7nciIMu7CELZiFiFi5TEvw3DdK8peVG2+8VirjeFPPIvR4jexvZSlEzzkBv4KNiKNjDYxYQ8M3C/T9xEfp8eZ9vghCEIoxjxjHl4UKDuiR6Ia/KPMZPhhoPa2NlKhei0yKxERPrKCxImzguC1fhpXxoylKUpSly8KxMJH+RexesNxzwxC1sbKLEMbGNjeEyUTo/UveKSeyCiExYhCxjHxfBCYKQF4awmvpHkGdg1G89jYwjDEMkztdBCWTEtRMWT8dK+FLlL+Sl4JtfjJleBukhOCYmUQ2NjZRaxsY2XKiCHhFIvhIZRH+fyk9D1YhCxjGMfNZ7tHQmVtMuL7HRr5EHjG+DshYTJvKE1IuPKR5CE4LJ+Rst28GUo2UpfgpSicKUpVxuptC+QneaHmdh7wEUbGKJCEMYow3pe8IdEX/kxKhX+v5/zYQQhCFjGMY8ePUyBEuqIqhGiEIPsYfkmH9CYP8AhCy7pZQEthCbMWznR95S5RuFKy/nv4CbQhVwS70fstbZRMTKe8fYkJCKMeG4exKvJd/phYv6AP8AaMfFCELGMY+L4OMdYPIuGpjIsMg0NfAxOhIyHcUc+nlIIhMhNmTmxvbrfFSlKXlSlLwuLt1OCaerZOJsXSxMTEyl6xYkNjYxRqLyKIdAgpOPl/6H2feRFfsa2ExCFjeGPHyh0GIh0gto7BITKY0friHhKUHqYsugYwFk/FOFG/gmPGxhu7fwUuUvKl1MT26vR4tn8CixdCZSiYnrYw2NlPIUUrDRF1T0v945JexQS+BjXFYsbGMePnByTTPCNSReEg6NUaH9jQxrsfW/tPAqOPTxhNmzhOLfe243BvbtKUpSlKUpcpSl43UxPimeWWOoiiE8pRsTE8XKl6LpBNvRQC7cIh4/j/5jGhrksbHj4PijpioRIoeoSHkbjJ3BExB9jd47vMvgyUsmQmIhOTY9bG7xo2XaUpSlKUqLxTxVt4FlFonDuvJFUJlHA8CYg8FKWiYogmYhA38Hc6Vv0P6t1/P2xoYycELGx4/wrDan0lJ9iDGhjGhBNFpaZJ0zopuYNRk2EIQhCa3M8Dxsby62PKXjS/gJid4UTu3E5xCcG4qOvsrQ/NZNj+hMTpcU248hOhRCCDfeFMjyHtjWNDWTVreMevjRDCYxJi0EqOgxujPoaGtYH8CexswhRbvhUIQhODxuDePGxvHxbLlxuF+ClKUpSl43FupwvFMQxR+oNDR2hYUJwmKiY2N42LvKcEjwIfR+sj64Q8ZCcFrxsevkhMYYqp4BShDseeSDQk2+Q/IigndJsp6U1Hk4Pi3j1vm2N62X8t41iep3bngIXg7so0Qg+ihFcIMUp2O2EFPEaDbg9R+T4r8/z/OMeMaIQXFvi9fBMTGGLhYWQrTH8jZYNjF4+g12eoKNUIgnspkudxvgxrtx42N3bybKUpSovKl2lxPU9Ex+SUmyjDRYJlqon3jFEomVjwxqovk+Pk/n+2PHrXCCRBsfF4+NExMbUGoHor8FGywcbjG8N8KDoQh8BRqCHj4t3G8bhcePGxvLxpfw0rKVF4XgpRO6TExHjPZNlGGLBsLCgnRBOGYeJjiWseb9v5/sePhOCxvKXXjx5cTExhiLmNRCVBu+S+hsb9jjDDZRsusiMzthMfB42XW4N3g8Y3ybLxu38CZVt1OCZRO4QhakQaxohCoxM7R061lPHho3Qwfo/U6n7kYMeNDWzk3lLrx8kxhiwr0LV1k2UbGOg2N6yjdj8D8ncCIeHjKXG4N8H5x48bHl1j438NLxpdup3CE8upzYQhB0MvA+h+0+i6RBD7Gqj2/5/sXSg0PH+CDG/wAD5ITExyonL5Fo2MbvDH5GMbGM2TRImeV0NafeXHWsb4x48Y3xY9f9BeCYnzLqbxCxEIQhCanUaCca5W9FmMleifz/AOYx6xohNSx8qP8AEYck6WUYt7LMdQ38HRhjFH0Nj8UY+hkmUQ+B404Nxc3i74H+ClKXblKXgtXBRxohMQhMTE8QuEINE09Ozt7HJCHlahqzoPuJz+f4HjGiZBkIJcLzfe3ihsOREKsWEqofQbGxsYfk8j+RvrUHRBWoQfnHjV6xu48bxvrXt5Uo3eV41ieLt1OC0sJiE8RRMTReEueDsdRd5dV2RGezsMY+l0RX89kIQaIQhCbCMu0vK5dWIYYgyaMWosmdkKMdGx0fToxjxodYVH2IPH1jxvLjeNwbvBuDfKl4UbKUpS4vGo8jKJ4mLvCeE+K1CepzKhod0Vz6EKaITgYz5LT+fsR09IYsooorFcRrb+VYhhhywQKog6UfoeBlGN94/Ou8KJMTqmGNwb1+MeN43B9vHrevg3lKUf4U4Jl2wqergLilKJ3E8WJ0QxFG4N0aoghBnRj6RDdiv0C7/n7HiPkgx30vH6Mb18bwWLgYbsgdQLUWXZYGNj+xj0x4ZCC6H2h6uW7YN43xb269o3ypcvKid1PU8UTEyidxMTKJwTonRCcLRjaJnctidCRKQShuxh/pUn8/Z5ZfD3+Dwf50ISEMMMUFSFUyfTH5H2Nj7HhjG8OLdhI10PDxuZcePxrxvXrZ4xsbvG86XinqepwWKVamLELKXFo4ztkFhLEL3RvsW5vSo1vst/z+9II4R5s9NI0pcpSlKUvJCELDD4UUYlKpopIY0MYzwx6UrVK6Y3wYxua8bg+8evXxf9AmXKe8TE/Wt6xMTExPKtGHOKVEcLEyYPpH6k6Oi/CX/v8AnOhdkJSSBoPh8EDy6+S4IWIWGJEWJ1dDB8Q2Mb7GNjGMfwPyMTaYtBRD7HwuUbuPX3xeUbG+D4twsKUbmE6uC4JixC0hNC7KITE0UUbLBjsF2xSEJidEeRdIMWvw3X+n8okXhRM84xohCCxj4T8ExIhCCEMNhVdi3ovdp+aNjG6Gyj+sedQVRRsevH44vWyl1jfNuFpedExbS7RCYjosWIohMTyjg/lgj8skghdiYtP4G7he9F/z/GzoiKJlpRC0UY+DJpCMjIyMhCEIJCQkIQhIxhtluose4bGN9dDY2X2NjePE52XQwxODcXt5N+vwN/lRLFQnqeQIXCiKXDDkYMZDsQhCeXCdj7DfZQPb/X/7P7FKUTLRCLxmwL8v47qIJCQgw1OJWQpUYylG+xsYxjPOPcqecMeNUamPGh4yHZSjY3r1jfBvEFLUdcG4RZOb3UxcKy4nD5FwzR84gSEIWJiYmeBeqfp0T7+L+/eJlohOYnx8CHiQkIKxVyUnLG+HA8qaDcZQQWD9DdC0uhpob7GMaGvkh44JlEMMetj4vHjHj1jZSlG9r5UuPQ5E8yrGxC7KhcXZ4ylLRso2UzXQjvEJ6mIXQfgWFexCQniFixYnwmMQQ7hTEYEOhUSDUZmLRTDCuKFuQUYZDHmLs+kZbjcpDXZRRWLGbFRF42PjR9jWMY8fBkOh08Yu3jBIap4kBsWZ4cE2eJROCeUuXEhBdC7EIb6ExOcHYbnWUX6T+f8AwSIi6mLFwXF59BNFimMkpCEqI8CUStiYLehtExTyQ1irwRoUdsQhBuhGKKoiQi9jXwMS4mIhphoanCmKPh4xxjZ0VFG0RnyLpXopUi7S77xcfAfo7BqxuheC97RDZfZ5PthsTogksQhCeITmo8xusbuFX/T+f4xCEIQlcWrFrFlDUMHlgwVGz0GFfY2diF4G0hq+xIvYmw8s9HUSvyJF0xR5SnTOw0IEeuZIIJGpCEhIWNTwUbz3neNkxvopsTiYpWVx98OTpYXsTy9FOingeJJnYNUI8MQl2iid4WHfkXYggmI86pSiKJiOvZRCNvSO7e8QhCPAsWpbSlLiw7joGolilDUwgmPkFLoQh5N70MTf7C6TFmI2JMglBiFhwxNiQjxCYbMaEIQhCDQ1CDWOHgeLGiCRCDo2xsbG2emKFBOiRBcvAXo8ho4PUJi74FCw8FLgl2dCYhMrE6NixPEJ06hvof19sWlCaExIhEkF8gkI+SPkgSDQgggnhMwfClMxjyQaH0O8DjGN6GNPI0S6OleISISEDw0aYglHe/UfQ8yDoQaw0NZS8AbG8NlKPwIP5YgyDQ0OBoaGnR5QzwvGQSIQhGL0MaO8m+zqJ0Qu8JlExqlQUCFq8YmfYhPLl7KeQulB4j9xRKhERPsp4H5hSHJUJ16KhJ8n6AXmHEXkhEbuj7wSIITJJggOXsQ8nYVjQlCIaeRp8DQ6DV5/NDWYH8RloxhiTEGiIaQ1hj8DeKK8XaNobpCaPQw+4wyywwpw+AmJCXIO+MCAgn6E1con8luUTKUTTEiHvE6JzE7ifo/QTp3Y32PWkL3LCqGsYl9nkJjYux19DSMb1b2Jow/NQiiG2qjR+hw9tBHadiR1RBOILPIpHZRFBbQrOx2OxRNsQliMMNCIiGGxsY22NsdKMuNjHo9ePJxpSlxrIQNDQgakiBdFKJiQWofZY7BIJQWkzzi1OC7yiaL8Ys8i8ieplh82VeRbYncQ06D06jtg4aa8408fZH+BIJBJCIRCSEgxtlYqITZQmx2JsVKZQkQoi4aQYaDEoYbGxsbGx5NeMRDDDfZ4titGw1CajsjJjw8uUo8FqQQRYbueBMooE9TExMWJlExOCYmLJiQto8FOlFOxEhT3D2FSI00QhMhCEyb2KBhRmGRMQQSZGNCHYQwzwdBlhhlohiODeqGNpHYYYYbjFItEhJhhlhqNBoNEMsMOCEx4/scGPhTvi8TYsW5QmUbKP0WCxZ+vCidyibxFFsT8YTSUy9QSAheT3i7CT7HrtYiMWyINc5hlsURnZ2JMSYkxJiTO4ioRIThBI/gPYNsdHaRkZGRjQzuTKVlKN0qR5e9DQSNRoNBoSNRoNWNRlijaKiBhs+A3BjRHjsVjuODoSiXwSkIQaInZ22SjwXQmVifoURRcUJiF0ITExMTpRZROuDXqJEuhzKMbgRaPNCIcu8K1eVi1DYjJwtFRqhA0GhB+gX1EUW6EY6CYTiYTCcbFZCEGGGxRWj1NQo2MUbZWV4yibD10POyx0Nh+pRVPPY2zs/UnHz1rb9YUO/LJ0WFbL2JlFUeRM8ZPgZMonexMogmLExMTP1LBPSYmIrExVJ7huhOhX5EwS+GJ5M9eRB2jx4t+B4wc+hhmihYQJG0yJiQliUSiQaFtdEzsFlRRTossr4KLyblllFHbHcYSkNWjY8PoUYZcOilR9sNlHA4LR7WWnrPHKw8dixClOvREP4Hayo+yXyJC4Kjz0SeRVicKsUExMosTEJidxMTvjE5oSnoQm14Eo6DymJv6GgXmCV+Sg0YhnwDwuw0JFjdEaLBMhBZEV90UEEolECwpaEjVDQaD+A0Qamtw8dqPVSjY2HjHyabIR5fQ9TI9ZRvG0N4Yn6Oi9ETGoPoTLkQ0ysrOx6PJ9Zfg+TH8SJSd9brKJ4n8CcEgmITLiYmNxHVPsadHk7SExM84m14Ej5RKy+YbvB18MSUTLyJpiYSWWCdiGQaIQWJlyEIIQh9njG/jWG9fY/ONCVDfOPSXYlSDQ0th0P61vG9bHxTKNZBpDS8jOmPoJE6EJjjOg+sTgkqIvKF7iSRRHkbQlVTwLz2dehddjfR41jwVEhYhMT+SoQmxNFExOCZ4nySNL4IBr4GsQuC7K0xMhq84JGJwoyLLCwuiB9kIJYhCZCEITi2jpj6GtuPLlEIQ/wAYgj0LW/jbh8WVlLw+ijR4wmn2PrFTobKUVemR+SdCREjodAulOqf2EhAn9wheGJr5IfQp4FCgvNZ8L2UlSj7Eh3HGR1OzoXSFgR5ExUkxBy7EMdI9F2XZNPYTgi95MrQkCYX3P1CXCITETGIEhMiITE9iIh68oxujY3i3gyVIY2Psh48lKNjd1jdy54H9Yx7cvBnjwL1Y+lC69iI6xa8FCnsh0yBZ1aE3wNvgXfUILyNTwY321ejz7Oir2JryX6EK/gX0x2dZ2+qT7P0GKBqkdB8g+BCdafYJtr0wZ7ZCEgkRfI1+/IydIThYkYuyQSZBhrh65QZdjbLGIUxw6GyKHWJdiRM6IdouOmzZRi0bGNDDyGwvkQxssx550PpDZRvWUbx63j64Mb/A0Jx1Dr7CdEdmN30SlPQh2hp/IvYUfCk/I0IIRERfGUr+RNuesfmaEn8i69lLD1lmEyicKVlYmysrFfnmm0Ix8ksqZEMNPgkQhCCRwS+SEJCUe31kNVDDfUJBD8CgysbeKmdDINYhB9kHSjZ0Rj6EYt7CUWUb7GXhRu43jcG+so+Dp9BRXAQiCCIi+DpZ2V74y8GXPQujryQTKxwr9svZ5xdj6OpsITFCInCiKUWraJhK/IkYoxq9aEmQhGNtIxjfYviJ2htUO1rGuqNWrRpC/AjzoaGystPHePYIFKUq8D1og0NHaKQ66O0xuZ54t/ZV8kfI0IGj9FRfrHlY6fqeSoq+SH7yP4f7H2v2Z9z9j7n7H2P8f9F/Iv8ApT/0j6v8n1r9/wDw/R/n9j7kfSP4F/6X7/x/9P4Q/gh8rf4/4fe/5/Yj7/ye/wD+n8K/+jQu/wDbPYAzeEQklzYmLE+CFwWrU8nBZWhOJjsJSCoqE0dD7xsZIIkP9DtrpjWuhgY14E010UqpX6OhA3RjcH9H2PPgauEoS+xeIx63TyQaxxjTC+rGryJ9M/jIfx/6L+v8o+Zf5K/H7iZ8fz+x9yI+hXtT+cPnP3f8H3v8f8Pkb9xq8v8AyR//AEn8fuRfSI+EifhL9v8Awfxr9j6f8HwoifWfUNPg+iPoPrG30W8H8oW//C/yX7CTfJ7uz9TL5j+NLe0NXlD58P4B19sLro8vFwSPefR4IFwns8l1cFqYmXhZwpSsWayvv2H02sBV7Q4VCVVISU77I7BF7GvWP6E70xpov2N3so18FT6G4J1djjTE0dtv7jEkllbfct7jb7/uML8fflkITs68ns9A+cozvPSEfAz4bIvz+5Pv/k/V/wAn0Mt6ZT0y/ov5R9BcWRf2fOxXCb2yXvJfcTfhn04pBJ8D6BHwhfpGvpCX2JP5HfLZ38ij9nXyNeww1+WJ+yj9Dyj1/gRRvDrE3yJv3hBdl6h5JzWTjchS48urEnRKqPwQIeBXfI0vAZSiEPs6dDO3ZYxixw8dFUbg3TrwyteBCbwPWa/2N/G3Xx8IU+tI/QvydZBpMalhg69jBqIEU+jH50P6CV5F0BE8DR6ED/VyflgvXpP4ij7Q/UxsSMM/Qb+yymNiilDbfkceWNV7G3obPY2fn8lKeARa8jEngmwhRMrE7zXYuSWvbqfBt6E0fZPEessQ1danZ2NtDYn6PqN+y+y06G+xFjg4ifQ3t18ApIh+eJddBMVL+y3hk/JJA/gNsbDKhi/Ax/TIeGNveO3TYggheynlnwMj2P1Mp7Jez52fYNfsfyn2DmR8PNXKpUxr7Y0DT0P0HtBtv8b6KdsXhIb8i3lnpIQukOshCc6xMuXV0xd9l4JxhNfHyoxGvA3HY0eBie0Nl6PYvsaF0OQpCfQ4K7RNWxp9D1vV24eRdjd4Y3+z3mONn2kfZ9gl+z7T7hv9n3j+Q+4p7PuPuH84/lPvPtPvHgX85X8lssorKysr5pkiv8dIPoX4PCIY8ntM8Ej9DsXjsaDojWUvCE5Jv3wom0J08eC5eCE/BClGJ4Y0UfY0eR/A+jwKONHwDRLsSwOOxvaMQ2Jv0UUVg3ajx8GPh3y8lLjY3sRCfnpUX1p2xeIj0h7TEfIl+EfDj630pdDUNkvA3RGil1ERCEEuSxOCdFqYnBO61wE1jLGIUYxc8jqY2eRjQ3BhTpFsobFFDEx95Rtnevi7sKIyiiiMoooossssojKIyMjzsgkyMjGn6ysIl6xO9Cb6E/yfKxJ8sj6F4iEq8IgiEjoiGUuIpSnTGjGrEjDZDURlKXE8hCEJwScVhK6QhMmKMY8JiXwMbxsZRjEo0I8sDQhjXLqxYospjwm2xYq9j+wl+Rr8j+YSfJD2fYfcfefeX95JG79B9AzbvQ8H7Rg+AGhaiW+vgEj0JPo6esWWUWXlu40QdQ2ylYmNOnZSsTZS+MUpCR2IYy2RaI0S8VPZEREQkQU4IQhhMSpBoNTgy+svvKJ/I4MY3Rsp9hvGh9DY2djpCjHwNHgaHj+tYx9j6UIdo6CC+RRPHWvIXiyYsY0niMmJidLyuIaMYYeE2IgkPwJCGTaOETzDEMcDZFFkmdHQkshETHjExMhFBLE0NJkIQY+h95S5esY+sfFie+N71+SY9QfY+yZBjx8E8JswUpS6+uM5TGsISePwXgIZ0fXDNEIQhDwW9id1EIJcEsaDodjdDZbJUWEQ9C7YjKZCoiUVbNlGPKLGNcD61jEIS+MbKU9k49iC77GQaQ1RwNDRGNEyD0R7YpTzjRCDXOZGQmQgvkQYaJl1CELIMMwhCDRMgsWTsSPR0UXZ2y0EMZsvDP03W9QJEEhMIX0MeMm3uDaaGMYyj4NDQx94xl4t9lG6IaGqMhBqDRIQa9njINHa8C7HlMUuURFn0dD4Qgg8IQSoliWPyN+t86hYJ0WQeLD+GIQhDwJbT0UXxqeTgHi9skkeuGi8YJQmNnnWNlLHh4+i9a6ilxwauPxxueB98Hj1j6GeC9cGMhCldE+xO+BZeU1Cx74KJi4IQSITgmJC5dg0SEITHTEIQSO9RBbCJ8B2/AgSIQbylGy0Y8qE6WDHr+No3ceMeedfBkGhrWIeBkI9axkGhoYmJiZS9FLwmpzSlylngpR/kT4XKXIQRMNEKKGoTqiRPkmwgkJXkC+KLCcby43ENj4XHjGQqQ96x8LCn66xi14yDRCY+j9R69mWCwT/AKJZOa2UQkITncT4QhCEJxQhIRCEWKyYYX4Ax7Jngp5168T+B8H2Me3XyeM8a0NDQ+lkGiYwl8kJBYnlKeOcREQhCZ54QohRR9iZCDXUGiFEITPAk12LnCE4JHjKUp54QhMJEKN4+xvvHjYxjL1lQ3wf4P1H9cm+8fB8LjGeMhMhBonJZSid5oWUXZCTIQguaGJnkmIQg0QnNHnjBLsmLE9TKJkRBJiyFxjfCw8nWPLBu5crLyeN4zwdlL0Xh4LrLrx8JxaGicbtLwRTrELIQhBIhOaEJ8uiIn411zSmLFqzoXZBijKXKPG8bG4Pgyi43X8jPY+8T6Oso8e0bvOI8cJx8ZOVOilK99lKJ5cfYxcJrJixMpUeeC4rhLxmpcFlEutQusTKPW8e3Gy6xv4182NcGsY3WN551/hlPY1NhOUGoPhClKIvHsT7Eyly/mpSlFxnBdC4+cuLFiV4IpSiZS3bryjdHr17BcKWkHj8Z1wuvz+J9jJwnNohB7MontKdcL85Sl2/kpRM975ybdWonfC55cqXrLjZcu3k+Vx5ZyY/rGPg2efwM+xsv9BCZB5S5eylKJlTKUp9ngTKUTn4CkJyTE7+CixPFqxd5cbKUuXLw8DxvGN4x4y/PCseMYxjHy9j4XXSn2X8c2ZCDJMvGwvGlLlKLKUuWCdQsXKLjcpS5eK27YJ5SlRSlomXKWl263vrWPKXob+N74Pjcf4b3Bhr3/SzfOespRMu0pS5cuIRBcLtF+BPaJlE5qLeNLlxSlKWlLTxyuvfJdfkvC7dfXJ5eL7/AC+sT6PXF+eD5NEgnilKXKUvOwszzk287wuUTE7lYsW0rKXKy3KJ5YWlKeS+xsuXW4XqbfwXaW8Hry42W+B/mn9B9Z484tTKhcKJ4iiZdTxaVMvo6PA6eCl28LiFiLlLiKXLnsbLyZeHrLjGPwMfG4/OMfBnsfjhBj9ngfQu2Nfn9URP6GcF5z3l1O9beNPRSsTpT3woxZ7PZ4xYsQsQ8W//xAAqEQADAAICAgIABwEBAQEBAAAAAREQISAxQVEwYUBxgZGhsfDB4dFQ8f/aAAgBAgEBPxB0jEXY3WJo6ZmLBdDGGHmzQdsQ0DUQxMPbYuxsarQtYutJJCEIjRY+RbLXYkTyWdTvj3EPg8NiHxpvhCcWiYXPovyolCUMY4azsWal3jY+RjarxHUhLexCDKpR+ghDcQ3Bvs3f2F0Q0hUv+Br/AI/3EGhjWIihdDYg6CTmLo0IbDHzYjqJM84ro6YNhmpNEJRpwQ9gmxdYeQkehd0QkhNiaNGa4LF+WnXFjWFwNehZnwz4JZ70Dr4NpCg5vtY99ohfEEIJCWHldUTG4IZ3v0v7EoeoLf23/wAKZeHP2EEGh42NFo9j2xiXRU6PqHsNy2xg2SHoOHjcbiceBkxMx8GIIxHglSG47k3o3CJqJoa4rrFE6d3jISnkFiF2P7wXQ8XFw8N5fGE4tUhODQ1wnCcZxaZZtsmINwShzRad5v0NCg1RBIgkJecXeOxFihWuxs7PdiFdK/s//jGdN5EEGMeFoVH9Db0RXQ/riZe/QnovqQXRXod6GTor0fQN50X6G+hsHaUIh0GDvWLZMY2hoRju4Po8sqHdDkLQQR7QxUl2QSZ2E0LIubL894zM4Qa4Tg1wbBsTDYjsPWQTt1idEGGiCRBBLDHiNEaehrwdBTmj68Ehnknn+/kaEGIMawoZNjaFBSGTQZUEoWc7A1gm4eaeoWEwUJxpJRbvRV4fST7RMyacIskdSrZQfWmh1Kbg7oW3BShIxsV0XDy2N8H8NzCMaNlLxhOD1l8JhjYQagtBUiiFtqyEoGhoaGNUSEhIWhjomxISJo0GPzy//RNCVpCxfCr/AN+beRiDQyFBh3tjDSdkvFqJZK7NCSR3sdeRQzZnXYkQkFHQzQoW2OipsujZGiwXUUhBCdnwnLN4foGhbQvYShZE+hNjDiw/npR8bmBqcHmDWYNTm2FCUpG+httmKFENhryIINEIQgkQgwlvBIY47TtP2K/sQp3loRB41/3gEGh4rsR2tm2SkY08FhU87kphIJCZWUoorwbQ4haE1YJFojoUs8NFQ68jVlGNJiBFSYhjViYrg+b5snCExSlOyc4dZa+DhFs6EhIg0NZHsaEsJCPrEEqINDaHJaKfa/6E8l5+kf8A8/6Wzy6NDwYg8IpuDUYrGscEYtMI+F/ssbllFDZeCy/RZfkpEfk2jZWUJxFs2JCQlBZfO/gpnsmYUuIJ4afF9YeBNMJCDWh5GMhCEJwSomuDaOxKNCafr+xdF7+i/wB+o8Hg1g1hLCSEjQkhJMSCQSECTPOEEE54yrjliR43iaEkEDwsPISEoIWLzv4RkzMtUanC4ay8H2aiSCQkIJhoMg0QmIQmEEsbo49U+dYpXRKlHs/7/wCoY+hjwTKlE8dnQhYWEXCz1lbIJERENBBDIYwwxCYMVxB8GUEEXm8TDzCZmITMIQhONLwmZmXDxmxKoacAQUY8wZKQh0QRcXGohL1sUcu8tCmq9Il/v0g+8MYgxomEIQkyCwiCQkQhCEJSEEipDRDnAwXnhuMaGMazB5aIQhP/AMGE4TLKPZHl4eRISKwSGhB4saIJEEIQZKJC6HoYceuH7DiCXHVP/fwfdTwxoY+JCEIWOxCEsogkQ0NENcLcYSsDZsTothoMYxjXwz8bOcJmZmKNeszN9iRubETBMjRCCRCEzBIo2sWhKpJH72JaLB6i/wB+w+sMYxjXAhZQhcFmEQNUMtx/cSsDdm2N0OlWMbLy4bR4vB5fxX8BMTnCD4zLXF8qU2ahJ4aw9cEIQSINEGQSolBoeDzHuhIXoR7Qe/79kPDH2PI8IQhYSEIWEIcERlliEegbs2E48IR7PHjY97FdEimHi4eevw0IJE4T45wmZmcHilsmhai0GyQ1ig0MQglohCEJsSEhj5WvzK/sLolp5JZHS/8An/ODGMaGQWEIQhImEIbg5Y37jDZ9FbE7PCEB0SEiDR2EqJbmuEVG83LxeF4P4oQXKYhCQhMIya5Tg9YmXhnTBNlEEhITgmQhCEGEoMgkMorGrCw/SghBenf23/w/M6ftoo8MYxjIdCEISEsIQiYuMU0hOxx9EexC1BIIWtFmew2tnXFjLaeJh/G38sILlCE+OIi+A16LwmbENzZBcD7DQ1gkJEjwxoguh5UEFrezYhXRv8f0Vvb74MY0MfeELCEIQuC6EOp7CyEwiWKNoWFhqNo2VDibziuaUvG/JMTkifgZhJxaxcvGCixUS4polGoQSINEH9CWTwjBqMmoSsvP2/8AH+TL4yxjw8kIQhLCysJVkoMg4ONwbExMaMTKJiYwxqx6tG8YG8DZcX4nzhOS5IhMTExMMhOHZMvDV4PBCCRISg1rCsGJE4CDWErh4aYPo3exiaFrF+jV/wB+twncMY8PJYIQha4LKiikHDWdBxiFgmfmIQsDaEPQdYbyMW8bzvNYXBcoT54TMGsTDVGpwJGxCQkJwkrIJCRINE0NDQtqQaHwQaC/oL+xIsHlob9TX+/Wiwnl4Y+sIQhC4oRTdCiaLHhSMQj8hDFrwQuhaHKqN2zRxkBYXFL8y4TjCfhJmYaGsNUmD2LTdRITG949hiWyYRCDQ8RCRDHgyY0GC19v9CFV1I3/AL+Rrn8sYsXgx4QhCFhZWGNncU1ZJldM0F8HSExMu9YJi6FgxtgfQTxJ1D/GwhCYhCcITEJxmYMaw1lYpQ2Q1EUOIMTBISg1oYY1iPCxuMgtmwyamUYtzvT/AH74XRS8WM0ISyhHjEwsHk2GjOkbDoTgtMZNiGwWCESZuGzQYb4L8K5zhOEIQhCE+GcJmYaIdMJrGpikKxCbJBBLBoahsNUkGjYawaQ6r0aPsxYhdlN7e/8Av/cJ5THhjHhKZQuSGIPsQUSEiiGYlhHWxsiwTE6X0LqMOIvC/AuK4TKxCEzCEJyhOT5TDwSrLNIZhKC3kJsUDIJE0MY0SDWCRDwTVKsZKj1sSgxL5aRor0iWU+THiaEIQhCwuCCCEDzC08T0T8Hg6EJ4VgtiGNUyNRRBhO/gVwnwL4JiExMzjMzDQxoTZuGiQU9mj5CDIjcbITGfY0IQazvRMKbErP0Eqbb43/v4Pt18LwbnBLCEIQhCFiDQgghRCQkWWxbRCQQj0yLCYi+mHrgXlMoXBLmkL8JCEGpmZesPC1iSsdjQ3BP5ImyxE9JWLUQyeDQkNmPBHqRTF7e9iVZq/hRf79i4XCl5EISwhCFicBBBDyC08DxiMWHY6C3ktDEjR3UawhviXFc0hL5p8UITi8IJs0CZcQQdQn8mhD6Eo3axOCEzMTY1BXR23FiF2T2z3/fsjzhD4sY8QSEiYQhYnBoQQQshI9EDwYlwR0S3h4wmEIqhssGo4LguaXNcFwXJLwNQRIJQhCCRBonKExOKzRoi0pcoIIR0JCl7xtI+DpVkg0SkKbsEtCOOEOnw/wB/WFjxyY8ISIQSwhC5IJ4GihIaOHSOkJCCQkIJCEqQWCJGjQ6UfEXwLKJ8MJ8jxHxmZhiRoY6KUTLlogweIaOhIlEslpEMFoSEFrEs+hH5yX9j8xv65UuXlIRCEyhcJlBoQSiR03muISpBJiQmhCQtHnCEVVO9jUexa+FZnNc4IhPmmITg8s7CC+cvFhclyxOPCxNBb2btH0jUKBGzubjJsRM/AdfY1Bix0Pi8JCRCEIJCELE4NCQQUl0ND7yQ+hKCWxCQjwLCEJVDvQxsJ3gsLgua5pYnxz4WuDw+zsUlkx5bKUpcMsHp7IiWzhg1MSggUQ+1PP8AfyecPCeZh4eEhLExCZSJxYmCFNCRkHjQhC0IQhCNZqqiqpRhcFxXKCyspC1zhCc5zavBjEJuiREy8sZ0IJlo3nVmwqCWJLSxRgmhImxPqlX+v+eEMmLweHhIRCYmULj1hoQ0F84IPZvOgQSwkIJZSwh1o7yLJBcliZnCZWUIn4OcphjIdR0QhMNc6UTFiySIjVvTFCizRGokKE+X+/6XZ41ilQ3mlRcUpScIQhMQnB4eRKhBppm0g0Q94ghZQsIRNSqLPhQuMylwX4Z7w+DQxiPNxeGiDwxDEJCQkNdFxtdDV2Kc0O9glsTD8JSg3l4XJS8BGLUkRMwglhcHh4YlQg0KKSNxVQgsLKJlCVHjZN0m8TKWVxS5QnGfHB/A1xeF2SExMNXDGhohDRibEEhKHQw2GI0NxjEIR596/wB+43svwgNYPiJE4Tg8PXEglEOnhd2QUSEhCQhLCwsVRRYRoWEspcYJcl8cOh8oQnKEz0JTYSRCEwxrDGNYSwQSJoaDDFxZeE236G966xh8FYvAvG+OhLEITkx4Y8iwQkzzlNY8C1lC1lYSndSO1hLCyuK5rh2TnPinOPJMCWiEw0QY0QaIIorAkHg9GHikY9kGN55aQmPwmL5ASSNBhh5CEwgkQhCEITDy8mIILTNJOEghYQhfWULFVSqZZlYSvFZmVyXGfMycWh4THBog0QaINYlEhYEEhpgbGNDxRMP5l/v4K79lZS4MUVhRRtg2KxfG1hwcHhjwggghJkciEIQsrLVUIUjsScEuCFyQvwEIQhOcyxkLM0YQiGGhogyYITEOkUG4DRoigJXBOU+FB6YNEGhohBqEJhoaNiJlE5tlGMbKXGw1ggtM68gLQhCEhZWZqWRRlLisrjRYXxznCcXybCSJiDQ0NDQqEEhCEG9FBvIxoY0oatsor9bG/UdN2Y0QaGhoaINEGsNDWE0QSSJCMjEYNCBsbwNjZthRBoQSM8JRE+xLCWFiYWXvH5xIxC4rheK5LjMTCGsomZRYHA8sSvF1wgyEEEsEhaGxsYxjVJhlggsI8V8Ig0NDQ0QaxBoaINDWRsMKxTw744RdYMUsExMYxiDxlx/JIISz3icaIgLhoU8nXK4QuFLm4XBLiubEsnSLMPC74jzCEITDY2MYx4ZBnQiH5mdKBomDQ1hohBoYxjw2MxseC+YCwbiFHAysdGweybxZwgxOi5LjuLKjExonBcpilzc3CixSl41lFnsUVw1CYZNiVklnshMJj6wxhvDGxjGhoaw9foS2dD0ixhBjVGhoaJhjQxjwtlBoSIbwWCwUrgKJjQ1wNibExijEKERIhpvEnJUXFLhSiply44bohCE4BCMoog18C4wWNsGnmlOwuhJCS0d8MQmxIswSJCcGPsex6GMeIQaGjRDUElPotXt/0NWY0QaGNDGsQYxjHhhY3Y5IeMJCeBIaIQkEJkDroaxCfVGr8QTehgbob4vNex00JlQVKgm+ApSlKUaTECcTShMJ2JsiKzp8ArCskIQhETCOwqVFgy57mxqE0d2MbyFmCw8seGPYyExsaINFhkEib4QwPLGPDwxkHh5UhBUWiDGt4uEhbDRChMy5RWH1C2TSgT4CfAtKKDGUoLmmRXP++hCsGNlB2IUEISlEQJRKJg8UUP5T77UwnBYjZCEE3h1Op2HhMkEiHQ+x4eGNwY8wmWhognsNCVftj9feWNjHh4Y8MaGiCkgiHSJkmdxlExPyJ3FHkveJ0ixvFV77IDyErsUx7Ehs0siMLBMggkSkIarZI8A1Y1I4JDRYoESQaNQoNipiwkJEyLIYQehjZWdkIQguzuPo2O3AdFvC+xvxxYxjWIMZCDQx6qIavWr+41j0NMbFMaNhuRopjZFFDTKGw2ZWKiCCGjdgaGWCCC2KKuy2xKNo6ZRVMUWw3D68H2jHk8oa7Q37EPiUiQkhH5hrjCH9sGvsf2GGw2Zb84PsF7sFixQplZQmxNiYhFKJiFExMTy8GPE5dyBBFUMMdhOMkIIYlZfQ37PPJoZMtExBoZSZP9im8DUZB/Qs3KY3Q7wdGxJsOe0NDpZonA+AKFmMtR+pZGitCZEOxSX7NfIo6Y1+RiuG2wqXkfuIeSsOxZZZXgbDcbjKxN7wpiVyT4EUwuAJlExMQXIA3Ch88ppLosN0TFKE4xCkXsU7EpJBSnY0QZBkJjyNRj3sRb+FR7olsSgzwJ2JsSW0JqjbIJo0NJ2wzqxsIULsZTwXEisKUKfRYhlUGQ2xYstEaNlaExWDCCbYnBQWMGMbs2KJiYvgUFQqFCrIkEhUVFKXEITKeLmsrEGxh1keGho2MbY2E49DB6DN4SIhBkxClZaEwmRYgkEwSM1wmYR7xtvehjtBKUYvIX5E2NFTIha6Nir0VFTw2ssY5jAzGY2EbCQiww4JIcwY2XBt3QlrYiMSEhMxs0NQ1DYrCiYS4QSEwm1w+tipWILIvFFBCjwSLGigniDITkZ5hqLQmJjIM4QhCExMnwV0kJkJxKJgmeS0eKIGsReYmjGLsShMsKVlyJhMvwduiZLQoKXY6G8Jjgw2x5SIUFZQtgeuOhJJIhEhxhBNkQgsFEx0u8TaZSlKyigRRRYbbN4TFvCYmJlKUubiFDOxDNTXCQR5KdjDbsaIQhMGlg8wapM1iT0xd4SeGFSmzj7Eq0NRm0hMKCRiddYUjLRCwmUbKUogkxoiNIbGQ2hqSIvXC2ZSiaRAkSJRAp5PYGTg0Gpvg6KjIQhCEIJDTIRkYkyExCCwIJCTYmZRReFFm1igmKLFm0IJCcJFPZLGjwMRLwRjD2ijIY72NkNkTLTJiEIQaHk1cNGrr8DdQ2bF9h1Q1gy0YFUJsEGHtEjIyUpuUJhMUJiveLLDMiaeHQYaDE4JlFCeCf2WUUUrKwairhJ+PDUZ4aIggpikY/JYJCCUk1FEKEQ6NOiigkmP1GmmWCGjYY2ioaTKDRjfA1DInQilZAaMiNkNYQmYQg9jUw0NCp/pEtCoexGjZTTGjGW5tFEyEE/sTiPIkJEyZuFcI8URBl+w2XGjRSlKUWHXh7KEYoqadkkogkgiNLGicJhCEWEwhOESg/orNMV7CaDoaYVCRlo76GyE4JROjRjGwirNM3G66FV3gTpDeEtnoG4eYR7EvsQn1D0CQw1iYY0MeH2L94pYKCbw0hluOopC9ysS+huMRorQmRBIhDcwqYmaGh8FAowstlFFFCbfCEyuCR2Q+hspRRjfJE4plKXioPYRNaGmjoVEyH9MSQ9BIJtCCaY16DUVoTCRmmNX0NXgrCZdiRsrCR9FIVx4wQKOh7o64bNA9h22Ol9YBsuxkINDQyUaeohK7jsaGeCmmNGP1GxWWblYhBiCiZ0whMuy/GhkFrCEiCysLCY+zqNie4bockR6KJ5hCC4TguKY1GnCOxoxKQikR7EBqdRH5FoToQ+zxxsuhshVDUNdjQeobLwJhIj2INI7GQe9HYReBOoz0bR6dgsIgI8Bdkd8lWmJX1hMRmDmaQUieHiaJvFw0Y/QbonFUo2WieFiovK4mYTCFmb5IkygsLHiYceFyWV8PWU5gVv2OKiv0ImtoaXIThBPT6ELhBAm6ISe8ZDpSloJMa9IXkaHFJjTpiHSZTQX0I3gTIhOmQ/I4l2NPaD9Ukoa0xecY1CXqxg+jXsbQ4b8CcKLgr9GiEpROhl5In0RD7EFuGqxo8ZbGlBqxluiM0XGylKXE5vgsplysLihuhfOGIdjdGiEITCEswnFcJwQmTqHr0j20adG2O6ynQjzhVp2M03Qkuvsd4heVo17QaO0GxdGQGh7qNHkNPQauhr0p6EH9QwNxt5Dbuu/yVvDEn8CYJ+6IMYRMbrtP3X/ANGv+f8Ahv5f8iCTT/s/KXJUR7X/AH6jlW8JpFAPzP2I0hwYkEM9GJWipjU6NlYh2Mkw+stjecEiYQX2IYncSkIQ6xMJnsmLwhMUTxoubxaJ+JUQ1HuKelH4kHubEYku1Sez+S/hn1Cd6F70JxI+5ntbIeP5IeP7Iho6S/YUl3L9CrpjQft9jps/U8oSoR3jU7EgSsBjxj+ga+ht3D6CL0a9YaPOGhoasYbjYaayTTGhqDHhsbKLoZPIk8NDWDQjoqKUomKDS8EylMzCXC52KHiDVLE+GEwkLK4RsTvAndMJo+kX0jb6PchHn+AvK2fcxGkifgT0k/Yi9CfgrGMeZjyby9kbFFLQefM8ERLofoYsxxbMrMIQhCDROQ1hhoz0DZYoeBujaGx4E0VDSEEUbRHTE70NNFZdFuZkaJeiexN0O0KJiJicUWCYm0xCLMGiEE3gTekJ/TCcF6hN7gvO0L2C8wXkbI+xI8CR4CR0gkdJfsXzBspSCD7iHlH3n3n2H2H3CZieKn0T6H9T8hXoXqLCngjeBP8AH8CnX0ONLbF3HEIXbEkuvhSithBO8XwY8NUY2UceWQYasfqNhuMsI4MIyDQkxCEwtqhu2yCT8oSc36Py4SCpBP2hOaQWvA4tWAQDbshYRBohGQSEvWCHsOGw0ManqMvYh0keEv4J+MDX0bislE7oetF+kW6X8FfH8Ef3+wnez88XkZ938lfP8i9ovIz70X5C+4vIPtPtPuEnBHwIDd3BpO4VPRLyj7kehn2/wR1f4GvY/DWPQkd2h0i+OobQtGGmi5LxeHloYeCYmGswMuxoPHHnETJC+8Q2zrsT0JwiZPEXQz6Y1dEViDbbwIaF6BXBA84bu1hISOvXErpTUGv6Ek9mw57LuEO09C2JD9Ua7E29CZ4R9CPpX8Gvr+CHr+CflfwfS/gafA+uNHAoH0UafJ+aOWtjI4FvpI+lD8UH9Y/Yht2G1axG15Hqd/qN+mZ7GJnQ0b0v5Gg08I/KLbo8AJ9I7wKb2OmXO5pAyy4EhYaMYYazcly8XLyxMQa9DUy1BneehNMSGJ+8QSuhpp6NtFdDdoTa2daPSi2GUmOOinvQSu+xUsLPJCxBd0qxi7RVmgiQqY2J0ICZdf8AwTJjV5Zb/wAY/RsaBs/BtisS6EjwbBCT0dxnpZE8A0nkPGR6ZbtjT7pPc+4l7CY+5vioIpCJdooJELwDwY99i3eOXgfBSmg0QlExs9DQlRKQJFijSGGhSI+NKUpRv4WiIaGhqEy3os7KU7E/AlGPoaNmkkEjQlFskxvDNlKJumRuVp7I2Eo2kEuU8XZR7F2oZumjZiH0FEfohCDQkRCE9HakXgSPyYfkNPAmfgteC3gfgR7EfUX8H0l/GIvSJ/YXgFx6JTwL1DQrBsnGhrpHgDtBfbzxJXSOsXPRcdFSGiEISP2DvBXbKYl2JtCkQXBqijJehhhhojN8a8UrKKylKXDZRjRBWXEFU5hIQtpkhGw1UeMTglVKQYaEhsaRQednnYlnsSmHJUMdfkb2JsSDMYaXQ2+D6yvg+nnVOvUL0i9J9Z9YvSfST8C9IvUfQfUfWfSfSfQfUfQR6IvQkicG52CX4ErpEnw1EIaBKG+Bgz0PyheY30doaRoYJGJHhm/BQs1IQXhBhiEINEJxo3l8G8QaGUTpCEKzs6Z9oTYxuM2L6ZJsSbYTsxYWVRDTRn1n05dIdQsUTwuOhPjCEITMIT8BUVEIaCUNRj7Ro/Ij2NPY18jKSRQUhFtR0YdNoe41L2IQSsqNYryorOjCobREQg0TMQ0QeaUvAjohjkah2JVEJSQlJBCdxJGSYtvY6EcBJAkIIEhBBBBBBBJJAkIIyEuNR9hPKgJJJ5ABBJJ9hIYNXkah+4avI0eR+4fsGo2Z9w2eKyxsPuPuL9lCcsovF5QU7KC7yJ0MbeHipdisgqL8YgUfFjxSjy2VDjoRWGiQTgncQSxoJCcwKYk9BzsJ/gafDPziDVE/yxn8j/ZEepHoNJBI0ICUN14FehaDRyjJ+RkvIlIH6iPQn4GnwfRh+kfNjEkIFgl8Vk0O2eRs8jf5LeR1+SYQhCEzCEIT4oJHWEiNfkXYRv0xprorQpzUpSlF55IKilGxqlDWW86whpLYmLDVGvOCVIJYhBIWsC8gkQ7GjRS0YkPAzctGwXBaJtFRF2Ie0JEJliPAxDLRCZmIQnO4pcKUuITlMQglRIoZojJifDCwTdGMXeBWJhBWVisykYQQUuCdHdBBwbDdERopS4Lse1lCEpIJi2IkIQkIdCgssJ8JiIvA7LBBCgxoQ1DplEJnY8JRTFPogNkMQmIQhCfDOC4QhBI3IxcFCKcQgaYGhrE53jSBOhMhFIJWbFFyBQXAJD7E3vAU6FjGxseCCO8J5TDQTwuEENMK7LntEGJiDYmUgOhhBu4KcMDrBQOhso0eFwWWURkIQnxXEKKwlSsKJ+ycWaE4mQ2xYe46HxXJvFmLjvFE3gTLCilwobF5kwjDBBMhzLlGx8xOmhqjQsGwhZaEoJ4UELLy8kx1hSlGyiZCvFhCjeCYmNIRg2GGGphv5IENECRA1gylZRWUUUUpcUpeF4wes0hYXjShOJs6V4J0IKRFYjLDZTWJwQniUcCohhYhOLeBsJ5eSZS5UpSCxSlwxi7EIpoiGgoQTN5vKIYSSbDQYeKXDxeF4UpSlzcMaxSlLilKUuExMTERMg0bKykaccuCxDofYhaEQlGEJifgWilHwRASMTLijGJzDYYuKUTEx4uKJ3EELFLgz04Wx0TiuVKUpSl/DXO8tXCExS4XAgggjJBUPDRCZpRdYRNEOsITw14JBYomJ87gomuD1ijZS8Ux4pcUTWE8UpS4aPAy5GoT8O/gfyPMxB4X2UuKUvlhCC4hFkWE895UFso9kFwoncvCZcExYNEGuE4obGylELi4UtKUomXDSeRZhOVxRspfiZSmvhnF5pTQ8wnwtlEwikEylFgpRfAhMosTCxBFKUeKJiYmIdjGmQZOEJ5w1l6LBBMTKUrG6NiCZS8Ggyyy4zCfBef388zPjnwQmbCvOKFpSkJiUawiUghYuYJcdkITKYmIKYg1h5WN4hBqkzSl5UomUuIRDLLLca4TnS5p18VLxnxT5YThsrR9iokIQ6OyCEPihPjPOGLEw2J4TExM7GhonBTjCE4JlKUvCcFwdDLLLYa5Tih/PS8ZiYhCD+eEIQhBrMJnrE5UWadj4NDEJiYhCeHiHWViEGIhBrhSl4p3Cz3waTGWWX6jE50pcU7/AAc4se/kmfGJiEGiUnwaFhYQi47zDrh0LCKUonmEGufWYNExRPFzRMTxSlzcMdDDLdDgaITg4uxFHvo30VlKUpfwj+OZmYJEGiaJymJzTLjoWKLhBZpcJnYuDcLhMpS0TKMSGspwpSlwtCFLClLwTxKMOuxqMsQhBohCQ6zSlxS/P18kFiCWyTExCDQ0QmJ8qey46EPvEFzWaJ8GPiuT9rNK8KiwTLilZdlwpaUpcSjDQ6GXBMQmYQeUXN49fKycULCxCEIQSIMg1cPMKL4liYaJxXK4onhvDxcXgs9kx5HilKWcAnwpS5uWhsMMuRiEITE84nClzcIvGcoQhBEIJCQsTjBK4tEGhonBM3nRPKL8dwiiYmUuZwuEylLh7w+FxcJlE2JlLlMpSiy0MMNMWWoTEGiE+ClL8sITEEsTMzBBIg0MMMNDQ0ThPgRRdcHh/Cs0uE8z4euUJxuKUJ3CfClKWlzDYaHWhluTEIQhDZsguNLmnn4ITKxBIpkhETYkJXhBoeGRP4lhCFm58Zfwp4WFwfH6GMfB8lxomJ3HWVlMWUMaHrDSGkPDXHzmi4o8Y8fN24wRT//EACgQAQACAgEEAgEFAQEBAAAAAAEAESExQRBRYXGBkaEgscHR8OHxMP/aAAgBAQABPxBACx/aCGjDlJSea6AVg7ygqM33gaJ24gEEanzYuaOOLitK9R2nnxLnxOe4fCpTMUKl0Y/EeNzF8xRbfzMMswzOWHiOkxZORy7xLG9y+itfUxPpmV+IdcSlt03iJcNH5RE7xcZ4Vxv/AHaLVXgwy5as51cqicZ+oQEJV4K05uAmLFNjBExZKLQGIHvMBO8zuKviX/BHjFiLEe0WosYWNiXH4Rbi1FixY9TUVsToxYQ8Qwwq+83+g/QSpUIMINwh00hDcVVHNhSFtJGpMYjcrUmo7t3CakdhO9CokfMRYIlJxdlEHHlGyA8XEIKKL7Sy8x534g32irS68TPkzDSqrxK2VrtGwfnxFAzTllbsleKz/EFCxgSjXFNJ+P1DKY/5H7Mtl2ZeTPOSV7SyOGkUrKhweI0ZY55+oxN/cLV8aqMpWuCoSB94hVyfEIK/iPMDEMNt4+Zq41BVOehEwI7NzYIa2d+I1HiJsW33huiviVomaBqVYLxMin9oUriualA4g42269sypxf/AJMCbuymIwcv4j3uzjBDlm+bOe8IIZ+YSjS1nue4dL9E3L7YlVnOxz4IkHOZnGNhPQw4mJHF0Cy6ixR6C3GGMdxbixjqL0XqnQ3NyoMOpxCEIb1B/QMGbh0NwemC2EcMzo2Q1SYWW4DSVQAywTLGVB5n7kEcFfJqZ2x9IXBXxEU5+I498zBb8xbzxcE78zYvUoCn6gwlcShFZNVGTDTAE0O2BxJR72/8mI1KjiOfg/JHYwafGJdc8cpXo3xVv9R1n1CbGezNcYitw9sYBzzc2FG69wgWhXfEMwMO4qxuY3uIW5eYx3ieV3HYnCj25lwwl6rwHLCncSBc1DLKzmd/mXqHFSxyN8zeCnzEYh4wwo2YOYjK8XxMbCAe7rEIBvzuKndY1KRVZ5hXHl/Eeza4zLJq1y+4MpzZggU855jdzdxGvEwr8VAUFGmnMupz+8u9yZvxGo8XGC262VK/R1gqIsWMLFo6C3F6LF3jFixYx3Fl309QzKuVmV0O8H9A30vMHHUz0INwYMJcINS9zKbmIAZb28QkKJctQTZiKsSvMHAL9xNR8uI6QduJUK+yYN16irhtjQc/UuxL7BshXfEoe5gAy+paG8RCbszeIYhu4EBoFLwxa0OpamwV/H8MWboIYMo7sL+wB8S4t2zdN8sJgmd7Ry5l4SHmCcu7MrvJplNTXtG7dbj4jCXnk2sHgC63CHcrh7QTbf7y9XV1LwiWJCXMU1zqWD+cqFuPdlQcfMOhV9tRjzhrr+08tinVKKjrGi9Jf4ivqRyNjX++YSr03HpfKxl7BYd9wgMbPqIDhKmxgqWDvcMbFWzcuSs+5kyPFdnPiOMV+0HBn1KYHFV+ZRQt/v8AiZh/cMlCntOIVFmnxBHDcBSPMs7WJafUsq+kWKRbij9ItxYvQsWL0LFixY4jiL04lneGeldPnrVzJDpfU31NwMf3DHQb6aS1m7MCrZWFG5kGC3CDYSvMAcb9xFUDysU2G95Tjk/eBZz3hRq4xZcc9sw/8loLuCL2l2Grr6mi38xZF1L6uiyIPJ4iMgq4aiEUTi4Z6hUO9Z/NxjyDdlgfVpGGCgeDmD0Nk7kys7cpjBRXx+08pNYIe2ZicltMU7Iv3ArRTVzwznECVSBmVSzfeBKW3UzrokA0bKqFWSs+GU2UQBRv3zDj93+4GaavUYMWxz5l3MBbw8wKWE1NzRfErtmiINrkzmW0WXWZTXYNmohWjz5jQvXBdupz7iyWBv6ZVat9uY6OS5upQUbvbAvUxzLbNne5kZvywGqPAbg5dnIsEwqMHNQhfXL/ALtDmiEFhau0F2vmUQWzq4BG2cVtmddjcwxgM9AxcXoLFixYuYxaijGPR63R0I4Oh0CJMyodQ+egQh0MwHub/QPSKYEoWCOTEFwLhytahdM9RNd+UmjJ2sXINRWjNd4iD53Mfa9XBnzMTm8SsrcUx37xtY1KjfxKaUtvMyefMJN1CDdjmOpq4yjC5O0sMsPirZFcGGOMsBo5Mi/Y/MqgClT4/wDYcugaz0rSbI5ylyPiGpaPEMUxrFlQZWoypvfzFp+UXha4if038zBR5NkGo+Esbe7jUspehrUDSN+9VOa1+tQVFY84jI0vk1L7Ia0wDAc/EDT7pLeeW9+oMKy3rEPr31Dt3s3iCKVrtUcK16yQO3CGYu9jVkt7RCYDqKGgKMSiDhKQFRqvEwYIrZCqxqt1KzTu+SBAJjVMCSrXZmGoe2YGF58QgtmTUuIWLYbuO+ZVMLy+Jdt1faaxB7Tyn4iodCyFiowtxYxYsuLFixYvVMR1GPqcQ6fE3L+Zd9CDX6Lh+YdR6GYNQ/RT0YrY8XNMzyyzs9g5hcJeVzG2hPdD71m9y8pwsC+MylO9QfdalD/2Fz3+0UO6xOwS2xahe+YULxA9yAo/aUUVb7mc3NXXyRXRzUpSSkbTMy6wC+7j+YaK8RyFKquVn6B9INPgQd5hgmDHQyMf9qBrD4J/40Jx+p/ysBwvtUKsGnuQX38QHNfqLx+MBiROKS1G3xF/+Isb+WIteHOpTv8ACKN/tlLb+E0X+EdH7YscfYVHucvBHly9Yni/Vz/gkTGsHsVKX9IuFsjybgRw+tzg7ADh8kVaqviXi/Q0ztz43CE1NrtLOjejGJZOPMJWz8cf64ZFRMeE0mfeoKzumIcaiq/jHArntcQ+cqziFrMcyuphKSURUuLFiy5cWXFqLHcWX0WPQf04ldK6GZcP0X+ggw+peJfWpZl8N2NIRaDn/ZII3dm1zctzkzp7wz8PmJW6XhlnLmO6ax2ioO8xL3U725RXeKvtAcckS6tq4pktPaUTG/M29cytHW03LGkt3HoM50EaymqeZWWlDMuzQ75A/tfqIsNS6zISNu36D6nmhcO4eJZcGdTFddL0iq4dqls/jOcIbh9Quw+prj8QP/E7P0cJIXEmXnKuM8eKzw6yzhHsonFPqKcH1HtoprD1PHHDgj2InhHsJ4FQ/wDxH/ZHD+sLN/jLV/jE8/jCT+kvZH1E8PqW9PqXbpXqeea7TyT6jD+sIJ+2AcMeJYxK8SiAUSkO0oqadV6FxYsWLmLcWXLl9Xpcqc//ADzfiHUL6V99Rgwb6XmODKIrzcTbeJl28Jnm/wCpgS6Kmers73BqHGYlDnx0ngJfbuIMTajHM3mIpFHGPEU87iTTjiX8RcOWaFYhumd2TKyw6YYeYyCrLki8JEriUXcCvv8A9IuLUAfUIAJUJ2lYX7jqCyDKQQYgmzEVfMArEKtdUCB2IVQgBlPEBAckpKdp4I6Kngh2Jg1PBCrU8EB2ge0p2iO0R2lL1E8kpaj2IDiPaj2p2HVC7FOxRl4R/GL4/U16/U40UcdNrlUJfmXiLLiy4sVFlxbiy76al11uXmWfo5ho/SZ5mo9RqDCGYnS/uXXQOI6UypKc0tt2QrrZnvTDdY7zQvVFyyjdz48zY8Q21WO8WvxKGyF3deI0/qazOZ1O3g4jhxFQz8zcXTF48yie2ApRfCMcw3KSgF6/1yytAUHYJhSQPg2+ynzAADCoHz/MdTSYsMFeYJmZ6ReWKYMUyi6D8QnGYQ6ZlfcMdDHEMZgXBcqEAlB0RlRiE/WgIb1DIwe0Dogup4IZlEqZXUCiODxGF7Rbi0RYstjLlRO0ei4jHpuXXW/0B+m6g3AlRIldCkGXKms/osZkdpWCVC1b9DG7d4vmVYHKxLjhj1f3LqFq4S54nsiw8TCug5MMnkiquPEXGtxviCt1cSoiqYDd1jVzVTxcvXnMNDWRqKwD8l0fl/EOcSzWsVtwPr8kVgaoIx1BDB0juV4gagguoSCBmBBAh8IbgYlSpUroIIICURMVmGCcwB3NOeirbjBE3NoIx/ESJiCugjOIjtGaykoTXS4sWmLOIxdGoz8RixYvV9zi4SqnGpz+i+OhElSpTBag9alY/iag3Lmyal9K3Ma3KhXODg14/wASlV5O8dbF+M/7mHoHeVHbiW0cx2EW2ytyovUG7lcxajXmZc4ZhzB41CjUsdvcyb18xEOiKB5xucdZMxgRzjzMWCDPbb/EDeagKngA0D6Wnl5xGJ0jpHMG8RV9WkHQThNIVfQhqEIHQRqIIJD7wWAckDbMeY4jgAfMpSH5hVL1cuDMqeemnQwTsgjE5iQRJUeldV+oxcxzHXRbjFivU/dF+p6l/o4m+idTpzDodLmyJjpWINQbf0YMtz0IkuUKXkst95XGDL+P5lArHyRtnc4F1eYsAV4DiWB2mnbM47x2JxL/ABDODdDgiFy4hlng4mXl7XC9uNwbvxNN3XEF5zE5ILIsYqJd4gtjECRnhRFSKWvlx+AjkiyaoCJmer2bX5afmcRj0aQQQRM7hgucIIQJlBKhDUCoQolQ3mINTywe8qlN2wVRK8y5KfcqACZQTxcrtqu8Ss1kUBkc03r/AGpbV7CCA4ZYEuyLFFXUY9C5jEP/ACOr6alUS4uJcXoxZdMWotEWLLxFxFi1qXUu5dyuof8AwGG5c4/+Jhg9eJgTAymRKXERFsPo5g1TUAK+Mwig1f4jXvUS1UwDzFM8yx3Dp5zA3EtxiYatjPUD/wBlBidl7jLpnguoysD5lBnDBQWMrvKRgKHYKhci6lXaUPymJbAp/vARXox3BDDDHcp0moKgeYECEIENQiYHlDLz0W//AGaQIk2qCNEPcQQA3mHulnmdtgA2wBNKwkly0HhVMhlbykexQFvBLH3DmFsmsTOS43FFqLF6GMdzKMWcoxnMWMYy5dxYxY8xYvS3vGLiX9w6eYzcJXTXUl30upd9LlwlS2EvqQ+ulxxkjOgLUE/Md3bX9sqF2qW5a1G9PEC0+Z7HmcLNQw3BaLm39zREu8TFqZ6zcNPEB07xhlKVl5V+blrvc483DAx8Rp/KbXzqV6W6r4z+5G87YChQqtKYT3ZgYemfx/HVP0EQ5iLmc18zOG4EC4GIIFwgRaJWPEo0y1ajzOJY7sHcflMCXtjMgOLQs2OE/wB5moDQpjVp9xCNtqr3bggWhTu4Dro1GRqryG4uJBDAEBNfMIl7DncAA7l20yItRYsXvLixcy4vnoW4xZcYsdxeixaixYovS+nz0O/QanEuXXTfTHMvpuepeJvoPbpZOdzv0qa31JrMuJ1B0leiVtRqfs/vHBZhwu/ie/ELdjczRvBgoa9xYLn3KMYIW9TAePMpec1wRa3j7h6eYVwPqIVlRau78xzcS0bZzN97lbLp8SihxH8IAMLWq5jbYpfbn9vzFVMsE2NyC/5fSCngaPjELS5cUenSCnpgdIOgyQMVUCGYEIRUSoajFy8YHlAmkrzAIR3VLmNB0dcH8ypJ4ocTOC20/wB2jNcdoUrd89rmTWDiWF4TFHM7NnuYqmuGMjaX+8zUkENQrraHMJy1E1ytFFmXcWLGF56GjFuMuKxe8ei1Fi56PiLFi3GFj35mZi4tQYQm/wBB+sxNv6bg29CNyrgV0GpuJBHMyfKUqOwqw4P8n1MBb3b7xHATcvR5jaYvtBSf1LFHYqFDsQRqOPk8SjBb8wO/MADEt8uIq7ypBxebgcLGKTHqAobyvEvvutxkWblysvqMJ3RWe8OzdjvWA/Z+5QMteYZq0V5ifNH5l5a55gwY7izGZFRQ+JjriB8wEIahmHQuHENDLD+IjQljmOuNENRELTuKKZs3Z+IUWtDV0xbWZ2OZlXo+4g/jcyAwGiv9/iUB7h43/rgwG4sgt4/3qG1briNvM5KludQpeP8AMO0XBDYWMvCYIuYu4xcWLcctxbYxYwuIuJcWO4vT5iy4sXMYxal0S2GoEOlwZfS+lwl9Llw5/WNcy5eoMZXVcpWwalPonxrMpNodM1n+qlTV4TMVEzv8wcG8zFLP7jpHtpjoxuNAFX4h8AE2qcDUwKhlXPch5J3tjvKymb7Fm2/xFQfeYly1Gsy41+I1dpK7zE1Xp5rP5l9suUBwV8pApLoh0GMfhgyyXiZjuadHKXlf6oZodIgx/wBhAQhOIQ2S0fMGwm8S89hxErlL0ZloFieJnSm/JKled3G+ar1LxRZsySvZxEH33zKW4TOPcu0p34gG0MdmX+U4uPsFItlCYMda5Db4J4jXKJaShi9GRGkV6LLiy4suXFixixYuIvPRZc2S8effTMvxMsLqEq/MWXUan7xel1FgouYMGWg9NfovE1qC+oPRzHEqU1kzMz/PoR0tAe5dH4qdpnkhOGXvGKYvxcoCqz2m1VqIUvE2PuFMk+oXsONGY8nmKruTw17hUv8AaCpEBev3hZ4+ILOaJUi5dwAWphT8xbvcFu9l2oy/gZyMs605uB/ZfEZIpNHmv/YoMLFy4sehdOO0H6FpAh0CEGbJYMG9QUe5MIcOKmU1KrxMJ+QgFnI1KI/njEryIY3LgMj2lpTtmXbV/P7Stbr3cADXr3Dd4B6l1+7FTlj4h7C2LaxAtX1VsAhwnRKiWeI05lsvovNxhhWLFubPPReixhcRh9y8xtLmpzLz1xB6c9dTL1OpAl/cuHQLS/0sKeYPxLl9IzZaIB8JSi8A8ygylvdnHeSGuT/sa12jrdF6pi9IZjBXfF5jbBmXPglXO3xLMnzA/wDcpIw5gEbie3mNS3HuXJe+AgfRKVefuCp+bhvF5zy0f3MDGIqgADHJd/8AO4vY1P8AvazFFBmkv9ApcEH6IQ6BKhDEFnR9GghQyMNPXK6iOOfYI9HkL5jZbTBrsinkpLywqCxdTWBk2O4TeVa4JYqsKzUFDGTgYW1VjuwVq/jctbzjvqLoCB/mEUqVz4mxzXeZquWBFcvEWLLjCy4sWOOL6L0WLFiy8S8S+m+hllW9KlSpX4lSpUeh11+nicTiXLuDcIP6BhFz4lKzKXZYQewS+L/5ESgw7iUecxnD/MNU7MbhpJp4Y9OYCxbqAcsSl3ncyt1T6hQrtGswEVHZ0SgHaCN9u/MWSs8yvffeJoaimW7L3GXVWKjGsGOYV/a/Af2v1FjxD5MOtZL5MxWilke4ooMvqz7QZlEHiCGZs4QOh+Y9COuhdKXWyWuJUJe2eJdwVktg4U/FxUr78+I3zV2cENHAN9oLlWSZ7fflluQO0IMlXlN1AV4eUlFrn7mYeC6JUGh1TKuFVX2zOIlHMIlwzTmUouYvxGFuLFmR0XUHE0lxYw9AuYsY0x6GuhuVCVKlSsSpUS4nVmv1Y6cTUHEuDCr8y5fS6l8S9npBLVfEHLSxP96YjS3mrh2fb/vxDQ36xNRXn1CXD+NQcZxHLmAUFV5In0TgueYr4bmXiliCue7Ky+OIkq8MDWSDntAQXNdpXJdcSstzKZL7Tm9oqG8UBHly/vBUFxraBweWC4AJ8lY/f8QariL5jhAy4sWLMmW9vxBD+jENQITUJkR10jllDDEAraYcdeNR1ZqVLFNcf7/yWmTDNPaaIjXZnFqvepUNihzeLnYfMQGrWBKLJeeYacH7V6lBriM2hQ/Ma2qFDkxDzzC3NhqRrJYM3FuXGLixpFjA9LjDFoosWKLbL/SEOhmB0JxnqsWWRbItS/PQx+kZfUbl9OYsdRIMrxK88BpwFfiWSFf03+blIjezvDoYt1KAWKN5dzCU0sX6Qvd8Snw1EOV9x1tuvM+WWN1GlLWOW/iG7GJe1iompeYlUmokot8x9ipc02c+FbgrtqE7ZmJMBglQlprujX8zwu2DxV/yQY8RRQeJfRixHrBUHUNdDXUiTBBmbZomZRYPMNfZGcFOXtBHYWC7j20ld0lThwp8TGYA7jiKyuDv84j2ul78fUXQAXvHiAvF4rZLbPmnMo7Z1KGAbmBPzK/rYh3rDxcNNhOiIixZhLjC7lwhhY/CXF+osfqKLL8S/mavqQhCDDv04lxY6j7lxm4zXTsxBl/qvpcu5c44l4lGUuXiYX5nZ4j7hOKtVjLmZOWDi+IKXQ8kzHXGWXYsziCjIPmJJU2DMb4HcRtPe5dQL3zLJc7lxBqtTBn8zDdyxExmJq/iGrPffiLN57Q+7e46bDjvVRcYL8tfySoBjFti29l8CxBVOiD8/wBRQYNxVzC0uXLii4mO3QGoM9QhuV1Cv0XbMlVDcG/EuD9ko1+4tE+QqU5A8/7/AGpk86u7iDyUXzMbd+19SwKvda34hUrOdx3y8kyuQLsr/eIKUfjOYcFZAzcBVUEYMUKHJcsV2hpgshpiOVmXhmLEXMWmW9Fpb0MXLixVl1zGHEZfUnMGEu4MGoeWJfQt6zLl4ix/RzL6kuoNYg3+q8wZcWpWszFnMD8TUQXxr+YVJ6Qm6Gu0WBTHEUZ0Ls7xxE33uZSw7MBiuO8qLfuEFZ7RU0nLBSXi83KQGiA7wyi6Ki+9eZkNUxLqLOOSPLh9QxON4mKLjwQS2nKRBWOXwZ/kiXeyG/tk0bfvfknYV09cfiLEGLJBsg1LgxYseI6l+WDoH3MoEIQ6B0qDExSlemKCTBj+IVkfUQIzf87maBZxxLaPKq+JgZDugVXdmyADVeJUXzi3c7hY1X+8wlzXlgCnTUK23Xj4mV9sLfWuxHNTJlADndcwFlglSF9AWosehcGWS5cuK4ly+JcWPecz8yqh0viXi5cGDCCPaMMXF6eZc313XS5fT+Z7Qi8dNS4dFy1zERla33ltkC2/95ld3aBnhLmH4uKYDEW6UXfEagt4iqs2cQLo5NkAS6cQRFlEwl1c5mx/EsVe3TDl/wCRF5xUsmcniAGPuOg1XeVa7RwF7mpNgwpN6DHeXH+TmvxUsNG3gC38So6lG9pPuFgIoQMVQYMIWLGWQQO8CCoQhuErpU3Kl0tJ3ekCge8BGkLQHUvorLl7SjFUG7F1TT/v+yoGj4TNdK1AyJk/uNttjcmXyYmgWHZzAGXAZIbQOXUIt0RH0FqELmHMd1mjmZguUIsWXUdJc7pcY7jF1GLGd5xGiV+hiy+l9Ay5dkuXL/VcvoS5d9b8wei4svMWLhlb99B8Bl55tolg4q5beJarX1O/AI1Fl4Z7+4ZoUkXPeO3eLMh77RUpV45lNmztczlj5mC2+IY8+5STh3C4CV2hwRoFuvMItrMrRQiIMDVCd4uRDB3ZrHZ9BUPCr+VQT8n4hk5QV3A3/jcWejhlBuKoN/oW8SnaCBcCVmcQhCECV0I4dQvJUonCc3Y0S6QpqxiLa60eSFMPGZhAuMev9/MPNOMpj/VC2WzL7hHBxDbW+ccyw1j1M3CzNRbXtuBRrN8VC5Vl4rEaCWuzxcAKVRqMZhOJfWcQwRRi57S5fxL9y5cvEuXL6VCHQLl1HLGXqanPS5f/AML8/puX0vrx1X0uVOXtSxZ3mFUDvilg0Sy8S3ZMziXFJiJlqVr9JnOTe4wUfcTQu3EpxsLu0lgytvgg5X6ZiN+0AWM94KraMVoee0paauMDnvTHvqMaxLQ5Tv3i34ZL4TP8R1UYBYF5Cs/BfMBMEEx31/EdwYqg2RQhpLiy4vTwQQIEOm0N/oqVM1MfQ+WbsQlBdzA4dQQ06/H7RXll4fMyXIY/37xqXxeypXS8d7j0b+dQlcZ7zeXT7mFd+HMIxSPuKc3nEqML+IuNeCW5rZmPzuDMuO2d5TRe5btGFSMadFy44S/MzW+l9GHQZUqLbKlPSv0czv01+i4Tmc//ADvpcGUzlU3Z4lcNOHzDthvDuDpmGuoLeNzKxuaiN/lL1gimD2mU4IRhcg6jx/wikcJyQFJV2rcfKC3HMAi7zeNQXkXiCGG7cypusRrSjj7jqtI8Sk4UPALcK0IqVphkKtDy2nwnwqejH8R9B0Bnt0GXqX0u5RCKqVK6BUNw+4Ss/oSyUdC8SVyrhukPd47agMKOcGJj+GIi9gd1mPdd4CCrMRhF92Gvm/7marcnaYt0OIaYL7xlp12jssx4mQGsbiWRMH6wywVF4zUuDMqVFXUuDcuXcPcuDPz+gMSty4ypUqcTUvrueJdzXnp26eprpeZfS/0X0uXL+elwcShTlNssdyg3eX/fEo6lO8xqJLnxCtL9kJuDTUNHHxKhxicg44gVgHxOIRBKU1jZiWyaWUEXtgVNxZgggBZvXiFSxXDLBDlzCLUvlhS8X81M2QLFY1+4/cIqtFOWPe7PTY9wK+ZairMr3Y4MUGoOIUYN9Ll/MVS0CDpUqBUCBA5hz+o3NkEzwGV8wr5+YSw/8l4pFVo2xsKrOfM1ii1/vzMa39DAbsxzn3MFm3VbliqVWszPjOMwl7dm5kvNok8nf+ZpvvncTqrw3Km5NWXG9ewlaZlwZjYltS4RcbQfuHaXLldNwnxCK9Ho467l4j110vovmLOJcHXS+lwly5cvHQ6rUrlWPSmFNmEWO0HaIv8AU849/wC8+WE/MG9XCFV8yxbMzYNpcLU32b/EIGlbxOY25y8wNAr1GAyBzfaYBYPBdMAplqhhUzgzd/cu957Hn/XFFWgjMGupN8M/mUTYvPdPxkKoprTnv+fzhiKLvBuDcIoQQsYW+gWQJUqV0VAgQK/ULJgh3LiCgXBQwV6i3cs+Q5DERuQbHUxVVPJLsge9S3N4fklKIFma4m98JRxRUzXqOgVntF13fPuVBvRFa8R8OyC5ROYcwg3NZlyZhURZcGaQzBqG4szB6E8dWVH9PjovQ/QGbm4zc+ZadFy8dOJcupfW4yqNKZWZYbwDFdyrFw9N5h8x+3mOXaHjLoEG8NQkcV6njgtpFCv2QA0yyutVW4AjMaSPl+xxBDh41/tQiTBeHN1LVXF4TUW5EJ4Et+r+mII+AMj2m35K9hC09L8v/WDUIdAYNzUUIuYly8S/MDEDEIkSViVAuBXWmPVhuVTdN0BQXiYRt4i77DXNRwFW4JaoNnmM2+fzCzjJuENU+hhgNAfiIEy1xLV2M+4s3faLQuvcytDm+PcQDKvErXoKEuIawCWoXMDMuly4Ny4M1LqGXcMTcOIQlwly/wBB0Ga6n8S+nnrc4lz3LuXmHiDXQoy9RcwZd+Zcvpey1TtLfzm/Zqo7LyxSqbbqfaXZu5tlQO87jLYDIgBxC7QBFZg1MHqViHBvxHexnsxeZXqGyPKMpTbpxOzjJdtx2VINnYH7L6msQUQR+V+B/EIQ4H4b/dYGA7wMCQJA94HvKQF7ntKd5WV7fmBAlQLgEolSutSom/0GDoXEv1KfWKmxvUcVXBqyoRGR2zWu+JW4zLa8NYxFD+MxFZY/64kCiqaMS673qYP4qLW6j/eMGJejYlEc6OYJuVEsDOJSdL6B3DUvMNQYQIdL6EufPTn9DFxNy5zL7dLJfbpeZqcwZc3L6eZZN6jjpeM7iy5W5hVzMpaFtsxy8RXP8I3feLCY4g+ZZKGflLLhBdoLX8wgYeF58QqLqp5Q0Su0ofExItYVLanXaCN0i1Xn/ZlDLU1TNbmZ4LfyvomD+Y9V2t7ALT3VfLFRWl/Lc8kD3gPcBA9oGBg94DvK1uUOYl2ykGGVAgV0qVj9Bqa6PTaLBcsIJjgVC2X1WlbKYcwlAW3mMJXH4lCF+Nwm8/xLl1ohHqNsyYd3FW+fMcGtvmOS1Fw/6+g0tOQxKGgoc5ILFAYud9fqGDMGlwYMPMvEO0IMDMJxqM46aZ+3TXvo9HyRj14630uM9y5f6LnEuXUsXotepdS5W8w6SW26uKt8RRLSXFSrWKg6CniGHQuIv9cuv1L4gBz9wkdPzASUH3K+m4hAAbmcaVKzBjzO+U6YgUBYO7WPmDSUfYf9A+I7CpbkJA8upVADWVbZP90St1ZiHl+YEgARwzNtypzDPlKEGHKeiV7kelBAldAhFY8yiVcqVDo76CMyhshS5QkRg1Ioe2axCdk9czbccu0Ftq/3j3eb9RbyV45nZZ5lG/3izXMWDiLfxLPOYb/7KD/MAMFPuHi7sgKDRDQzK3if64PaDUNw/EIPECEvofnqMMdEiXuMWa6XUZccS45gzbO0W+hWYNS7g3LlzHRYNx6KhlKJ3niVa+IqrzHnMdanYg9vzKm86gXAjxMVrMQSkmoIg1CLyS6zgRHBxvHMBVlusygwErdQiuzCFJY+Jqhzi4ZrgW1v/wAhQ0APRgirq+VV78VLY4EO6HP0fEBbmBOYZ7hnue8POV7wowec94+UfONOZ7whKgQIECVUSJNMvo9MHoWGZoKubMQCVohvEfEL22KlSVWhMrx5j+2MktJYeJ31nUvy/wB/qjM38zPZrcdD37wKao6TcUBhGyNapQJcIyUTvLAbxuXVmUEMS4ZgwuYNw8dbh0P0VGLiL0uPRZfS+vM1FlzmXHEWoMuual9b5lyqMrDtLmOkfMuV7bjfn8xGIh6THS8RpbqZeb5hjZjzBDkgDhKbtXKkHanN1c1KzDGrtAJjEA0uY6ruXh3irL4nWMP2kwP9iZVncAHtWj5ZcNZnZf7IyIPFDznvL3B5kP8AT0WXP5g+8H3l+8X3loDEFCkIBm3WVFREuVicMd9MnoYuYLmKWjiboKKsp4h0ebVRcjDWSKcouLOC3VzJXWPqaKGfUNXVbxKcE0l43F3O+sTeXUW8QhE6VjMJOSBLaz0xYEIQIeoNQhD9OuneJmc9L6s56EXxLly+l9Llz4nzNMvJFx4l4ly5cGG5crYzxlS2e0yj9YXXMvNwF6S3QTQDluFshC6Slt+Uuta2bWq+0NSw2JWIfiK4rKQplzAcCAWCeA2n4PqFO0ckeFs3nqz4TAzs74NfgEvmDvS1uEFcy1uEEiy3mCnaj3JaCEDpUNwej0SJHmJi6jD2gjGZkwPStGCSrlggO8ykrmNlzLJmIutP3HRh+5Wlmo6rMZeydtnzEd/mPbU3TUyXoUw5I7uMCXAClusXCKLDklgbjzSswm0IT10uX4l9LxLly8R8pfmPW5cXHefiXLly/E3LzMxZeYvXUvMW5eoPHTm5crhQ4Dj9ygNNx8phth8ZbWY0LvLIDmCdkILKVrAhCTt/Ri1WqrzL+28YBAMTtDBjUAAq4DdYlbM0ChbxLqK+5NH4CLLT2OALYaBLNXLXqGoODBMOYRazyzHuPe5d0BzCMG+hb6DEJVdbhluXUzLj0uo2iyiCxlE4QYlCy08TZ2gLDNQ7Sm4aUr8Q05TF4neVM5hucy51knN/EdGXPLNopmLtGObmswoJGZYCnHMHGKHeXBmVnPQzCDjxLgy5cvMuX56XF3Li3LqXLl3LvUuXcZcuLFly5fPS5cf0XUu4MvE3FQytSpFzY+ImWVSW3cF3ncdy5MzVmX6YJWYUABjERSQ5mUdS77QGncJ2s8Yh2D5qGjExMT8cUy5jVMfEyEEPa5pBBXNFS9GmdsCT0X0UdcCbmm1+wi/mPQs635OpKGIMG+lwe8BKXAbhFale8TEykYT7jjFJVMcp6LGEoJdctuXi95QE3BCO7ZAy90oREXEoEWMROaqosNYfqVcZiizl/EW4vyRjGBumV1qC4W0vzAUG8wmsyszKQEqkr3lJTvKVHCbzA6MpvuX/AEBj2l1zGFxmLGXNxf0XmXNS5cuL3jCu/QMuVuUYmFzMkVYxrW7XMvu6ly5lstC5hIQM6h8oA3KBIouvylI5fcq455g0dym9r+pSMeJQCvxMLEEEcqlJEbpzKUZA8ZfxBj+ocsDHak/ZWLl2HSqnzkxlgpm9IGZPPSxRZnzTImvUGDMeZY5ijmOXeB7wJyQHeUrcfKe8fOPnHOBhbmAu4D0MEybl0B/qOSc5pKziEmrajXeWahnZWlzAbZs9z6JVtFSxcRw/3GPiP3HM3ia4ldimmYyriECKQgqXiEhmVOZXvAd4HuSuJQ5nvCc4iVuUgYBh+ntY9fizGV7yoxEQa+pXvASkBMmXL5nBKuaiSG447dC5XNWTUzeE7mH9ojmCsSMRgFsAb+oJy5lXP3ApCGrKOnPfUotvZGQpzBDRCqtPiADEKyYUoeI77lvjuz4oanImOFf6GOFQqzwt2Z4sX6JR+MvFB18qZY5wy6WTxFmP3FHNo4Uxly+ivuOMU5nfw7vyhY39R5WRJzEVuB/6lVge8Am8woLoxdTMlDVwO8ZzLzct6DEYDeGCYrGMU8zF1faozV2RUOY5MRCiJ5iVuJBjErxKjBIdRq5TllzMKM3LTK73EBse4Z3TjMaFG2PJA94DvA9yA7wJv8zvJKVXEckGjMD3gN3A7gHmBeYefT7zBuIQEvL8RfeL5ZUxhMkBAkMwMruEXqpRc0IY7l4zLxLgLKFgKuOCuVWU/EVmEhyA4hjdTC3lMaU+5mc18xgg5ee8M2YuWYPmoYMalVaQwFZglASkLNzHEoPfzECks1+Iil08S1inN8GD+ZXwS7djxeyl9LBWTj9o2v3HKEkfXCehqjj89Cgxei/UC9xquWxBMRviPmsv5/Mc6GWytI1rcTOpgzuWtRSKtGSYGYiqjrzmErYK1i+Ylg/cpGYlNw13DqrledTKPyhJHNlxSpiHIuQuWr1CLYxaIAJeol6l84i3ioqK+ZeKIheYtajXMIF7eZalr4uGFZvmGDlKGyMNkBz0AWA0w3QRcJBr8oaqAY4zEhFMRmD6IoJzFdoj2iVqOnQFcYIt8zOYAFGBGFkl4ZlTD04plZi7MuyZiZgUlCm8S73uLl4wP99xnD+0AXMCtQuUMvMpGLL0JsDLxK3lXtDV5lmkKtlwqFQhglEMlhrjtCkpi/yhR5xKjyA/MN3Ijfhb+VlW+W3UkGKNvbI1n8fdmdmZmErvHGiZk3HNYswdTyQbi10J2i3iKuopYj7am5K+IjGZVisRBXj5leJfcf8AmSnVqCpZAGhAG6waivbwCIcB1iP6MLnoGpO13+0JSKjYH/bIfQb8wkoe7jxH3LbGAbbEYWDxFhScQiMjvLGwjgCuIpb1BFWuONIy7zhWVUdhVHkhfJ8xux8fETKpcFaYIQ9qgksRd3cd4H+RBOfzApNp77hVNfcvTg9Ichp9wgbv6lh/KG8nzHKbQgbgwioqUTHMeoYQpDCBqdhAzFXia6iWxUYIWB24lmvmWK4oMxUJapUOI6Ua8ZzhKq2o4VMFe4WfgGC/bWBFwgweX5lBzDB2lNhDp29RCq4iLLWAf0SsAIQHebRNUD0gT+um8OZWF+czsupUywbfF5/EMWmq1BUlXlnCHprGTxGNNUfb9UybzLLBmZvpMxQQa6DEqCBiJiY8w4DtczYghxKTiZmJqxSEDiYMSgI0rZAAZhNOL5lyFeJdZxETX5g3KfMDo3XKIYCzRzfniMMPI2HFpSX7Zp7nxZZZfcsR5siEodSw0CntX8Je4cG5SWXplWIOmDChcE3mcRaotTc96mxjBQFMOsU3BpirKxuaf7xHH9RZgp7kzVVucMU3m+omvOOv82Ib+8OVPxB9z2qVUIcACc1FDNIVLX5gAH8pUUbTFzKyj4jhdkeo++ZcZXwRdplFFCuYVZ7YWZpe8s4YBYahglw3HKntmJWZQYB3qVFd0wNTEAyjMIEUMdYY9tOWVxT9RQKYwGOkZw6Z9FKkcSwSoGVzVtqfvkq5kn5QMl2wLtQrH0neQ6Y76ntGTTdw6pvU5UAh/ZDKr0lKYlIG5igU7xrGyfvNEbTJ8wUIuoyW/Lj+WPccswe0pT39CCcIynA1c+Fns6Rt/wCQXNU8X6I2CLvplnUIKbj0Sk9YQKS1S5YiiQlRuKJuJqZO0tQNd4wZX9oduEQcKjjue420Md4RCyrMCVa7SWna9Zd/tEiKm1qHnLh5oJiJkNzBWD7UvqoWhNUHBAPfK/EcMAxzz5GHdY9JUsT95WvzcwQYxCfUsXnUoE+oVAUXKLEMinuLRjG5jSy0fZFcR7f7IaTfiJ/yJXfwg2K81UrSvqFSi+IFDjIqb1c5lWVUwQs+JwMeInS0MBc+Iis/qNAL6iBw9R276j05dpX3P1K5Dg0inEGceoUdZjXiENSjxFMMQ5le8C4c92G1CYV8WRWe2T1BpUVzHRxJjlrjiU1qGUhUh/BBI7xN71UHcmpe0TiVHMo8kZplMetWWAXb2mp0hg0lNWZhFVBDUIZl0xiNpSuYupHPMpdS63GlVmadpfVEy/RAGX3LHUaaEBrsVb7ijbX55v5Q7EFxL2swjkgOPxBVZO0j9uh4onaCg+0XVVLs9H6XRUFplaiUuWCOYB7xHT6mPCy2Bf1GIMrdQBjJu+I1e2xYJhplhGyVpueBxfohJdJsKN37WvBmBwhBTVZ5WA6/7iVzQs4Kx4Frvb4gqyvFQo5eoru54IIWNESLFc0Sgn+Y5jDmWPgZjtGneOVKXtFtXPCbmBt8ER2JDTPmmWNZXZz/AJyDPDxB4pXqWj+EQ3X6i6s/Uog5eoo7u9Sj/XoRo4fUW1+E459TWA+JqB9QPT6lP+s5GfqMf0mYnXy6YnrINYlSI7QIRcovtK3UKN1PSCGJNLMVISrAV1OIhu8AxASiYGOktWlxHW4ECPFnCx3c5ZY3YDtC4Mdq38QmX7CMpJM0Seo3hPqVpkfEADEI4qV8S+mPUwjzBoo1H2YvMwJsz4OjT5gNWmIQR0tQCylqecv2Q+IWeT+4JCY4AvV188H4Iz8KEHCGtwh5AhpXZykLww+IQpL4l2/VKqXhLrWu6SnF3clQF/qLsrfJNGOeJTAW1fM1YnkhzuN7TwfaUxpHziCbJYMvJ2MsrhxQqN2FmDRcS+K+I11quZiUW2otBtqXNQvxuD1CO2QDuboRqCKzw/gIMB9yU4vggn8IIg/JqXjQd0MGJcigJIiNl949y58QEfxlr+G4YNX1KnslasIpNW1hGD9QfD6hYLxgUqTCDAwBMLEFlNaxKY7BEcE5KGBcHQPQTvQPZEnEHtKOIVhlDtMou4jcV4uXktYntEHSBtECgDqCwxNkAnmiGDQW4QqXs4yGlygXxGUO8owZsyA6Q9wVgzjmaN+o80L9Qd7fBL9R8R039RLLlW1+pUU0+IHBconmKdoswNROTvtNneNNSxu5aaUczJaM+WH/AEwPBggLsXc4X9m+0ldCs7Vw/wA4hKGuDyg1APcYm+nmPsvvWYhsDxBdlp3hJwWypmg5Y3KMFmcS75FlwEoXMEhZeQ0xK6qagFijQ3Bp8iKBs9qJFFuYJBOYRzBDcFvM25iVbiNfEFlnE8JL+PqeK5lWNeCL4/UItj6iq6fUptEfUvwkwQiWIVqEsrT0ZiVZezBdah7TkVPfmH4Yjy1hMACj7iegiXEpqoJxOGY9QH/ErNTDo+oeJ9QI1Uo2QLpCH2mLiN4qV4f2jOyOTMZyRVVLDiH+WEbDcFiGFpjiVcU7w8pemCMRBMB6NWansmKIEExBgohLEeBhb1A3MRWlnEE8x3yoajh7i4x0FHHfRS81E8iGZkDQ+Ia0JxtiLD4hjVIS1IWlkHtE8D4m4E9x1dcRuE3/ADD8yri6guma3tWvzUtnxzGj8qywTUOVYAewwR0rQ4O0z94b7y8I0lSkGBY3Cu0BqoV2JRYlZSWSxlYlxz0WIHMRzO8ncYj8yx3KtZgMqiF6lfUsZqFepQcSowDqC2yUPEK3AKC3vD3RHK32h3xLHWJtExzCcspVJiCkKKylMzMe/wAwQ0j4b2l2VtdAB1USvEp3uWu+mdFwYrMCX+UZCT5i6U+5USB/aFV/ul6v3xWoZFWAQD3MW5h3DvcDe53Ep/6gDmU7xC7/ADEJBeYKQu8dOwuFeCLjMp3iM2kERkMyhuE5gBuW8xj3csYNRC0NRpewzqXOJJyRR6hbmOMcH955yguEqWprGGzbcaeMQxomXGGN5PEFaJ6lwphdRQm9dchEgx5IKc49wbfmW7ko93L+xBvmFWWteAz9KyrBhg/aNYDLHOrn8TBF+6BEospzdkF2wuWYNQi0G3McTXnosly3tHziJVhCErUs4lHEA7wSikG8wXmUuCm4qNsIQC6jd6jo6Qz3Rd2PuOwIy0zmWE3eEs8Yg8vcyP0Mzi/zDAp6lBSoVK/Kd5+5SYZScTnWJriO5YiTlhQIEKUMMYRK4jFfgl4YIeXf3D138wDFpL22ENzy/uWNsDamGTSCGkKty1xLcpLOT1BD+0qWbHs9wnhArSJDIgcCC8IBzmW8wgyk4MDbIIbgi/yirqQMwYezUMGIZJ7ZwP7h+B9xUwfmK9hl6pcTAG2/U8Qm1SaYfcq8wELcy3ziWI0XcYL6JYl3Cj3g8xZdEw4QSD53xKc+KIlVijWIae//AERTAo6K4/YJYOAKxwVLv7qsurm3EQmm1PBFbOARQsmMx4pAmLHsZoB8RDViFyzlYeUql+5UrpvoxhpzAHcHvCBwQe4ix3k7rpqG4VQr3jJHeYzhCFXRGGrJYVcstisGashMKrT9S+Cdpki63zG8/hLWQzxzFiy37xEfYgRhndO8eOxBCEmZS51K+ZrXB1c0QK5iTQhzIQ3HCS62xW8JfoSnaXlXMD9QLiBGoFtQLpncSDx22sW3NZs1IUXAF2vkjg15JjWfczmkWNpR/vKjwm0ddxjWI/MLW35YotPuWTRhRN/mEKN8RCS4R3j3Dn7DCeW+zGbFPmKB/Zmxp7YSv844NiHIpFlkPsXaMDb4ibQ/E5Sx0GkbyLmkygm8RIuc8qa6GsBTv177hlK3LUItUOgYftO9ODspasC/SxxXdh7KCDVxmlA7lUdrcqlcKjmgSE8QOiBC3kgIFbzAzBfcMKjBu5vIw3AMrLHrkC5RxFWo7MUf9ncQaF1kxxzBMCwpVxiBF/mDWofjo37EZE8IvhO3X6lhC2n1FVyfE/8ACjRp8kHw9QHh9Ss0gNRhg7RK+CWuNTs0HpIE6Sl1+5VxlPb8wKC2LYZlDDLkw7RIdkHxPSYhV4lJWWLgXicFQFYm3IzFWZhHAgYGFS8pBGKPUusoOr0ShL8kyV2eGN0oHmCSqnuUh+EAZox2UHhnDibQ+J5J9QCn8cTXqe4bpM0IS8GjceBt5ZVsDxcAYCeGAFIHmAOXgSvGwTpe9RGq/cLKueGCLV9Qt5K7RVQniNbhGqLUqgB8wIQ/cFsDfaU+JnDEJbi5nczIUh5ynP7S1ncGt9JzMotDgAXttKLQ5cQILgTQWQHuSmG5AhOAZZIp8SyfMleWahYJS2DEC6tLyG9oFJXuRWjOKQYxIAd40azPOlfbDw/iD5+4dukNl3CoLhVsIdw2FO+i/JGjc7gI5MIB0g+6JRW39zkopuF/9iVGJwanCZbdJHaLyL6nGtmnuN8zMxzz4nKLE+IRwsxcJ7iuIrdTtPxL3n1E955UWtMzah4wrwgHYZnLlnBEwtRxQSujOK0KqWh/F1L1l6ILt+IV614qWmRFDSnuOsKG7ZiFmPFA9qiPFeIzhv4YgGMYTL9EEbBfOJarIkZfpmcbTuRWW+xNYBdZilRlixR2IyDDjUVN6IQhvbDVFfmJgrdoobeZGyKmc5gxIdyUuO8wAg+5jAWWW8zNQekslj1LFjIOMTvExAfMrKN+WHFL8y2HPULheOgSZTPEvN+Iu0eDvDg1jPZr5BARGX8SUvqUgizMgI+qMrX0mLKS9kshaqh2kyyIpeUO7JzrF8tzvxfZPHhg9yu5Qcxk7CIMNh1iA4l14mfWe5CAcVBzM7fQG9464zXRu5jfuPfjCLHKJ5VGcvuJNvUawrErlY+UPaNhFRuWRc9DKyFYQYus94WsEsFYjNzs4OsVAGoIgHCBcEAME8BD0nmPuU8kr5j354H6huBjXSM3tOFIZSXEVGeIsO5SRq+YFQX2mLZE4aY6qlBQF8xskDiNNj3YNItebjEbE2JarfGY+xWVWoTKV2yw6JJZlA9oTUSX6yeGIyFOLMqjJzCWBeKlkR6Qxz9oNGtYaiTtxqWxkbq4XJ1zZLqqrzAgWOtw1iGMDe6igSNFtxCFaKiwdW1DQlDN2eYM0GztEFdeIgTDzGqSOalK5+YeoD7gRaPMw417h5zJG+neYjxFbcFa+IDCJSAqhPcoPgjbYyfMUMxijA3NC4ZuKcgzhsw0wXLFBPUuYWckhoE7MRuiL8NzjWe2Y6LB38wpXpLHMEQdTIl3HZEuF2jI0dDYjC9AtjLKKzAEF7RtzHHUsnpG0DtPWU8S1xM+CXZMwhszLyEqZ8Y7A+/coc/axAxjqaIatEPF9S/n8QflPMg3az3h9pTnqXLgI4YntY+RFVMNsSog3G6FyzMM1HpgdpUCap8xaq/uOhpO2Ykoj3I4QAae0aChPJKyNNNI1iohWgR5lOWvmpWPzCGUG4uCnDHcls3G7G4Ai27kTJd8bjZIbxYi8dsqXUYOLWRVzjQDeMulXYCv7S1pHswQysYK/mOtzcj4j9rdWu5lHzg1cNAqOEzEwhTepQXWDzHakqgunMqvmJQ+ola5i8ELIp29pQF2xFb3gUfpj7B8VBsi5CpxABDgIpdxwCjdKWVrvCUyvxmYYeWSIEvUUoA1FrUsFbd9ie4TwYFPv90WioVhhkCqMMBuCOZd9BQgZYkUJTIEcyTSRDiXldBGyLlLiLjBnUzO8OiJAv8AaW7SU+ZT0JrcpGVTUW493S9kWLGDvMe56x0YY1ETt9wHLBw4nrGnEo5lM4IEBwlDM1xDtUu6m/RohhJiUmPxBs7SgLPxMR+0oJbqeSZcw84ecD3qUvcq5lL3Ad5T/kAwEcenxlkqWioAwShmZqKQQ9ybrnThL1w0WBLcLtQRm2btZMcdatZTNV3cOOHIMxWCxbVxILNW67pRBOMAfVwAyN+IkCutYP6lmSc0q/xNaDQcNwUuW88h8zGj8Kl91B3GFmfUrVScwb9nt/8AEUQa0F84YMvQJPjl/MwtoADb96l3kF0R2oE+4vMClLJrDSLY/EGgombfJFGCKkoGG9j6hsJTJAFuXHEUeyDY/iVuU7j9iOI2O5fxEuiiAPZl7aOBeQvUfKoAYeKc5u5tnP8AESuIGg5+2CSuFbiWNu6Ys5NO8E4TwJkxfIpVYXyZrC7I/dKTJgdGi4LKyhbdvfjcZ81F1e12CLjEVWhVkqB7/FQU+BDDdV8jMKNYfModgdBDcSVggblsW8RFkjPaA6jhDntKhcuxqWxWMHxMsH7Ipn5xsYnBL1GHPQsRMXcYI8uZkwU5nfTJCRYxEGIhESKNRHMXt+YgdobFWMOw77QANEQ5l6xcUMfiI9RqXBY/MM7x76kiw7kG5gq3ExmDkgwuaT6m4ZgQWob3NXCWkLXOoHj5gqEpsuaEKdSZu8QaXKsSqM0p+iMv7tDtUJWXupv5mAFXoy4+CvR3zUJo/SzZ5JjTZVo4blFBDZTSxU0ElAYr9x/Nuv8A0lC23mfww8xjETlJlXmYYqIFcqtD8xjUS9yzSF45mujwXGiqZ7C9m/sgmxOyfyxNWO2yD+Is18X/AAmBxYAZfZZ/LHKZ3bf2QVqEQfiOUjKvlswx55/KIoE3nD9ofFUWZEzqLkNfSYijXDRwrzQBlUAvEU7JP2BR+ZmNdqK+L/EA3wx3+2NCloUThVRfJLOfO2ifJGChDgIVO4vhjQKYftLqJrAWLhV3u8RwVQ/4xY9ah/MUcqqS7KiYRLeU/EFZ6jqUtGjgr0sruAWQhrfiIoB7k2uDhi9fPBKv3m0WeoZrI8xKYgepvpxHcWcE7suXRK9oZrpANywM0sBitkLqsQ3crdQmjuAFkQzx3gnvFHMvWSUF6g9mA6jd6WFdo4ba8k3MFSvMIsMXhgryfMAZi8P7RFeJx2EGgYgpGGSAaOE4rdxq3COcwCoBX8SvUumO2YIOJS5vEtDcGpt3hCDCHQmnpXeEKMVLwpBzEL9zbdvwy2lUXtdxj1iskrSgMeoq6LQsfcEMjQL3mI1ysSKLQBaO0BwqJgs8Iw33U1uyJUrp+4kavBcYMr/hmPCz+f7I9VBhsf2UVbSbwX4II+yIz4LgJ/MO/SMD4Pr1fgI5YseT/MKdE80X7sTd/Ct+826B4P8AEB6rbW6PIWtZa4muqT2+AggYoKw71VfxMrRqU+Df1FAacP8AysPxlgVqVZUvlLZpjKBhIBsUfSGfE0i31MeCaoIebDmougN4ZZ7O0aEJCSYcwGj6oijgeI3MEHwSwYD6js/xFYLFYieIecQ7Lg2pawV6j1GM2HuQdhYTSM56RV0xKjYxdHtG0thEkZGtk/5YnOKDZLtpLJwirUqXqWyMQQihq4UjFJhqNtzeY64jf3CVVlyqaM4CdAVShcc7uWG4BQhKOYrtBzJ0IKxHGtB9XGijxUyGJZKalRRRCFdkuaIBUwDea7QOJZMl8dCaPEGDAJMoMIQcQO8IanuZazOZnuM0t+G/iMV8wv3JrQ5/voEHw38BYRg6vKa+iJbaKzhznaPqw7P+4Ten4/5IOhmMqkisVb3H9SlsnP8AVSbIf6sscat92/chxzdH9EsDxpE2Foe4yLcy7kYMRbc49o6234viNIUvzHC8HOLgs4qrahTi6zoncKIdo4q8PEyEd6sgZs5/ER5/KXRZfbEQNVdg8CVQY8szijT7gpYfAmgAgAAAOxNkzAgQVXKqwtwMsqlhSJaxcBrbMi8zxlDorMqFd5TlCwSNGqhTzKSD3IVKIliOsynUFrMLuYYzHxlViGCsaczSVlOXLHDZMYbZmfyhkN1L8CPEYqTshIZ7jFgVdm4UsX5lVUF85jVFLjQ3iWcljwylpjqCHeJruAbIPPHmKWTwwC1ThqHQD2Y/N+4DnEe8j3AbXn3BVBgTOGI1+6GaJ5mUuNeSMsKdmF7vJC0VoTQ2qIUhXO4XT8A+IA50agch4lG/2TByswLxFVFXaE6ZeEfsQMU+y/clLRf4bhZlftIW2Ha/i41H0VaMC08796gK+mI+Kf8A3Zh2Y5pH4jl//vhjOPeP8xy+6/7Is2O7/oymeEEPxLuTHqZRuokcp9w5ovIkGsHX9kxN+NlQ4o0L2Qcrgz/XXMMv/Bgi6v8Ah2JQ2ffTixl/7oh7cGsF/RGV8II/Il7e+E/fAPQ/wywBmHFKccw7JJusJq6pWIunYvoRvC/70JRROqyEgXyVu/djk289l9jLleQHN68qrzZK+gftOMKVo+8Ewpio/CECGtZlafh/EAi85a32wCgBBzNxw9AnMIOJdy30qZU5p8wE4gUww8pZ3ly+lz8R9wy3BngPUcMZ8kXRmW6QbmoeG+g6cgmg/MLsMQyWQWsxDlMDuLdCwB5l9kJXjDMyCk2d4w2DyQ0sp5lGGtycywpXZlSFjXMEwmwVEVYYTLDITheMR4pcmJrh2hZLGFuWGebq6iirnhkj1Ap3U0Zl7Lu6grkC74mU9DJCwJzQrGJ07TwId8AafuH2n7f3TxnLdcFgiwEWA7wzYQXWPJmVdsO4x9zDKUQvBvsb+ou4ezuOz1twOF4skJBuNhf21AGFNTs5E8R0WG/yAjSJ3Ev95u1rNftBlgyeFgs9TB/EMaAu8dvzDX0Af3neT4B+8KM5xV/ZYJQbh/bHE2dkX/Ykylax/VJhheRfwJsnd/7ItI4+TUuM/wCB2oNkh2/7qPORYBogsf8AjCWFh4H1TL/O9l/ZiKPyw/eKOa2X/uz89g/dilfKsfyzIsef+kcP4Q/mIGf6SOu57p/EPaZ5UIMfIn+Ja0jzb+Ye1/kd5o/WX9xdw+o4Q9CKLv8AbH5h4UVgCPoCCqGPZSKD6TIogut4haaWk4C38XPRyMUcNAiXsRVfce0lBV/mVS+U9f8AjmVceVsnp/NwgG0fsghqi+Jbcu9NQbvuTcviXFubemoJlIspcIsZW6mNTZAcktNwfBhbmX5lyyXUuXLOYQMRsRWsRekQgy7mcBDrVxHaLeIDGLpfzAoBHdkgKjTzLgT4xMijNgrr+YCWs7EiMkc9mFQNOVV7OIaDaWOh8d5ZazDbZ4fUreeRMPhIxNSYdj9RrFS6SLUrQMzSi/mJZoBq8DuMba/A6P5/eajlcpTmTGYeEiDS0KT97NxlPIXQZRw/G4tvJQWA3VXx/txBpau5e+g3wxFBg3NmSjJyVFeC8GR8gqHSBpU3wbP8e0xSurw/xBeYHcfzB7/z+ZT18n9kJa13b/vNMKxd7+04Nmq2zOXK4N89p4OQvwYH6XuayFZGgeTNfhKCL7jxYfhSMeSVwXtsn1D2FVMtHiz7BKWAIofcP5IELEjeB8YSIIGTQ0/TGtS+2SZ8xexED/3HszU+FV/MWbUe/wD2hxfD/ZKFX+P+0dz5h/MNB8k/mFC7PhSzIeT9VLC7jdjfxL7EbNf4m6Oi/wDwzwQAh+0qhZoVj32gFzzcKe9RLwbWfXLCAJ6IXzVx7YD7YVh8ynCYVVPkJBimLGw+4XShpX9lmFOmHNAcAEUiE8jhSP5Q9FXRaPu0SmGtT21IaIGCofNBAFfsCn+SBj3yx6vH++XbrNXdx820/FalyQi7jJowXFl/lTB8pPjO5XGg6Xa9A/VyyPFi/J/JMC1KW3zWCvuUwahwfkLP4lcLhQcfNFRKGnyPhSfUtaLDle9Q0XlrH6/7DEMZup7rfzDr4cZB6FXiY4+I7Jc1rEGeXRcuUOZt4PwwBwIdRHvxAiJbV5lwFECe8RxOxmV9yF+UT5lu8IAwv0EdyYc9OfQZbljsiKxuUwZXUOidU6Ad5e7mGjL5Sx7Yb3R2QIW+nUC43XC/mMIHkBVQy6DsG4lgrNbEKplriPaOAoxXmUFwvnvMp5mr8MuSYWlwj1ppVe4DnNuiAfukaLIliiPmVK6lgZjVmOQbF9RAW1kbslebtrvDtfD9S0fEGArG9eIHEsW1F4POPpmrwMRfY+TjOji9wB5hVNTTf1LO89zPeBtNBFvPiHHvjdfXY8DEOgLQovpz+8A+QcYIfDg+Agyo1Ry9FyfCepjfqcDtmvyYKiq71H2H8jEFXMI/dMftHaBOL34E5rQgg+NCWRZkUHsFI0RHIBfISJSTIKPgU/Uy6qYE/WH1KYlkXD6Jqi8iP2hwKYRUXCIpQs9+34hFQrkoHxCAobwz+IQLumz9rI6ym+Q+YXl3ELD9Twdgo+ncchc3lTMODlQPmojtXLgl+9/mLtqVpf4j8V2ujT8fyQQFSc7PrX4is+uQfmf/ACJ5zYAP97ikEjAmf6+JXrYLafmAJ2QwjFLsd6MQVVq7Kkc8cMKHHuBhV7oy58+tTcFnZBlA/wBd4gDJewZgUbBWCKfY0wL9JRY8v+EsTMDOxv8AEQFvFgE/Mbuou6xcKKsOQQHBgBqLXTX6UHMIzArh9xEgeWF0fJbBeY04Qk1TPJb3YDh9TlA9E/iVTDx6ivlHDDmOmS4HBNoo8zJKPEGphCCDokBAwt66LvoK9L61coejqV5jySc3KSsRxySxU0whuV9zuIGjAkApFcjpiob49pW1rUE0aG1wgJfCxy1l3hkOJaLKO3iBmysjuFjmbZRAQW+8acy0eZWBrkqCaEwYqbME24/3eWnzs5loxAusP7IyU6hrwOD95d5ih/2AljqGYlwVttO6A8ll2t8efMcTu77XYf7uA2rrEt86mg2p2/75gHclWYGhTs3IaoBpqd1O+/8As1Tfcc/75maUdz/fxMQk7mIcB5aZVEB2FzhnttDGn7u5VkV8yuA+4Olad46YLzUynhZbhf3D5B9TIDHvUKilPENy71H+SAVB9dwQwtHtUDaE7hF83skt5Pepb+TIZH0wwROyQVs+ZNnJ3SNxXcuPZIe7AWSRYBDNRZdl04hS3fiZjB8xgVHiGRKvMCL+aaQTUM1r8S5cdypdS5cQc/mbkQPSX1g+YE1/G0ake+iBlkQFaIhzWBF7LJiL4YprUU8QgzCTcB3IB0zI4YpBTTKOwYNyksazEPiPYQoSq5ip2gzaLFVLjCCy+EEkX6SLJcWPVwbLVmFVRQAHvFeefBlO3bLtq93AbpQ49y4jl2mjRriYVwlMLQ4yzum+/ECQBr1LpDR7QlqxXEaSgC88wNOTUWJ8MWMoMp2Ymb/cdXiCksWDJ5jSZQz0IMBK64IlMtaLl3Y4GWd2HJ6wqjFXllvm4LwKxj3KqbqNql+Itmnuo4al+Iv/AEIhm30iF0viPO/CazpEgGZAun1AuP1DQH1PF+oSA9QI1lHGoGawHhM2kf8AgQHBP/M6QV4+qFn8UD/qgevqgXD6lHY+oUmuiUR15iCi3x0JpRA0A/8AhQiW0m/H3A9fuAqL5gbYdsmHBTuYPzHx7RbFqRyXR+Jl5v2ymvozAdGqWITzSKgQ8sqEEdUzWiNG420z5IdwTDu5fuA1zAgWAlz7wDxKOHEFJRmIwI5Rw7Te7h2IMNRFgMMbmMLYgGXBz0WXLlksrnaRhyPZ2TFG/cY9p6ed3AoMPMpK47zGJj3AO33Diy8wr2b2RL0+mFa9mac+Iwbjm4CEsdxxd3vzGAbgYIoAjCsx894a8FAEWIsG5fmWy1Il2kqlSKwdiDuCUcSAIAMlQgAwdNIBlIpL63BmpzLCWRbZXxLMW8y7w+pY5/EKG/xMuYv/AMT/AFUw5leZqXiEuDOJrpqXiDmViXXMQcxLYixwmwM4/wDc/uKBql8y2wncxAMncfzAET5VH8xFGXZb+YiobxYPol8sX7hop8zn30kB39Ihao4vKPueZLj45NRIT7bbB9MwTPtTU8lOPuGqhBTPeYMIX0w5ZA2krxK4MLiXO8EaZitZ3kBAsqwaUeY1EzaNuI/CGvcAwMrGVQ2QW4M9QrnrVgGZguMMM6xsNQsF5yTCXbtGCL9x2AlP3MRoeY5xpizkPcypR+zNFbHEKhZdXY8VGCFJFEMv3gEFLnMZALuyACyDUX1BeBzD7i+SHfiOH3FyKms8eZoA2J4ks4QbjLuH3EuH3PEnaJVw+494IdlEcIExA/M80r5JZzM24DknkIhyR7pM+ydlGeeF+yCcn3BuSXcwbk+55Ijz+ZRz+ZRyfc80E5lJdxCPhnNEHyfcJwIHh9wRxPmf2JDn50YNr1lD8wJsO0GkF+WJlY8GoNteF1BtpfmANH1B7CIPD4ndE20oYDCrWL6r8QO2IXMMG+L8QlDfZGg1vYi6/BxFAyctMFARoD+8RAsOTMRg3niAiUmlgwlj5j1jHuYm9wBhhYsYhKHMW9wQSwlkDAPMr3gO8WGWM3cIKqFAF5ie8VzAP/sClT7TLxBqClu0ZDbvcWtVcaW3PEtWb7MJ5r5g5N9zmEfmaC/Mds/mXxbSZnN+5mgVAvvzFNbJ21/EsZZVa1ABDzaXezzbv95smp5Q/vAD8OyY9u+5EX3m7n8RAfT/AOJSVg2gxVg+7P5gx4llLADMIV/g/uUpt9P7g2PoP7iyvr/6jQAdjJtiV+fX+cy9j4s/uIWr4TdIef8AicVe1/UJjS+X9RjoPJ/xHQE8/wDiCmCMgB9xUwCe48Q+5xQPmJ5H3BNXqW7HwQ4X6nZk9QUzR9Ts31A9F+JwjpJ5Qy9sIrknARheCdkjMcOIbpHhYvdZwx6ie35rozK7F5UecuEuE8K5YafUW0ZYwJyB8Qb4srwKnYEFxGjRD4SnIRHFECM7kslnZiV1UCv5gCb4qAeLgVAwJFX4llWHFCfJE1K+WJfEBwtSpMZt2RGrtwYsnzBMH3LAF+Zu0sxAk3HlDmD4ZiS2ZYQN5iicjBSY3MUvMc9w+8O9xUd1K0uWTESo0zmUquYrlA3cx3pMe6YbC2+pnxMwT7g09BTS/mZmM+ovT9kdvmV8EvBL8xRN4ClhmJIlORlY7cwDRqptYIBbgJvO52lqsiRcrMMVlainBnipV8hlFkgHcCMIOBzGo6+ZSd4HJLT+4WF15lhvMyZI+IwXRMZZHywMuRjJ8zssodRB/qAj3nNwZ5h3wzwwRss7y3I48wHqC51KIy+JVAcMdViZu0yVEDeIINwp5mfEMNzHuK4l+mMLiN8xX16hV3Cu4tv95fzH8wV7YfwlDAPad5ZLdMo8wGO5kgH9SziWN4jYwxK1iJUTMIO2X7hmQ/EIcTyEzl14qKm7+SLD5aN4aHZlINx3ZTVh5lUo+4SAWFaRGCpTApuXdoC+nITyzDACCjdRUMfLDjTL3is1+4JgfMoC4tIrBoTJle8Dvl8S/wCCexHsSo3L3ciCmoA4uB3z3iXnczpnxELdYhilx0aqk3/7Aa3cHTkiJvEUisO7jpi1P2uClw44OIWxDDczgIGVGeSLwhrTCCpuVtUnMEI2O8UCFEDUcQRdXiGhguATLFfMXojtrtMQ5lrDLnIR3H4jOSvcMLyo5QDu5oWchKPIyq8kv6j4Z/4sG8YlmHcogNjO5AG4v3iFICzFQ7SoJZzU33O/BswY25qNucR8sQKqCH+5UYaqIWGIuOYs5n/qSvlirBil1KXDcRSu4xy/mLw3OduHlHwl3EhuEDeViE4ieIV4lhFTDDYrhN8I5ZR8Tfh6SahHmDQXzOShkEwAGsqn90qIaZygTJ9wSBapnlgVuUczcNxWF8zGNA5h23HeWTLmpmINwDbmESNU7iunSZf3h8PRFaO4cgYio8cRwluNpaVeY0+uIjxFPctWZYvXxLNvRtM39Q4bm72mtb4lNiyCKftzHFbEdbuAvntDK/aYRxBNLvmpYpqoGWmPkmwxTOPmGOJgtEJDgQmFRH2d5g3nzGBnnMt6ibLmDUZ1pApBsIzLFSG1eUIZ/M7riFuYd78yqUcjLSVHmaxhSYMMu3BEsKjzGXLQiuFloAZhG4kdx7uAr1CU7qao0TvpiDmZKWVlleGKcsPjMIazK3VMUwzADEQtUI4hDmYSlmCRYxBkM4NSspeGKMGIh2mDopvUC8QHeYDxU3Q+Ym6KnbzG7p9JyLGsrK+7BDlApwl7K4FlwRuENy67le72iVkxwRmElbQIyD8wmDRKXc7jLLzPeGFfmDYVoOI2fqMAdcS8iqKhjW5fhL+YgilvcLIz8RcliXvEF2xu3n3HepfhqOi7e0yb5mJolKde5cYqXcmojQYlaMREWtd4DEUd5y7PEKZD1KCizvAVY4hh38wHQTSMeIX4qK7qETj30VdXKHWYNWTAEAcUw4ITHK4kbIaMjtGKukvxcxECm7+YK5Q9YcMyxMDWICQLyYjxmFj4gU8wKhkmO4RHrEHdQ7lguZQVcKVFpmmazuVkE4YjieKU1U7RAVVSl6lTZBGAiARJhkh3oh8MwslgczLiV8RalpYhzuIcwHmUWIN4H5iRB0VSNNwJdceWbOgg4Im6RyyzpRAtNQENPmXD+UACO7uc6O6/eOdn3E1GjgmGbZjfmNncBRKy6+5dK5lKHEr51BwgMvncQd2Rnh3BPaLFjcI7lK1ESht9RW0N5qCELYTPnEHVyw4jKSMLCd+E/djmLjWfUU8wMiH1DJd1KuMkXZrsxvx+IAJcXGJoshAP8QtVmAvJmFwjAgBMs1LeJUcR9IYmDoKDpbiqcPEo0wyL1AbYjIyjL6llZgpuFEq6m0MswpAo7MoTzKqPCPElnEHhLDiF6nJzLnEA6xGOIpU70qnmjbUu45ajVy/Fw0xO+R6WiNcVKumNaiWKGyJwTuRbdympaEoN1M3iAeZR5lxhm3HQc86huoikm+4wEWkqo6itFI7mEKv8oVF4TdM5YhrMrIfiMDsQ0Qw3OwwfeGJmYeont2gHOPUp3Vyqsk8ZqYsmUSnzzUVbgCufMtcfiWbuGVynjIJfMWDvDeHcGY+okPELLUvK+4tmrPUVuLJWcXNoKJapMQuPuZu5ATE4A2RL1KkYBxl6QdpQyrHCWMajXeoLNxw8RpuPl0OTI6SGptH4xyjTOo6MSwGDBBbC7wnmAm4TAERKLqVTNRqWETwlE0S6J4CCIwDtKSpA4cSqKFIPfEocyk30QRp9wJnxAQm6iVEPmVfEbeZxQWNdbik8MA8RoufiE0aYTXvEvMymjzKu5fzKMLRKS8ZSaPqJTHvB3IHZZX2/mcwjkMf9ReYFwwbDOX3Kf+oNwWIdDN8xuWQY24Y8jPioXyLEaW9o4Y8zUrMbjcbMAtfMIbuEckN8zkI0+Y0ck2uGPmCLcTFC+Q/EoP7lxdY7SjIP1NdXHvsiqk/EcEdHPiVri4VMfiD95zCGufUBN5h8iWCOpuVZmmULRpM+CZyhLD/MtiXG70ax+kpdy2kpEYhtqCDNQqZueSWl3LapmUozCQWNGVNTCE49FDsdCGZEe8FOiMBUr8SnvpygYmpj8xDLCY84TzDtZ9YEYCMAgup4Z90aMwlphAQpC8LFx48TKDqKNQxCDiVME4RDcfE3h0xbOQYJ30gCQGedGTM4yUL2SolDkhhzKvUKrWOmjfQAPnhli7jYPE7iAehVeoADvKsaEfCmLxEd4ZriAcw84mbUddfMo4hdgqKifdPJBGZZ6ium4UKcwDU8l9DuspRmUuKB3jeNfc7ZnEXdRVS5DwjDbcbOphcpuyCyop+JQZhIXTzDO8QO88sB2w+UDiJihiorMcFxs4j5dbGIZZMMHMICSnERKwEYolt5lo5jW8xeuiLmJLeZlzcU6YwWeIgLfRZRc3xMkGFJkkRBthSNosyxB4QnicBOEgdoC8XOIxFtiGiXM7iNDbGv/Ut/6iBvHaJkglNNwyzLxVz2iAlzZf1LXE01j9pybgrmChEEjliAYjSdvNQyVmHze8DDTzUu9RE11ArGo0sqEywoTBiHxL0N2/8AJ2ICRcXuFmmGnmWYlnuLcyTLoCyZ/EadLHrKI3gO3QyiPdMZ/CWhzBLbOUxMQY78GNy3meeWcy43AYJEOOi2vmfaYSnmNkPCFoKepYI1mMcovhliXjfmeaJejm/MRO8sYn3jaX4YLW5gzDzhBeoRdxBxGmZdQkgKgw6G5UMomMkqD5htx14iOLnHZ6HCRxbshETRfSpRnHiKPuKJuv4gx3WYWXvLlyje4ylxD/yZ5VPjU3sRzFR3KPMYBcokVQCXufVxVL+Yy0yztHOemqMRUXIobjPiFSOjOCUMy/uIPSEffeYL0jdyyEjcw9FfES4h4jS5xN9He41EOhzRxLlVKHMfnUXFhlnMKmYTzBeZ5MQDmAwRCIRvGxMsZSjUUTn0NuIxhz0I7xMSxpGWRs9Om40seZY4gpkwx3EM7hfc94L4hFGWRfmXMoJDPMzMwZdQ+RAEUTcUXQyRbiGAtFGhm4CCKYjio6C7P3LHNkqMdo2t/Eocy33LuFnxExl7x2Xmo4U5lealFxHpUe44ZviDJTFqF9ILBrPiIPhgv/Es3M25lywXsZnxDXmdlFNbgTGYGCMTSP5isK30B9xE7wUcXA4uEVSmBDvhaK+82eZTKiRIkwjcdTCZDFxB4j5jHjiaBqIUuIw5ZnywWsw2sy3mWu5YbhbmWQpIDGEdKmNI5QZ1BUSS45dBNZRggzLIDlg3PBmKOLlJEiE9TTELxLJd1Gn/ACNL6Tyh855IPRVkD/MSW5jHqBL6CjSN3xFcdMIykzhcCiWNwC3mYazmHHaAczbLplUtxGnxBWYtw1rtANxEqrzEQJSYeYt+JllixdATzLz4m1yhFGMs4qJwjB5iLK3MLbcMOINxCoK6HghGE+eUJ3mGYJSep2iRKiZYIKYIISKOokbIQNwcKek4Vswbg4zA75gu37hvMrdwFTOBqAgEHjfQ7nQplC9JJSS3mMFqNI4SxIkdGJAYntEQgJCKRHDEHUe6BYVcKVKHoF4F6LJZFF7wQ0dFXQWJs6FBLCLKReI+L3FvJ8Qu4K7+pni2F+WZOYYQv6TL6mWNEwZuFDFhm5S0qyJ4hnEwSK4Mf/EWJApjEo7y+Y58sz9S+eCoon+Yizcwc3FDCuIum46TPabQL4lYogv/AJCbbqWJRay1lsIG5USJnoeVRvx0dkUgjbMfjGKRgwLO0CtTQ/UYrMU5jCWsuDOZfUBylkt5gDnqA6A9v0KUTLzMMaxyx0WfLLa6ERpicSqYQfmYfB0Yosf7jbi5X1B94vMGFIReD79LfGowsyRznuOLEzZHhiqLRcp2/ExjNeZed5Y8RUUxWjEK7zwlHqCJLLx0C24519TM4hybhZ/mHhEqDR/EPSGHRWPErxDPiBdfvHUZg/vEdiMNrKlcwj43GziXGZQTCCMs4uc4OoU8yldoUhXjMa6hTMWoYeZi9zKZRjwTHjoyj0ydpTPWOcbYxGcZpMo0lppiqUczLvEo5h1SxXMHC4Z46NkLTfcsamURMsZ+2U8SydiVcSiKk+8RcXJMTtDu1AMKY38RbZ8HuP4jKYNVO70DCHygjXENdGFjR8THO3Cz0EXMo5iiqNp8IOM3LslzLV1MXaXZ/c4kM4VHg45gfmYS8w3BzB+J4Ef5mnEMIwZxBbqD/iHcx4OI1UtqKoMwYLCee8W9x8JlcrxG8sOIKi5YNR5xEVmBE7fid0CV8Sn3DLUrsfcCWjhlCmUtiMMfeOEsj4RtxPBHu1MSOHNzeY+IUlXERjORGhzmeeead5+4DzNeYbWYXeeSFoYx27yxjZ6nD26GNxmU8Qp2I14jZLm5WBZe7hhEMQiRRPEIO6EiImDmDi+hhylsvJ8kXQwxi6HeYpe6nkue8TB17llW3BFXUrZlq7gJT8Qw8xxKq4XKSAlRzY9sTOnMS9fcZdTDKm0Kst6I4/7BiHTFnxDvcHichz7gK7yr4zG/EsZme4Htcq+4IrHQHQMNSsQJUDtK/wCSqMzeobhhgrlEVk3MoxjM7jtG0acdQavRXUfjGsqo9AxVRD1HMWxDmaoVmZ5SWcxcQ+8NMP5hecoXhYYi4hHLpiztxJg6rRKSDLJVy6lzczg3CBeIfUJX3KV5jrEoIymM2yyNlcrIFy2L0K56ExXMdShOPE021K++Jj8dCqFm+YPaGPUI7nyg2xD7jX1PSNJQxsHL0tCzMS/MBDSLE0gyxDEGtahnzCMEzxG9dBlNNS1wKhFFi4QgXK+JWJUDGSVAviBMIlQtCBKj0PTpaS2NNRrGscJZxHDovQYaSvqWIhEOY/ec9zyTRmeeC1mWG4cKQjOZ4jkhGHnExSpY06DfxGeWIuLJ7I0yoWRTuQHEwlRYjmKQTDqo3uvM2dI2VcynoUzZKypFqpba5wLR+0pQ1mW0/aVYgjieieaCcwai8S8cEd9pfeLE7E37PRLOYleIdpXiVPKD9xZIv8xPE7lEweZlK/E2/qWQkuwp0HKMqNRU7Zhl2mPt0EMIRtKqceYBN+4E7yoKZhBNkG4hcDM+IFk/CMsNpRUe+6nw/oV/y4yueCJUac4g4bUqd1K83jzL4xpmXcJNkHlDCFoZzOZZYRYLjyj84zbcaxoaj4QXiVrUWcVDG4V8wJhxCU1EJxFqVMpzKOjZHUctz8MP48S5emXMK9ypZTsigz/qgub5iiZu4ivccteYtvUY86iMyIlD5iF5lrlgYv7Qbgp6ETnOj+4molq1no2QD81ODzE/epSrho9EFFzBpPMTcHJG0u5ODDpVuZlAxOalERaRKImXxN4EWGcsTMWYKVLpJuo4+4aZeIKC2RGCRgwcsFjYIcQa+puvMOYODzHDUFESUJcEBAFJV37qIQ8wFRECoMQYgsYaL5qOEy5zKQ3MLM77iY2Z30NWYirjfiLLVcVFcxiK6i4Z2iEuAticREAIAHn+o2v1EU+Ilg3NRZhmIWiZO0XbFiaijwxOUSETnPEbHpzEYrFzGXnRKS3x0P/Z\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="fileName"\r\n\r\nfile_name.jpg\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="useUniqueFileName"\r\n\r\nfalse\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="tags"\r\n\r\nabc,def\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="folder"\r\n\r\n/testing-python-folder/\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="isPrivateFile"\r\n\r\ntrue\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="responseFields"\r\n\r\nisPrivateFile,tags\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="extensions"\r\n\r\n[{"name": "remove-bg", "options": {"add_shadow": true, "bg_color": "pink"}}, {"name": "google-auto-tagging", "minConfidence": 80, "maxTags": 10}]\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="webhookUrl"\r\n\r\nurl\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteFile"\r\n\r\ntrue\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteTags"\r\n\r\nfalse\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteCustomMetadata"\r\n\r\ntrue\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="customMetadata"\r\n\r\n{"test100": 11}\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="transformation"\r\n\r\n{"pre": "h-100", "post": [{"type": "transformation", "value": "w-100"}]}\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteAITags"\r\n\r\nfalse\r\n----randomBoundary-----------------------\r\n' - - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual(url, responses.calls[0].request.url) - - - - @responses.activate - def test_upload_succeeds_with_url(self): - """ - Tests if upload succeeds - """ - URL.UPLOAD_BASE_URL = "http://test.com" - url = "%s%s" % (URL.UPLOAD_BASE_URL, "/api/v1/files/upload") - headers = create_headers_for_test() - responses.add( - responses.POST, - url, - body="""{ - "fileId": "fake_file_id1234", - "name": "file_name.jpg", - "size": 102117, - "versionInfo": { - "id": "62d670648cdb697522602b45", - "name": "Version 11" - }, - "filePath": "/testing-python-folder/file_name.jpg", - "url": "https://ik.imagekit.io/your_imagekit_id/testing-python-folder/file_name.jpg", - "fileType": "image", - "height": 700, - "width": 1050, - "thumbnailUrl": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/testing-python-folder/file_name.jpg", - "tags": [ - "abc", - "def" - ], - "AITags": [ - { - "name": "Computer", - "confidence": 97.66, - "source": "google-auto-tagging" - }, - { - "name": "Personal computer", - "confidence": 94.96, - "source": "google-auto-tagging" - } - ], - "isPrivateFile": true, - "extensionStatus": { - "remove-bg": "pending", - "google-auto-tagging": "success" - } - }""", - headers=headers, - ) - - file_upload_url = "https://file-examples.com/wp-content/uploads/2017/10/file_example_JPG_100kB.jpg" - resp = self.client.upload_file( - file=file_upload_url, - file_name="file_name.jpg", - options=UploadFileRequestOptions( - use_unique_file_name=False, - tags=["abc", "def"], - folder="/testing-python-folder/", - is_private_file=True, - response_fields=["is_private_file", "tags"], - extensions=( - { - "name": "remove-bg", - "options": {"add_shadow": True, "bg_color": "pink"}, - }, - {"name": "google-auto-tagging", "minConfidence": 80, "maxTags": 10}, - ), - webhook_url="url", - overwrite_file=True, - overwrite_ai_tags=False, - overwrite_tags=False, - overwrite_custom_metadata=True, - custom_metadata={"test100": 11}, - transformation={"pre": "h-100", "post": [{"type": "transformation", "value": "w-100"}]} - ), - ) - mock_response_metadata = { - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "http_status_code": 200, - "raw": { - "AITags": [ - { - "confidence": 97.66, - "name": "Computer", - "source": "google-auto-tagging", - }, - { - "confidence": 94.96, - "name": "Personal computer", - "source": "google-auto-tagging", - }, - ], - "extensionStatus": { - "google-auto-tagging": "success", - "remove-bg": "pending", - }, - "fileId": "fake_file_id1234", - "filePath": "/testing-python-folder/file_name.jpg", - "fileType": "image", - "height": 700, - "isPrivateFile": True, - "name": "file_name.jpg", - "size": 102117, - "tags": ["abc", "def"], - "thumbnailUrl": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/testing-python-folder/file_name.jpg", - "url": "https://ik.imagekit.io/your_imagekit_id/testing-python-folder/file_name.jpg", - "versionInfo": {"id": "62d670648cdb697522602b45", "name": "Version 11"}, - "width": 1050, - }, - } - request_body = b'----randomBoundary---------------------\r\nContent-Disposition: form-data; name="file"\r\n\r\nhttps://file-examples.com/wp-content/uploads/2017/10/file_example_JPG_100kB.jpg\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="fileName"\r\n\r\nfile_name.jpg\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="useUniqueFileName"\r\n\r\nfalse\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="tags"\r\n\r\nabc,def\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="folder"\r\n\r\n/testing-python-folder/\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="isPrivateFile"\r\n\r\ntrue\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="responseFields"\r\n\r\nisPrivateFile,tags\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="extensions"\r\n\r\n[{"name": "remove-bg", "options": {"add_shadow": true, "bg_color": "pink"}}, {"name": "google-auto-tagging", "minConfidence": 80, "maxTags": 10}]\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="webhookUrl"\r\n\r\nurl\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteFile"\r\n\r\ntrue\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteTags"\r\n\r\nfalse\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteCustomMetadata"\r\n\r\ntrue\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="customMetadata"\r\n\r\n{"test100": 11}\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="transformation"\r\n\r\n{"pre": "h-100", "post": [{"type": "transformation", "value": "w-100"}]}\r\n----randomBoundary---------------------\r\nContent-Disposition: form-data; name="overwriteAITags"\r\n\r\nfalse\r\n----randomBoundary-----------------------\r\n' - - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual(url, responses.calls[0].request.url) - - @responses.activate - def test_embedded_metadata_recieved_on_upload(self): - """ - Tests if upload succeeds - """ - URL.UPLOAD_BASE_URL = "http://test.com" - url = "%s%s" % (URL.UPLOAD_BASE_URL, "/api/v1/files/upload") - headers = create_headers_for_test() - responses.add( - responses.POST, - url, - body="""{ - "fileId": "fake_file_id1234", - "name": "file_name.jpg", - "size": 102117, - "versionInfo": { - "id": "62d670648cdb697522602b45", - "name": "Version 11" - }, - "filePath": "/testing-python-folder/file_name.jpg", - "url": "https://ik.imagekit.io/your_imagekit_id/testing-python-folder/file_name.jpg", - "fileType": "image", - "height": 700, - "width": 1050, - "thumbnailUrl": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/testing-python-folder/file_name.jpg", - "tags": [ - "abc", - "def" - ], - "AITags": [ - { - "name": "Computer", - "confidence": 97.66, - "source": "google-auto-tagging" - }, - { - "name": "Personal computer", - "confidence": 94.96, - "source": "google-auto-tagging" - } - ], - "embeddedMetadata":{ - "XResolution":1,"YResolution":1,"DateCreated":"2023-06-21T11:49:39.850Z","DateTimeCreated":"2023-06-21T11:49:39.850Z" - }, - "isPrivateFile": true, - "extensionStatus": { - "remove-bg": "pending", - "google-auto-tagging": "success" - } - }""", - headers=headers, - ) - with open(self.sample_image,"rb") as img: - resp = self.client.upload( - file=img, - file_name="test.jpeg", - options=UploadFileRequestOptions( - tags = ["test"], - response_fields=["embedded_metadata"] - ) - ) - x_resolution = 1 - y_resolution = 1 - date_created = "2023-06-21T11:49:39.850Z" - date_time_created = "2023-06-21T11:49:39.850Z" - self.assertEqual(resp.embedded_metadata.x_resolution,x_resolution) - self.assertEqual(resp.embedded_metadata.y_resolution,y_resolution) - self.assertEqual(resp.embedded_metadata.date_created,date_created) - self.assertEqual(resp.embedded_metadata.date_time_created,date_time_created) - - # self.assertEqual(request_body, responses.calls[0].request.body) - # self.assertEqual( - # camel_dict_to_snake_dict(mock_response_metadata), - # resp.response_metadata.__dict__, - # ) - @responses.activate() - def test_upload_file_result_on_upload_with_non_breaking_changes_in_response(self): - """ - Tests if upload succeeds - """ - URL.UPLOAD_BASE_URL = "http://test.com" - url = "%s%s" % (URL.UPLOAD_BASE_URL, "/api/v1/files/upload") - headers = create_headers_for_test() - responses.add( - responses.POST, - url, - body="""{ - "fileId": "fake_file_id1234", - "name": "file_name.jpg", - "size": 102117, - "versionInfo": { - "id": "62d670648cdb697522602b45", - "name": "Version 11", - "decription":"Removed Background" - }, - "filePath": "/testing-python-folder/file_name.jpg", - "url": "https://ik.imagekit.io/your_imagekit_id/testing-python-folder/file_name.jpg", - "fileType": "image", - "height": 700, - "width": 1050, - "resolution":120, - "thumbnailUrl": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/testing-python-folder/file_name.jpg", - "tags": [ - "abc", - "def" - ], - "AITags": [ - { - "name": "Computer", - "confidence": 97.66, - "source": "google-auto-tagging", - "description":"automatically created tag" - }, - { - "name": "Personal computer", - "confidence": 94.96, - "source": "google-auto-tagging", - "description":"automatically created tag" - } - ], - "embeddedMetadata":{ - "XResolution":1,"YResolution":1,"DateCreated":"2023-06-21T11:49:39.850Z","DateTimeCreated":"2023-06-21T11:49:39.850Z","DPI":120 - }, - "isPrivateFile": true, - "extensionStatus": { - "remove-bg": "pending", - "google-auto-tagging": "success", - "image-enhancing":"pending" - } - }""", - headers=headers, - ) - with open(self.sample_image,"rb") as img: - resp = self.client.upload( - file=img, - file_name="test.jpeg", - options=UploadFileRequestOptions( - tags = ["test"], - response_fields=["embedded_metadata"] - ) - ) - self.assertEqual(resp.embedded_metadata.dpi,120) - self.assertEqual(resp.resolution,120) - self.assertEqual(resp.ai_tags[0].name,"Computer") - self.assertEqual(resp.ai_tags[0].description,"automatically created tag") - self.assertEqual(resp.extension_status["image-enhancing"],"pending") - - - def test_upload_fails_without_file_name(self) -> None: - """Test upload raises error on missing required params""" - try: - with open(self.sample_image, mode="rb") as img: - imgstr = base64.b64encode(img.read()) - self.client.upload_file(file=imgstr) - except TypeError as e: - self.assertEqual( - {"message": "Missing fileName parameter for upload", "help": ""}, - e.args[0], - ) - - def test_upload_fails_without_file(self) -> None: - """Test upload raises error on missing required params""" - try: - self.client.upload_file(file_name="file_name.jpg") - except TypeError as e: - self.assertEqual( - {"message": "Missing file parameter for upload", "help": ""}, e.args[0] - ) - - @responses.activate - def test_upload_fails_with_400_exception(self) -> None: - """Test upload raises 400 error""" - - URL.UPLOAD_BASE_URL = "http://test.com" - url = "%s%s" % (URL.UPLOAD_BASE_URL, "/api/v1/files/upload") - try: - responses.add( - responses.POST, - url, - status=400, - body="""{ - "message": "A file with the same name already exists at the exact location. We " - "could not overwrite it because both overwriteFile and " - "useUniqueFileName are set to false." - }""", - ) - self.client.upload_file( - file=self.image, - file_name=self.filename, - options=UploadFileRequestOptions( - use_unique_file_name=False, - tags=["abc", "def"], - folder="/testing-python-folder/", - is_private_file=False, - custom_coordinates="10,10,20,20", - response_fields=[ - "tags", - "custom_coordinates", - "is_private_file", - "embedded_metadata", - "custom_metadata", - ], - extensions=( - { - "name": "remove-bg", - "options": {"add_shadow": True, "bg_color": "pink"}, - }, - { - "name": "google-auto-tagging", - "minConfidence": 80, - "maxTags": 10, - }, - ), - webhook_url="https://webhook.site/c78d617f-33bc-40d9-9e61-608999721e2e", - overwrite_file=True, - overwrite_ai_tags=False, - overwrite_tags=False, - overwrite_custom_metadata=True, - custom_metadata={"testss": 12}, - ), - ) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual( - "A file with the same name already exists at the exact location. We could not overwrite " - "it because both overwriteFile and useUniqueFileName are set to false.", - e.message, - ) - self.assertEqual(400, e.response_metadata.http_status_code) - - -class TestListFiles(ClientTestCase): - """ - TestListFiles class used to test list_files method - """ - - @responses.activate - def test_list_files_fails_on_unauthenticated_request(self) -> None: - """Tests unauthenticated request restricted for list_files method""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files".format(URL.API_BASE_URL) - try: - responses.add( - responses.GET, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.list_files(self.options) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual("Your account cannot be authenticated.", e.message) - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_list_files_succeeds_with_basic_request_tags_with_array(self) -> None: - """ - Tests if list_files work with options which contains type, sort, path, searchQuery, fileType, limit, skip and tags - """ - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files".format(URL.API_BASE_URL) - - headers = create_headers_for_test() - responses.add( - responses.GET, - url, - body="""[{ - "type": "file", - "name": "sample-cat-image_gr64HPlJS.jpg", - "createdAt": "2022-06-15T08:19:00.843Z", - "updatedAt": "2022-06-15T08:19:45.169Z", - "fileId": "62a995f4d875ec08dc587b72", - "tags": ["Tag_1", " Tag_2", " Tag_3", "tag-to-add-2"], - "AITags": "", - "versionInfo": { - "id": "62a995f4d875ec08dc587b72", - "name": "Version 1" - }, - "embeddedMetadata": { - "XResolution": 250, - "YResolution": 250, - "DateCreated": "2022-06-15T08:19:01.523Z", - "DateTimeCreated": "2022-06-15T08:19:01.524Z" - }, - "customCoordinates": "10,10,20,20", - "customMetadata": { - "test100": 10 - }, - "isPrivateFile": false, - "url": "https://ik.imagekit.io/your_imagekit_id/sample-cat-image_gr64HPlJS.jpg", - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/sample-cat-image_gr64HPlJS.jpg", - "fileType": "image", - "filePath": "/sample-cat-image_gr64HPlJS.jpg", - "height": 354, - "width": 236, - "size": 23023, - "hasAlpha": false, - "mime": "image/jpeg" - }]""", - headers=headers, - match=[ - matchers.query_string_matcher("type=file&sort=ASC_CREATED&path=%2F&searchQuery=created_at+%3E%3D+%272d%27+OR+size+%3C+%272mb%27+OR+format%3D%27png%27&fileType=all&limit=1&skip=0&tags=Tag-1%2C+Tag-2%2C+Tag-3" - ) - ], - ) - - resp = self.client.list_files(self.opt) - - mock_response_metadata = { - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 200, - "raw": [ - { - "AITags": "", - "createdAt": "2022-06-15T08:19:00.843Z", - "customCoordinates": "10,10,20,20", - "customMetadata": {"test100": 10}, - "embeddedMetadata": { - "DateCreated": "2022-06-15T08:19:01.523Z", - "DateTimeCreated": "2022-06-15T08:19:01.524Z", - "XResolution": 250, - "YResolution": 250, - }, - "fileId": "62a995f4d875ec08dc587b72", - "filePath": "/sample-cat-image_gr64HPlJS.jpg", - "fileType": "image", - "hasAlpha": False, - "height": 354, - "isPrivateFile": False, - "mime": "image/jpeg", - "name": "sample-cat-image_gr64HPlJS.jpg", - "size": 23023, - "tags": ["Tag_1", " Tag_2", " Tag_3", "tag-to-add-2"], - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/sample-cat-image_gr64HPlJS.jpg", - "type": "file", - "updatedAt": "2022-06-15T08:19:45.169Z", - "url": "https://ik.imagekit.io/your_imagekit_id/sample-cat-image_gr64HPlJS.jpg", - "versionInfo": { - "id": "62a995f4d875ec08dc587b72", - "name": "Version " "1", - }, - "width": 236, - } - ], - } - self.assertEqual( - "http://test.com/v1/files?type=file&sort=ASC_CREATED&path=%2F&searchQuery=created_at+%3E%3D+%272d%27+OR+size+%3C+%272mb%27+OR+format%3D%27png%27&fileType=all&limit=1&skip=0&tags=Tag-1%2C+Tag-2%2C+Tag-3", - responses.calls[0].request.url, - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - - @responses.activate - def test_list_files_succeeds_with_basic_request(self) -> None: - """ - Tests if list_files work with options which contains type, sort, path, searchQuery, fileType, limit, skip and tags - """ - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files".format(URL.API_BASE_URL) - - headers = create_headers_for_test() - responses.add( - responses.GET, - url, - body="""[{ - "type": "file", - "name": "sample-cat-image_gr64HPlJS.jpg", - "createdAt": "2022-06-15T08:19:00.843Z", - "updatedAt": "2022-06-15T08:19:45.169Z", - "fileId": "62a995f4d875ec08dc587b72", - "tags": ["Tag_1", " Tag_2", " Tag_3", "tag-to-add-2"], - "AITags": "", - "versionInfo": { - "id": "62a995f4d875ec08dc587b72", - "name": "Version 1" - }, - "embeddedMetadata": { - "XResolution": 250, - "YResolution": 250, - "DateCreated": "2022-06-15T08:19:01.523Z", - "DateTimeCreated": "2022-06-15T08:19:01.524Z" - }, - "customCoordinates": "10,10,20,20", - "customMetadata": { - "test100": 10 - }, - "isPrivateFile": false, - "url": "https://ik.imagekit.io/your_imagekit_id/sample-cat-image_gr64HPlJS.jpg", - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/sample-cat-image_gr64HPlJS.jpg", - "fileType": "image", - "filePath": "/sample-cat-image_gr64HPlJS.jpg", - "height": 354, - "width": 236, - "size": 23023, - "hasAlpha": false, - "mime": "image/jpeg" - }]""", - headers=headers, - match=[ - matchers.query_string_matcher("type=file&sort=ASC_CREATED&path=%2F&searchQuery=created_at+%3E%3D+%272d%27+OR+size+%3C+%272mb%27+OR+format%3D%27png%27&fileType=all&limit=1&skip=0&tags=Tag-1%2C+Tag-2%2C+Tag-3" - ) - ], - ) - - resp = self.client.list_files(self.options) - - mock_response_metadata = { - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 200, - "raw": [ - { - "AITags": "", - "createdAt": "2022-06-15T08:19:00.843Z", - "customCoordinates": "10,10,20,20", - "customMetadata": {"test100": 10}, - "embeddedMetadata": { - "DateCreated": "2022-06-15T08:19:01.523Z", - "DateTimeCreated": "2022-06-15T08:19:01.524Z", - "XResolution": 250, - "YResolution": 250, - }, - "fileId": "62a995f4d875ec08dc587b72", - "filePath": "/sample-cat-image_gr64HPlJS.jpg", - "fileType": "image", - "hasAlpha": False, - "height": 354, - "isPrivateFile": False, - "mime": "image/jpeg", - "name": "sample-cat-image_gr64HPlJS.jpg", - "size": 23023, - "tags": ["Tag_1", " Tag_2", " Tag_3", "tag-to-add-2"], - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/sample-cat-image_gr64HPlJS.jpg", - "type": "file", - "updatedAt": "2022-06-15T08:19:45.169Z", - "url": "https://ik.imagekit.io/your_imagekit_id/sample-cat-image_gr64HPlJS.jpg", - "versionInfo": { - "id": "62a995f4d875ec08dc587b72", - "name": "Version " "1", - }, - "width": 236, - } - ], - } - self.assertEqual( - "http://test.com/v1/files?type=file&sort=ASC_CREATED&path=%2F&searchQuery=created_at+%3E%3D+%272d%27+OR+size+%3C+%272mb%27+OR+format%3D%27png%27&fileType=all&limit=1&skip=0&tags=Tag-1%2C+Tag-2%2C+Tag-3", - responses.calls[0].request.url, - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - - @responses.activate - def test_list_files_fails_with_400_exception(self) -> None: - """Test get list of files raises 400 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files".format(URL.API_BASE_URL) - try: - responses.add( - responses.GET, - url, - status=400, - body="""{"message": "Invalid search query - createdAt field must have a valid date value. Make " - "sure the value is enclosed within quotes. Please refer to the " - "documentation for syntax specification.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - match=[ - matchers.query_string_matcher("type=file&sort=ASC_CREATED&path=%2F&searchQuery=created_at+%3E%3D+%272d%27+OR+size+%3C+%272mb%27+OR+format%3D%27png%27&fileType=all&limit=1&skip=0&tags=Tag-1%2C+Tag-2%2C+Tag-3" - ) - ], - ) - self.client.list_files(self.options) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual( - "Invalid search query - createdAt field must have a valid date value. Make " - "sure the value is enclosed within quotes. Please refer to the " - "documentation for syntax specification.", - e.message, - ) - self.assertEqual(400, e.response_metadata.http_status_code) - - -class TestGetFileDetails(ClientTestCase): - """ - TestGetFileDetails class used to test get_file_details method - """ - - file_id = "fake_file_id1234" - file_url = "https://example.com/default.jpg" - - @responses.activate - def test_get_file_details_fails_on_unauthenticated_request(self) -> None: - """Tests of get_file_details raise error on unauthenticated request""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/details".format(URL.API_BASE_URL, self.file_id) - try: - responses.add( - responses.GET, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.get_file_details(self.file_id) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_file_details_succeeds_with_id(self) -> None: - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/details".format(URL.API_BASE_URL, self.file_id) - - headers = create_headers_for_test() - responses.add( - responses.GET, - url, - body="""{ - "type": "file", - "name": "default-image.jpg", - "createdAt": "2022-06-15T08:19:00.843Z", - "updatedAt": "2022-08-19T12:19:22.726Z", - "fileId": "fake_file_id1234", - "tags": [ - "{Software", - " Developer", - " Engineer}", - "tag-to-add-2" - ], - "AITags": null, - "versionInfo": { - "id": "62a995f4d875ec08dc587b72", - "name": "Version 1" - }, - "embeddedMetadata": { - "XResolution": 250, - "YResolution": 250, - "DateCreated": "2022-06-15T08:19:01.523Z", - "DateTimeCreated": "2022-06-15T08:19:01.524Z" - }, - "customCoordinates": "10,10,20,20", - "customMetadata": { - "test100": 10 - }, - "isPrivateFile": false, - "url": "https://ik.imagekit.io/xyxt2lnil/default-image.jpg", - "thumbnail": "https://ik.imagekit.io/xyxt2lnil/tr:n-ik_ml_thumbnail/default-image.jpg", - "fileType": "image", - "filePath": "/default-image.jpg", - "height": 354, - "width": 236, - "size": 23023, - "hasAlpha": false, - "mime": "image/jpeg" - }""", - headers=headers, - ) - resp = self.client.get_file_details(self.file_id) - - mock_response_metadata = { - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "http_status_code": 200, - "raw": { - "AITags": None, - "createdAt": "2022-06-15T08:19:00.843Z", - "customCoordinates": "10,10,20,20", - "customMetadata": {"test100": 10}, - "embeddedMetadata": { - "DateCreated": "2022-06-15T08:19:01.523Z", - "DateTimeCreated": "2022-06-15T08:19:01.524Z", - "XResolution": 250, - "YResolution": 250, - }, - "fileId": "fake_file_id1234", - "filePath": "/default-image.jpg", - "fileType": "image", - "hasAlpha": False, - "height": 354, - "isPrivateFile": False, - "mime": "image/jpeg", - "name": "default-image.jpg", - "size": 23023, - "tags": ["{Software", " Developer", " Engineer}", "tag-to-add-2"], - "thumbnail": "https://ik.imagekit.io/xyxt2lnil/tr:n-ik_ml_thumbnail/default-image.jpg", - "type": "file", - "updatedAt": "2022-08-19T12:19:22.726Z", - "url": "https://ik.imagekit.io/xyxt2lnil/default-image.jpg", - "versionInfo": {"id": "62a995f4d875ec08dc587b72", "name": "Version 1"}, - "width": 236, - }, - } - - self.assertEqual( - "http://test.com/v1/files/fake_file_id1234/details", - responses.calls[0].request.url, - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("fake_file_id1234", resp.file_id) - - @responses.activate - def test_file_details_fails_with_400_exception(self) -> None: - """Test get file details raises 400 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/details".format(URL.API_BASE_URL, self.file_id) - try: - responses.add( - responses.GET, - url, - status=400, - body="""{"message": "Your request contains invalid fileId parameter.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.get_file_details(self.file_id) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual( - "Your request contains invalid fileId parameter.", e.message - ) - self.assertEqual(400, e.response_metadata.http_status_code) - - -class TestDeleteFile(ClientTestCase): - file_id = "fax_abx1223" - bulk_delete_ids = ["fake_123", "fake_222"] - - @responses.activate - def test_bulk_file_delete_fails_on_unauthenticated_request(self) -> None: - """Test bulk_file_delete on unauthenticated request - this function checks if raises error on unauthenticated request - to check if bulk_delete is only restricted to authenticated - requests - """ - - URL.API_BASE_URL = "http://test.com" - url = URL.API_BASE_URL + "/v1/files" + URL.BULK_FILE_DELETE - try: - responses.add( - responses.POST, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.bulk_file_delete(self.bulk_delete_ids) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(e.response_metadata.http_status_code, 403) - - @responses.activate - def test_bulk_file_delete_succeeds(self): - """Test bulk_delete on authenticated request - this function tests if bulk_file_delete working properly - """ - - URL.API_BASE_URL = "http://test.com" - url = URL.API_BASE_URL + "/v1/files" + URL.BULK_FILE_DELETE - headers = {} - headers.update(get_auth_headers_for_test()) - - responses.add( - responses.POST, - url, - body='{"successfullyDeletedFileIds": ["fake_123", "fake_222"]}', - headers=headers, - content_type="application/json", - ) - - resp = self.client.bulk_file_delete(self.bulk_delete_ids) - - mock_response_metadata = { - "raw": {"successfullyDeletedFileIds": ["fake_123", "fake_222"]}, - "httpStatusCode": 200, - "headers": { - "Content-Type": "application/json", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - } - self.assertEqual( - '{"fileIds": ["fake_123", "fake_222"]}', responses.calls[0].request.body - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual(["fake_123", "fake_222"], resp.successfully_deleted_file_ids) - self.assertEqual( - "http://test.com/v1/files/batch/deleteByFileIds", - responses.calls[0].request.url, - ) - - @responses.activate - def test_bulk_file_delete_succeeds_and_recieves_extra_non_breaking_changes_from_apii(self): - """Test bulk_delete on authenticated request - this function tests if bulk_file_delete working properly - """ - - URL.API_BASE_URL = "http://test.com" - url = URL.API_BASE_URL + "/v1/files" + URL.BULK_FILE_DELETE - headers = {} - headers.update(get_auth_headers_for_test()) - - responses.add( - responses.POST, - url, - body='{"successfullyDeletedFileIds": ["fake_123"],"nonDeletedFields":["fake_222"]}', - headers=headers, - content_type="application/json", - ) - - resp = self.client.bulk_file_delete(self.bulk_delete_ids) - - mock_response_metadata = { - "raw": {"successfullyDeletedFileIds": ["fake_123"],"nonDeletedFields":["fake_222"]}, - "httpStatusCode": 200, - "headers": { - "Content-Type": "application/json", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - } - self.assertEqual( - '{"fileIds": ["fake_123", "fake_222"]}', responses.calls[0].request.body - ) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual(["fake_123"], resp.successfully_deleted_file_ids) - self.assertEqual(["fake_222"], resp.non_deleted_fields) - self.assertEqual( - "http://test.com/v1/files/batch/deleteByFileIds", - responses.calls[0].request.url, - ) - @responses.activate - def test_bulk_file_delete_fails_with_404_exception(self) -> None: - """Test bulk_file_delete raises 404 error""" - - URL.API_BASE_URL = "http://test.com" - url = URL.API_BASE_URL + "/v1/files" + URL.BULK_FILE_DELETE - headers = {} - headers.update(get_auth_headers_for_test()) - try: - responses.add( - responses.POST, - url, - status=404, - body="""{ - "message": "The requested file(s) does not exist.", - "help": "For support kindly contact us at support@imagekit.io .", - "missingFileIds": ["fake_123", "fake_222"] - }""", - headers=headers, - ) - self.client.bulk_file_delete(self.bulk_delete_ids) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("The requested file(s) does not exist.", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - self.assertEqual( - ["fake_123", "fake_222"], e.response_metadata.raw["missingFileIds"] - ) - - @responses.activate - def test_file_delete_fails_with_400_exception(self): - """Test delete_file on unavailable content - this function raising 400 if the file - is not available - """ - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}".format(URL.API_BASE_URL, self.file_id) - headers = {} - headers.update(get_auth_headers_for_test()) - try: - responses.add( - responses.DELETE, - url, - status=400, - body="""{ - "message": "Your request contains invalid fileId parameter.", - "help": "For support kindly contact us at support@imagekit.io ." - }""", - headers=headers, - ) - self.client.delete_file(self.file_id) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual( - "Your request contains invalid fileId parameter.", e.message - ) - self.assertEqual(400, e.response_metadata.http_status_code) - - @responses.activate - def test_file_delete_succeeds(self): - """Test delete file on authenticated request - this function tests if delete_file working properly - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}".format(URL.API_BASE_URL, self.file_id) - headers = {} - headers.update(get_auth_headers_for_test()) - - responses.add(responses.DELETE, url , status=204, headers=headers, content_type="application/json") - - resp = self.client.delete_file(self.file_id) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 204, - "raw": None, - } - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/files/fax_abx1223", responses.calls[0].request.url - ) - - -class TestPurgeCache(ClientTestCase): - fake_image_url = "https://example.com/fakeid/fakeimage.jpg" - - @responses.activate - def test_purge_file_cache_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.API_BASE_URL = "http://test.com" - url = URL.API_BASE_URL + "/v1/files/purge" - try: - responses.add( - responses.POST, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.purge_file_cache(self.fake_image_url) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_purge_file_cache_fails_with_400(self): - """ - Tests if the purge_file_cache fails with 400 - """ - URL.API_BASE_URL = "http://test.com" - url = URL.API_BASE_URL + "/v1/files/purge" - try: - responses.add( - responses.POST, - url, - status=400, - body='{"message": "Invalid url"}', - ) - self.client.purge_file_cache("url") - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual("Invalid url", e.message) - self.assertEqual(400, e.response_metadata.http_status_code) - - @responses.activate - def test_purge_file_cache_succeeds(self): - """ - Tests if purge_file_cache succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = URL.API_BASE_URL + "/v1/files/purge" - responses.add( - responses.POST, - url, - status=201, - body='{"requestId": "requestId"}', - ) - resp = self.client.purge_file_cache(self.fake_image_url) - mock_response_metadata = { - "raw": {"requestId": "requestId"}, - "httpStatusCode": 201, - "headers": {"Content-Type": "text/plain"}, - } - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("requestId", resp.request_id) - self.assertEqual( - "http://test.com/v1/files/purge", responses.calls[0].request.url - ) - self.assertEqual( - '{"url": "https://example.com/fakeid/fakeimage.jpg"}', - responses.calls[0].request.body, - ) - - -class TestPurgeCacheStatus(ClientTestCase): - cache_request_id = "fake1234" - - @responses.activate - def test_purge_file_cache_status_fails_with_400(self): - """ - Tests if the purge_file_cache_status fails with 400 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/purge/{}".format(URL.API_BASE_URL, self.cache_request_id) - try: - responses.add( - responses.GET, - url, - status=400, - body="""{"message": "No request found for this requestId.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.get_purge_file_cache_status(self.cache_request_id) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual("No request found for this requestId.", e.message) - self.assertEqual(400, e.response_metadata.http_status_code) - - @responses.activate - def test_purge_file_cache_status_succeeds(self): - """ - Tests if purge_file_cache_status succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/purge/{}".format(URL.API_BASE_URL, self.cache_request_id) - responses.add( - responses.GET, - url, - body="""{"status": "Completed"}""", - ) - resp = self.client.get_purge_file_cache_status(self.cache_request_id) - mock_response_metadata = { - "headers": {"Content-Type": "text/plain"}, - "httpStatusCode": 200, - "raw": {"status": "Completed"}, - } - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("Completed", resp.status) - self.assertEqual( - "http://test.com/v1/files/purge/fake1234", responses.calls[0].request.url - ) - - -class TestGetMetaData(ClientTestCase): - file_id = "fake_file_xbc" - - fake_image_url = "https://example.com/fakeid/fakeimage.jpg" - - @responses.activate - def test_get_file_metadata_fails_with_400(self): - """ - Tests if the get_file_metadata fails with 400 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/metadata".format(URL.API_BASE_URL, self.file_id) - try: - responses.add( - responses.GET, - url, - status=400, - body="""{"message": "Your request contains invalid fileId parameter.", - "help": "For support kindly contact us at support@imagekit.io .", - "type": "INVALID_PARAM_ERROR"}""", - ) - self.client.get_file_metadata(self.file_id) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual( - "Your request contains invalid fileId parameter.", e.message - ) - self.assertEqual(400, e.response_metadata.http_status_code) - self.assertEqual("INVALID_PARAM_ERROR", e.response_metadata.raw["type"]) - - @responses.activate - def test_get_file_metadata_succeeds(self): - """ - Tests if get_file_metadata succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/metadata".format(URL.API_BASE_URL, self.file_id) - responses.add( - responses.GET, - url, - body="""{ - "height": 354, - "width": 236, - "size": 7390, - "format": "jpg", - "hasColorProfile": false, - "quality": 0, - "density": 250, - "hasTransparency": false, - "exif": {}, - "pHash": "2e0ed1f12eda9525" - }""", - ) - resp = self.client.get_file_metadata(self.file_id) - mock_response_metadata = { - "headers": {"Content-Type": "text/plain"}, - "httpStatusCode": 200, - "raw": { - "density": 250, - "exif": {}, - "format": "jpg", - "hasColorProfile": False, - "hasTransparency": False, - "height": 354, - "pHash": "2e0ed1f12eda9525", - "quality": 0, - "size": 7390, - "width": 236, - }, - } - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/files/fake_file_xbc/metadata", - responses.calls[0].request.url, - ) - - @responses.activate - def test_get_remote_file_url_metadata_fails_with_400(self): - """ - Tests if the get_remote_file_url_metadata fails with 400 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/metadata".format(URL.API_BASE_URL) - try: - responses.add( - responses.GET, - url, - status=400, - body="""{ - "message": "https://example.com/fakeid/fakeimage.jpg should be accessible using your ImageKit.io account.", - "help": "For support kindly contact us at support@imagekit.io ." - }""", - match=[ - matchers.query_string_matcher( - "url=https://example.com/fakeid/fakeimage.jpg" - ) - ], - ) - self.client.get_remote_file_url_metadata(self.fake_image_url) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual( - "https://example.com/fakeid/fakeimage.jpg should be accessible using your ImageKit.io account.", - e.message, - ) - self.assertEqual(400, e.response_metadata.http_status_code) - - @responses.activate - def test_get_remote_file_url_metadata_succeeds(self): - """ - Tests if get_remote_file_url_metadata succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/metadata".format(URL.API_BASE_URL) - responses.add( - responses.GET, - url, - body="""{ - "height": 354, - "width": 236, - "size": 7390, - "format": "jpg", - "hasColorProfile": false, - "quality": 0, - "density": 250, - "hasTransparency": false, - "exif": {}, - "pHash": "2e0ed1f12eda9525" - }""", - match=[ - matchers.query_string_matcher( - "url=https://example.com/fakeid/fakeimage.jpg" - ) - ], - ) - resp = self.client.get_remote_file_url_metadata(self.fake_image_url) - mock_response_metadata = { - "headers": {"Content-Type": "text/plain"}, - "httpStatusCode": 200, - "raw": { - "density": 250, - "exif": {}, - "format": "jpg", - "hasColorProfile": False, - "hasTransparency": False, - "height": 354, - "pHash": "2e0ed1f12eda9525", - "quality": 0, - "size": 7390, - "width": 236, - }, - } - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/metadata?url=https%3A%2F%2Fexample.com%2Ffakeid%2Ffakeimage.jpg", - responses.calls[0].request.url, - ) - - -class TestUpdateFileDetails(ClientTestCase): - """ - TestUpdateFileDetails class used to update file details method - """ - - file_id = "fake_123" - - valid_options = UpdateFileRequestOptions( - tags=["tag1", "tag2"], custom_coordinates="10,10,100,100" - ) - - @responses.activate - def test_update_file_details_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/details/".format(URL.API_BASE_URL, self.file_id) - try: - responses.add( - responses.PATCH, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.update_file_details( - file_id=self.file_id, options=self.valid_options - ) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_update_file_details_succeeds_with_id(self): - """ - Tests if update file details succeeds with file id - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/details/".format(URL.API_BASE_URL, self.file_id) - headers = {} - headers.update(get_auth_headers_for_test()) - responses.add( - responses.PATCH, - url, - content_type="application/json", - body="""{ - "type": "file", - "name": "default-image.jpg", - "createdAt": "2022-07-21T10:31:22.529Z", - "updatedAt": "2022-07-21T10:37:11.848Z", - "fileId": "fake_123", - "tags": ["tag1", "tag2"], - "AITags": [{ - "name": "Corridor", - "confidence": 99.39, - "source": "aws-auto-tagging" - }, { - "name": "Floor", - "confidence": 97.59, - "source": "aws-auto-tagging" - }], - "versionInfo": { - "id": "versionId", - "name": "Version 2" - }, - "embeddedMetadata": { - "XResolution": 1, - "YResolution": 1, - "DateCreated": "2022-07-21T10:35:34.497Z", - "DateTimeCreated": "2022-07-21T10:35:34.500Z" - }, - "customCoordinates": "10,10,100,100", - "customMetadata": { - "test": 11 - }, - "isPrivateFile": false, - "url": "https://ik.imagekit.io/your_imagekit_id/default-image.jpg", - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/default-image.jpg", - "fileType": "image", - "filePath": "/default-image.jpg", - "height": 1000, - "width": 1000, - "size": 184425, - "hasAlpha": false, - "mime": "image/jpeg", - "extensionStatus": { - "remove-bg": "pending", - "google-auto-tagging": "success" - } - }""", - headers=headers, - ) - - request_body = json.dumps( - json.loads( - """{ - "removeAITags": ["ai-tag1", "ai-tag2"], - "webhookUrl": "url", - "extensions": [{ - "name": "remove-bg", - "options": { - "add_shadow": true, - "bg_color": "red" - } - }, { - "name": "google-auto-tagging", - "minConfidence": 80, - "maxTags": 10 - }], - "tags": ["tag1", "tag2"], - "customCoordinates": "10,10,100,100", - "customMetadata": { - "test": 11 - } - }""" - ) - ) - resp = self.client.update_file_details( - file_id=self.file_id, - options=UpdateFileRequestOptions( - remove_ai_tags=["ai-tag1", "ai-tag2"], - webhook_url="url", - extensions=[ - { - "name": "remove-bg", - "options": {"add_shadow": True, "bg_color": "red"}, - }, - {"name": "google-auto-tagging", "minConfidence": 80, "maxTags": 10}, - ], - tags=["tag1", "tag2"], - custom_coordinates="10,10,100,100", - custom_metadata={"test": 11}, - ), - ) - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "http_status_code": 200, - "raw": { - "AITags": [ - { - "confidence": 99.39, - "name": "Corridor", - "source": "aws-auto-tagging", - }, - { - "confidence": 97.59, - "name": "Floor", - "source": "aws-auto-tagging", - }, - ], - "createdAt": "2022-07-21T10:31:22.529Z", - "customCoordinates": "10,10,100,100", - "customMetadata": {"test": 11}, - "embeddedMetadata": { - "DateCreated": "2022-07-21T10:35:34.497Z", - "DateTimeCreated": "2022-07-21T10:35:34.500Z", - "XResolution": 1, - "YResolution": 1, - }, - "extensionStatus": { - "google-auto-tagging": "success", - "remove-bg": "pending", - }, - "fileId": "fake_123", - "filePath": "/default-image.jpg", - "fileType": "image", - "hasAlpha": False, - "height": 1000, - "isPrivateFile": False, - "mime": "image/jpeg", - "name": "default-image.jpg", - "size": 184425, - "tags": ["tag1", "tag2"], - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/default-image.jpg", - "type": "file", - "updatedAt": "2022-07-21T10:37:11.848Z", - "url": "https://ik.imagekit.io/your_imagekit_id/default-image.jpg", - "versionInfo": {"id": "versionId", "name": "Version 2"}, - "width": 1000, - }, - } - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("fake_123", resp.file_id) - self.assertEqual( - "http://test.com/v1/files/fake_123/details/", responses.calls[0].request.url - ) - - @responses.activate - def test_update_file_publish_status_succeeds_(self): - """ - Tests if update file publish status succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/details/".format(URL.API_BASE_URL, self.file_id) - headers = {} - headers.update(get_auth_headers_for_test()) - responses.add( - responses.PATCH, - url, - content_type="application/json", - body="""{ - "type": "file", - "name": "default-image.jpg", - "createdAt": "2022-07-21T10:31:22.529Z", - "updatedAt": "2022-07-21T10:37:11.848Z", - "fileId": "fake_123", - "tags": ["tag1", "tag2"], - "AITags": [{ - "name": "Corridor", - "confidence": 99.39, - "source": "aws-auto-tagging" - }, { - "name": "Floor", - "confidence": 97.59, - "source": "aws-auto-tagging" - }], - "versionInfo": { - "id": "versionId", - "name": "Version 2" - }, - "embeddedMetadata": { - "XResolution": 1, - "YResolution": 1, - "DateCreated": "2022-07-21T10:35:34.497Z", - "DateTimeCreated": "2022-07-21T10:35:34.500Z" - }, - "customCoordinates": "10,10,100,100", - "customMetadata": { - "test": 11 - }, - "isPrivateFile": false, - "url": "https://ik.imagekit.io/your_imagekit_id/default-image.jpg", - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/default-image.jpg", - "fileType": "image", - "filePath": "/default-image.jpg", - "height": 1000, - "width": 1000, - "size": 184425, - "hasAlpha": false, - "mime": "image/jpeg", - "extensionStatus": { - "remove-bg": "pending", - "google-auto-tagging": "success" - } - }""", - headers=headers, - ) - - request_body = json.dumps( - json.loads( - """{ - "publish": { - "isPublished": true, - "includeFileVersions": true - } - }""" - ) - ) - resp = self.client.update_file_details( - file_id=self.file_id, - options=UpdateFileRequestOptions( - publish={ - "isPublished": True, - "includeFileVersions": True - } - ), - ) - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "http_status_code": 200, - "raw": { - "AITags": [ - { - "confidence": 99.39, - "name": "Corridor", - "source": "aws-auto-tagging", - }, - { - "confidence": 97.59, - "name": "Floor", - "source": "aws-auto-tagging", - }, - ], - "createdAt": "2022-07-21T10:31:22.529Z", - "customCoordinates": "10,10,100,100", - "customMetadata": {"test": 11}, - "embeddedMetadata": { - "DateCreated": "2022-07-21T10:35:34.497Z", - "DateTimeCreated": "2022-07-21T10:35:34.500Z", - "XResolution": 1, - "YResolution": 1, - }, - "extensionStatus": { - "google-auto-tagging": "success", - "remove-bg": "pending", - }, - "fileId": "fake_123", - "filePath": "/default-image.jpg", - "fileType": "image", - "hasAlpha": False, - "height": 1000, - "isPrivateFile": False, - "mime": "image/jpeg", - "name": "default-image.jpg", - "size": 184425, - "tags": ["tag1", "tag2"], - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/default-image.jpg", - "type": "file", - "updatedAt": "2022-07-21T10:37:11.848Z", - "url": "https://ik.imagekit.io/your_imagekit_id/default-image.jpg", - "versionInfo": {"id": "versionId", "name": "Version 2"}, - "width": 1000, - }, - } - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("fake_123", resp.file_id) - self.assertEqual( - "http://test.com/v1/files/fake_123/details/", responses.calls[0].request.url - ) - - @responses.activate - def test_update_file_details_fails_with_404_exception(self) -> None: - """Test update file details raises 404 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/details/".format(URL.API_BASE_URL, self.file_id) - try: - responses.add( - responses.PATCH, - url, - status=404, - body="""{"message": "The requested file does not exist.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.update_file_details( - file_id=self.file_id, - options=UpdateFileRequestOptions( - remove_ai_tags=["ai-tag1", "ai-tag2"], - webhook_url="url", - extensions=[ - { - "name": "remove-bg", - "options": {"add_shadow": True, "bg_color": "red"}, - }, - { - "name": "google-auto-tagging", - "minConfidence": 80, - "maxTags": 10, - }, - ], - tags=["tag1", "tag2"], - custom_coordinates="10,10,100,100", - custom_metadata={"test": 11}, - ), - ) - self.assertRaises(UnknownException) - except UnknownException as e: - self.assertEqual("The requested file does not exist.", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - - -class TestGetFileVersions(ClientTestCase): - """ - TestGetFileVersions class used to get file versions and it's details - """ - - file_id = "fake_123" - - version_id = "fake_version_123" - - valid_options = {"tags": ["tag1", "tag2"], "custom_coordinates": "10,10,100,100"} - - @responses.activate - def test_get_file_versions_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions".format(URL.API_BASE_URL, self.file_id) - try: - responses.add( - responses.GET, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.get_file_versions(self.file_id) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_get_file_versions_succeeds_with_id(self): - """ - Tests if get file versions succeeds with file id - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions".format(URL.API_BASE_URL, self.file_id) - - headers = {} - headers.update(get_auth_headers_for_test()) - responses.add( - responses.GET, - url, - content_type="application/json", - body="""[{ - "type": "file", - "name": "new_car.jpg", - "createdAt": "2022-06-15T11:34:36.294Z", - "updatedAt": "2022-07-04T10:15:50.067Z", - "fileId": "fake_123", - "tags": ["Tag_1", "Tag_2", "Tag_3"], - "AITags": [{ - "name": "Clothing", - "confidence": 98.77, - "source": "google-auto-tagging" - }, { - "name": "Smile", - "confidence": 95.31, - "source": "google-auto-tagging" - }, { - "name": "Shoe", - "confidence": 95.2, - "source": "google-auto-tagging" - }], - "versionInfo": { - "id": "versionId", - "name": "Version 4" - }, - "embeddedMetadata": { - "DateCreated": "2022-07-04T10:15:50.066Z", - "DateTimeCreated": "2022-07-04T10:15:50.066Z" - }, - "customCoordinates": "", - "customMetadata": { - "test100": 10, - "test10": 11 - }, - "isPrivateFile": false, - "url": "https://ik.imagekit.io/your_imagekit_id/new_car.jpg", - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/new_car.jpg", - "fileType": "image", - "filePath": "/new_car.jpg", - "height": 354, - "width": 236, - "size": 7390, - "hasAlpha": false, - "mime": "image/jpeg" - }, { - "type": "file-version", - "name": "new_car.jpg", - "createdAt": "2022-07-04T10:15:49.698Z", - "updatedAt": "2022-07-04T10:15:49.734Z", - "fileId": "fileId", - "tags": ["Tag_1", "Tag_2", "Tag_3"], - "AITags": [{ - "name": "Clothing", - "confidence": 98.77, - "source": "google-auto-tagging" - }, { - "name": "Smile", - "confidence": 95.31, - "source": "google-auto-tagging" - }, { - "name": "Shoe", - "confidence": 95.2, - "source": "google-auto-tagging" - }, { - "name": "Street light", - "confidence": 91.05, - "source": "google-auto-tagging" - }], - "versionInfo": { - "id": "62c2bdd5872375c6b8f40fd4", - "name": "Version 1" - }, - "embeddedMetadata": { - "XResolution": 250, - "YResolution": 250, - "DateCreated": "2022-06-15T11:34:36.702Z", - "DateTimeCreated": "2022-06-15T11:34:36.702Z" - }, - "customCoordinates": "10,10,40,40", - "customMetadata": { - "test100": 10, - "test10": 11 - }, - "isPrivateFile": false, - "url": "https://ik.imagekit.io/your_imagekit_id/new_car.jpg?ik-obj-version=dlkUlhiJ7I8OTejhKG38GZJBrsvDBcnz", - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/new_car.jpg?ik-obj-version=dlkUlhiJ7I8OTejhKG38GZJBrsvDBcnz", - "fileType": "image", - "filePath": "/new_car.jpg", - "height": 354, - "width": 236, - "size": 23023, - "hasAlpha": false, - "mime": "image/jpeg" - }]""", - headers=headers, - ) - resp = self.client.get_file_versions(self.file_id) - mock_response_metadata = { - "raw": [ - { - "type": "file", - "name": "new_car.jpg", - "createdAt": "2022-06-15T11:34:36.294Z", - "updatedAt": "2022-07-04T10:15:50.067Z", - "fileId": "fake_123", - "tags": ["Tag_1", "Tag_2", "Tag_3"], - "AITags": [ - { - "name": "Clothing", - "confidence": 98.77, - "source": "google-auto-tagging", - }, - { - "name": "Smile", - "confidence": 95.31, - "source": "google-auto-tagging", - }, - { - "name": "Shoe", - "confidence": 95.2, - "source": "google-auto-tagging", - }, - ], - "versionInfo": {"id": "versionId", "name": "Version 4"}, - "embeddedMetadata": { - "DateCreated": "2022-07-04T10:15:50.066Z", - "DateTimeCreated": "2022-07-04T10:15:50.066Z", - }, - "customCoordinates": "", - "customMetadata": {"test100": 10, "test10": 11}, - "isPrivateFile": False, - "url": "https://ik.imagekit.io/your_imagekit_id/new_car.jpg", - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/new_car.jpg", - "fileType": "image", - "filePath": "/new_car.jpg", - "height": 354, - "width": 236, - "size": 7390, - "hasAlpha": False, - "mime": "image/jpeg", - }, - { - "type": "file-version", - "name": "new_car.jpg", - "createdAt": "2022-07-04T10:15:49.698Z", - "updatedAt": "2022-07-04T10:15:49.734Z", - "fileId": "fileId", - "tags": ["Tag_1", "Tag_2", "Tag_3"], - "AITags": [ - { - "name": "Clothing", - "confidence": 98.77, - "source": "google-auto-tagging", - }, - { - "name": "Smile", - "confidence": 95.31, - "source": "google-auto-tagging", - }, - { - "name": "Shoe", - "confidence": 95.2, - "source": "google-auto-tagging", - }, - { - "name": "Street light", - "confidence": 91.05, - "source": "google-auto-tagging", - }, - ], - "versionInfo": { - "id": "62c2bdd5872375c6b8f40fd4", - "name": "Version 1", - }, - "embeddedMetadata": { - "XResolution": 250, - "YResolution": 250, - "DateCreated": "2022-06-15T11:34:36.702Z", - "DateTimeCreated": "2022-06-15T11:34:36.702Z", - }, - "customCoordinates": "10,10,40,40", - "customMetadata": {"test100": 10, "test10": 11}, - "isPrivateFile": False, - "url": "https://ik.imagekit.io/your_imagekit_id/new_car.jpg?ik-obj-version=dlkUlhiJ7I8OTejhKG38GZJBrsvDBcnz", - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-ik_ml_thumbnail/new_car.jpg?ik-obj-version=dlkUlhiJ7I8OTejhKG38GZJBrsvDBcnz", - "fileType": "image", - "filePath": "/new_car.jpg", - "height": 354, - "width": 236, - "size": 23023, - "hasAlpha": False, - "mime": "image/jpeg", - }, - ], - "http_status_code": 200, - "headers": { - "Content-Type": "application/json", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - } - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("fake_123", resp.list[0].file_id) - self.assertEqual("fileId", resp.list[1].file_id) - self.assertEqual( - "http://test.com/v1/files/fake_123/versions", responses.calls[0].request.url - ) - - @responses.activate - def test_get_file_versions_fails_with_404_exception(self) -> None: - """Test get file versions raises 404 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions".format(URL.API_BASE_URL, self.file_id) - try: - responses.add( - responses.GET, - url, - status=404, - body="""{"message": "The requested asset does not exist.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.get_file_versions(self.file_id) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("The requested asset does not exist.", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - - @responses.activate - def test_get_file_version_details_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions/{}".format( - URL.API_BASE_URL, self.file_id, self.version_id - ) - try: - responses.add( - responses.GET, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.get_file_version_details(self.file_id, self.version_id) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_get_file_version_details_succeeds_with_id(self): - """ - Tests if get file version details succeeds with file id - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions/{}".format( - URL.API_BASE_URL, self.file_id, self.version_id - ) - - headers = {} - headers.update(get_auth_headers_for_test()) - responses.add( - responses.GET, - url, - body="""{ - "type": "file-version", - "name": "new_car.jpg", - "createdAt": "2022-06-27T09:24:25.251Z", - "updatedAt": "2022-06-27T12:11:11.247Z", - "fileId": "fake_123", - "tags": ["tagg", "tagg1"], - "AITags": "", - "versionInfo": { - "id": "fake_version_123", - "name": "Version 1" - }, - "embeddedMetadata": { - "XResolution": 250, - "YResolution": 250, - "DateCreated": "2022-06-15T11:34:36.702Z", - "DateTimeCreated": "2022-06-15T11:34:36.702Z" - }, - "customCoordinates": "10,10,20,20", - "customMetadata": { - "test100": 10 - }, - "isPrivateFile": false, - "url": "https://ik.imagekit.io/your-imagekit-id/new_car.jpg?ik-obj-version=hzBNRjaJhZYg.JNu75L2nMDfhjJP4tJH", - "thumbnail": "https://ik.imagekit.io/your-imagekit-id/tr:n-ik_ml_thumbnail/new_car.jpg?ik-obj-version=hzBNRjaJhZYg.JNu75L2nMDfhjJP4tJH", - "fileType": "image", - "filePath": "/new_car.jpg", - "height": 354, - "width": 236, - "size": 23023, - "hasAlpha": false, - "mime": "image/jpeg" - }""", - headers=headers, - content_type="application/json", - ) - resp = self.client.get_file_version_details(self.file_id, self.version_id) - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "http_status_code": 200, - "raw": { - "AITags": "", - "createdAt": "2022-06-27T09:24:25.251Z", - "customCoordinates": "10,10,20,20", - "customMetadata": {"test100": 10}, - "embeddedMetadata": { - "DateCreated": "2022-06-15T11:34:36.702Z", - "DateTimeCreated": "2022-06-15T11:34:36.702Z", - "XResolution": 250, - "YResolution": 250, - }, - "fileId": "fake_123", - "filePath": "/new_car.jpg", - "fileType": "image", - "hasAlpha": False, - "height": 354, - "isPrivateFile": False, - "mime": "image/jpeg", - "name": "new_car.jpg", - "size": 23023, - "tags": ["tagg", "tagg1"], - "thumbnail": "https://ik.imagekit.io/your-imagekit-id/tr:n-ik_ml_thumbnail/new_car.jpg?ik-obj-version=hzBNRjaJhZYg.JNu75L2nMDfhjJP4tJH", - "type": "file-version", - "updatedAt": "2022-06-27T12:11:11.247Z", - "url": "https://ik.imagekit.io/your-imagekit-id/new_car.jpg?ik-obj-version=hzBNRjaJhZYg.JNu75L2nMDfhjJP4tJH", - "versionInfo": {"id": "fake_version_123", "name": "Version 1"}, - "width": 236, - }, - } - - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("fake_123", resp.file_id) - self.assertEqual("fake_version_123", resp.version_info.id) - self.assertEqual( - "http://test.com/v1/files/fake_123/versions/fake_version_123", - responses.calls[0].request.url, - ) - - @responses.activate - def test_get_file_version_details_fails_with_404_exception(self) -> None: - """Test get file version details raises 404 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions/{}".format( - URL.API_BASE_URL, self.file_id, self.version_id - ) - try: - responses.add( - responses.GET, - url, - status=404, - body="""{"message": "The requested asset does not exist.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.get_file_version_details(self.file_id, self.version_id) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("The requested asset does not exist.", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - - @responses.activate - def test_get_file_version_details_fails_with_400_exception(self) -> None: - """Test get file version details raises 400 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions/{}".format( - URL.API_BASE_URL, self.file_id, self.version_id - ) - try: - responses.add( - responses.GET, - url, - status=400, - body="""{"message": "Your request contains invalid fileId parameter.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.get_file_version_details(self.file_id, self.version_id) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual( - "Your request contains invalid fileId parameter.", e.message - ) - self.assertEqual(400, e.response_metadata.http_status_code) - - -class TestDeleteFileVersion(ClientTestCase): - version_id = "fake_123_version_id" - file_id = "fax_abx1223" - - @responses.activate - def test_delete_file_version_fails_with_404_exception(self) -> None: - """Test delete_file_version raises 404 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions/{}".format( - URL.API_BASE_URL, self.file_id, self.version_id - ) - try: - responses.add( - responses.DELETE, - url, - status=404, - body="""{"message": "The requested file version does not exist.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.delete_file_version(self.file_id, self.version_id) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("The requested file version does not exist.", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - - @responses.activate - def test_delete_file_version_succeeds(self) -> None: - """Test delete_file_version succeeds with file and version Id""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions/{}".format( - URL.API_BASE_URL, self.file_id, self.version_id - ) - headers = {} - headers.update(create_headers_for_test()) - responses.add(responses.DELETE, url, status=204, headers=headers, content_type="application/json") - resp = self.client.delete_file_version(self.file_id, self.version_id) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 204, - "raw": None, - } - - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/files/fax_abx1223/versions/fake_123_version_id", - responses.calls[0].request.url, - ) - - -class TestCopyFile(ClientTestCase): - source_file_path = "/source_file.jpg" - - destination_path = "/destination_path" - - @responses.activate - def test_copy_file_fails_with_404(self) -> None: - """Test copy_file raises 404""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/copy".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.POST, - url, - status=404, - headers=headers, - body="""{ - "message": "No file found with filePath /source_file.jpg", - "help": "For support kindly contact us at support@imagekit.io .", - "reason": "SOURCE_FILE_MISSING" - }""", - ) - try: - self.client.copy_file( - options=CopyFileRequestOptions( - source_file_path=self.source_file_path, - destination_path=self.destination_path, - include_file_versions=False, - ) - ) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("No file found with filePath /source_file.jpg", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - - @responses.activate - def test_copy_file_succeeds(self) -> None: - """Test copy_file succeeds""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/copy".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add(responses.POST, url, status=204, headers=headers, content_type="application/json") - - resp = self.client.copy_file( - options=CopyFileRequestOptions( - source_file_path=self.source_file_path, - destination_path=self.destination_path, - include_file_versions=True, - ) - ) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 204, - "raw": None, - } - - request_body = json.dumps( - json.loads( - """{ - "sourceFilePath": "/source_file.jpg", - "destinationPath": "/destination_path", - "includeFileVersions": true - }""" - ) - ) - - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/files/copy", responses.calls[0].request.url - ) - - @responses.activate - def test_copy_file_succeeds_without_include_file_versions(self) -> None: - """Test copy_file succeeds""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/copy".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add(responses.POST, url, status=204, headers=headers, content_type="application/json") - - resp = self.client.copy_file( - options=CopyFileRequestOptions( - source_file_path=self.source_file_path, - destination_path=self.destination_path, - ) - ) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 204, - "raw": None, - } - - request_body = json.dumps( - json.loads( - """{ - "sourceFilePath": "/source_file.jpg", - "destinationPath": "/destination_path" - }""" - ) - ) - - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/files/copy", responses.calls[0].request.url - ) - - -class TestMoveFile(ClientTestCase): - source_file_path = "/source_file.jpg" - - destination_path = "/destination_path" - - @responses.activate - def test_move_file_fails_with_404(self) -> None: - """Test move_file raises 404""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/move".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.POST, - url, - status=404, - headers=headers, - body="""{ - "message": "No file found with filePath /source_file.jpg", - "help": "For support kindly contact us at support@imagekit.io .", - "reason": "SOURCE_FILE_MISSING" - }""", - ) - try: - self.client.move_file( - options=MoveFileRequestOptions( - source_file_path=self.source_file_path, - destination_path=self.destination_path, - ) - ) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("No file found with filePath /source_file.jpg", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - - @responses.activate - def test_move_file_succeeds(self) -> None: - """Test move_file succeeds""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/move".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add(responses.POST, url, status=204, headers=headers, content_type="application/json") - - resp = self.client.move_file( - options=MoveFileRequestOptions( - source_file_path=self.source_file_path, - destination_path=self.destination_path, - ) - ) - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 204, - "raw": None, - } - - request_body = json.dumps( - json.loads( - """{ - "sourceFilePath": "/source_file.jpg", - "destinationPath": "/destination_path" - }""" - ) - ) - - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/files/move", responses.calls[0].request.url - ) - - -class TestRenameFile(ClientTestCase): - file_path = "/file_path.jpg" - - new_file_name = "new_file.jpg" - - @responses.activate - def test_rename_file_fails_with_409(self) -> None: - """Test rename_file raises 409""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/rename".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - try: - responses.add( - responses.PUT, - url, - status=409, - headers=headers, - body="""{ - "message": "File with name testing-binary.jpg already exists at the same location.", - "help": "For support kindly contact us at support@imagekit.io .", - "reason": "FILE_ALREADY_EXISTS" - }""", - ) - self.client.rename_file( - options=RenameFileRequestOptions( - file_path=self.file_path, new_file_name=self.new_file_name - ) - ) - self.assertRaises(ConflictException) - except ConflictException as e: - self.assertEqual( - "File with name testing-binary.jpg already exists at the same location.", - e.message, - ) - self.assertEqual(409, e.response_metadata.http_status_code) - self.assertEqual("FILE_ALREADY_EXISTS", e.response_metadata.raw["reason"]) - - @responses.activate - def test_rename_file_succeeds_with_purge_cache_false(self) -> None: - """Test rename_file succeeds with Purge cache""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/rename".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.PUT, - url, - headers=headers, - body="{}", - content_type="application/json", - ) - resp = self.client.rename_file( - options=RenameFileRequestOptions( - file_path=self.file_path, - new_file_name=self.new_file_name, - purge_cache=False, - ) - ) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 200, - "raw": {}, - } - - request_body = json.dumps( - json.loads( - """{ - "filePath": "/file_path.jpg", - "newFileName": "new_file.jpg", - "purgeCache": false - }""" - ) - ) - - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual(None, resp.purge_request_id) - self.assertEqual( - "http://test.com/v1/files/rename", responses.calls[0].request.url - ) - - @responses.activate - def test_rename_file_succeeds_with_purge_cache(self) -> None: - """Test rename_file succeeds with Purge cache""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/rename".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.PUT, - url, - headers=headers, - body='{"purgeRequestId": "62de3e986f68334a5a3339fb"}', - content_type="application/json", - ) - resp = self.client.rename_file( - options=RenameFileRequestOptions( - file_path=self.file_path, - new_file_name=self.new_file_name, - purge_cache=True, - ) - ) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 200, - "raw": {"purgeRequestId": "62de3e986f68334a5a3339fb"}, - } - - request_body = json.dumps( - json.loads( - """{ - "filePath": "/file_path.jpg", - "newFileName": "new_file.jpg", - "purgeCache": true - }""" - ) - ) - - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("62de3e986f68334a5a3339fb", resp.purge_request_id) - self.assertEqual( - "http://test.com/v1/files/rename", responses.calls[0].request.url - ) - - @responses.activate - def test_rename_file_succeeds(self) -> None: - """Test rename_file succeeds""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/rename".format(URL.API_BASE_URL) - headers = {} - headers.update(create_headers_for_test()) - responses.add(responses.PUT, url, headers=headers, body="{}", content_type="application/json") - resp = self.client.rename_file( - options=RenameFileRequestOptions( - file_path=self.file_path, new_file_name=self.new_file_name - ) - ) - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 200, - "raw": {}, - } - - request_body = json.dumps( - json.loads( - """{ - "filePath": "/file_path.jpg", - "newFileName": "new_file.jpg" - }""" - ) - ) - - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual(None, resp.purge_request_id) - self.assertEqual( - "http://test.com/v1/files/rename", responses.calls[0].request.url - ) - - -class TestRestoreFileVersion(ClientTestCase): - version_id = "fake_123_version_id" - file_id = "fax_abx1223" - - @responses.activate - def test_restore_file_version_fails_with_404_exception(self) -> None: - """Test restore_file_version raises 404 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions/{}/restore".format( - URL.API_BASE_URL, self.file_id, self.version_id - ) - try: - responses.add( - responses.PUT, - url, - status=404, - body="""{"message": "The requested file version does not exist.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.restore_file_version(self.file_id, self.version_id) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("The requested file version does not exist.", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - - @responses.activate - def test_restore_file_version_succeeds(self) -> None: - """Test restore_file_version succeeds with file and version Id""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/versions/{}/restore".format( - URL.API_BASE_URL, self.file_id, self.version_id - ) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.PUT, - url, - headers=headers, - content_type="application/json", - body="""{ - "fileId": "fileId", - "type": "file", - "name": "file1.jpg", - "filePath": "/images/file.jpg", - "tags": ["t-shirt", "round-neck", "sale2019"], - "AITags": [ - { - "confidence": 90.12, - "source": "google-auto-tagging" - }], - "versionInfo": { - "id": "versionId", - "name": "Version 2" - }, - "isPrivateFile": false, - "customCoordinates": "", - "url": "https://ik.imagekit.io/your_imagekit_id/images/products/file1.jpg", - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-media_library_thumbnail/images/products/file1.jpg", - "fileType": "image", - "hasAlpha": false, - "height": 100, - "isPrivateFile": false, - "mime": "image/jpeg", - "name": "file1.jpg", - "size": 100, - "hasAlpha": false, - "customMetadata": { - "brand": "Nike", - "color": "red" - }, - "createdAt": "2019-08-24T06:14:41.313Z", - "updatedAt": "2019-09-24T06:14:41.313Z" - }""", - ) - resp = self.client.restore_file_version(self.file_id, self.version_id) - - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "http_status_code": 200, - "raw": { - "AITags": [{"confidence": 90.12, "source": "google-auto-tagging"}], - "createdAt": "2019-08-24T06:14:41.313Z", - "customCoordinates": "", - "customMetadata": {"brand": "Nike", "color": "red"}, - "fileId": "fileId", - "filePath": "/images/file.jpg", - "fileType": "image", - "hasAlpha": False, - "height": 100, - "isPrivateFile": False, - "mime": "image/jpeg", - "name": "file1.jpg", - "size": 100, - "tags": ["t-shirt", "round-neck", "sale2019"], - "thumbnail": "https://ik.imagekit.io/your_imagekit_id/tr:n-media_library_thumbnail/images/products/file1.jpg", - "type": "file", - "updatedAt": "2019-09-24T06:14:41.313Z", - "url": "https://ik.imagekit.io/your_imagekit_id/images/products/file1.jpg", - "versionInfo": {"id": "versionId", "name": "Version 2"}, - }, - } - - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("fileId", resp.file_id) - self.assertEqual("versionId", resp.version_info.id) - self.assertEqual( - "http://test.com/v1/files/fax_abx1223/versions/fake_123_version_id/restore", - responses.calls[0].request.url, - ) - - @responses.activate() - def test_get_metadata_with_non_breaking_changes_in_response(self): - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/{}/metadata".format(URL.API_BASE_URL, self.file_id) - headers = {} - headers.update(create_headers_for_test()) - responses.add( - responses.GET, - url, - headers=headers, - body="""{ - "height": 176, - "width": 287, - "size": 15869, - "format": "jpg", - "hasColorProfile": false, - "quality": 0, - "density": 72, - "hasTransparency": false, - "exif":{ - "gps":{ - "GPSVersionId":"1.2", - "latitude":1235.124151355, - "longitude":12315.326236 - }, - "thumbnail": - { - "compression":12, - "YResolution":1, - "XResolution":1, - "resolutionUnit":"DPI", - "thumbnailOffset":12, - "thumbnailLength":12, - "overallResolution":1 - }, - "exif": { - "exposureTime":10, - "FNumber":1, - "exposureProgram":null, - "ISO":"2001", - "exifVersion":1.2, - "dateTimeOriginal":null, - "createDate":null, - "shutterSpeedValue":122, - "apertureValue":12, - "exposureCompensation":12, - "meteringMode":null, - "flash":"white", - "focalLength":null, - "subSecTime":null, - "subSecTimeOriginal":null, - "subSecTimeDigitized":null, - "flashpixVersion":null, - "colorSpace":null, - "exifImageWidth":null, - "exifImageHeight":null, - "interopOffset":null, - "focalPlaneXResolution":null, - "focalPlaneYResolution":null, - "focalPlaneResolutionUnit":null, - "customRendered":null, - "exposureMode":null, - "whiteBalance":null, - "sceneCaptureType":null, - "cameraModel":"Canon" - }, - "image":{ - "make":null, - "model":null, - "orientation":null, - "XResolution":null, - "YResolution":null, - "resolutionUnit":null, - "software":null, - "modifyDate":null, - "YCbCrPositioning":null, - "exifOffset":null, - "gpsInfo":"navic", - "cropped":true - }, - "interoperability":{ - "interopVersion":1.1, - "interopIndex":1.2, - "interopRandom":132 - } - } - } - """, - - ) - - metadataExif = self.client.get_metadata(self.file_id) - - self.assertEqual(metadataExif.exif.interoperability.interop_index,1.2) - self.assertEqual(metadataExif.exif.interoperability.interop_random,132) - self.assertEqual(metadataExif.exif.image.gps_info,"navic") - self.assertEqual(metadataExif.exif.image.cropped,True) - self.assertEqual(metadataExif.exif.gps.gps_version_id,["1",".","2"]) - self.assertEqual(metadataExif.exif.gps.longitude,12315.326236) - self.assertEqual(metadataExif.exif.exif.exposure_time,10) - self.assertEqual(metadataExif.exif.exif.camera_model,"Canon") - self.assertEqual(metadataExif.exif.thumbnail.x_resolution,1) - self.assertEqual(metadataExif.exif.thumbnail.overall_resolution,1) - \ No newline at end of file diff --git a/tests/test_folder_ops.py b/tests/test_folder_ops.py deleted file mode 100644 index f955b717..00000000 --- a/tests/test_folder_ops.py +++ /dev/null @@ -1,576 +0,0 @@ -import json - -import responses - -from imagekitio import ImageKit -from imagekitio.constants.url import URL -from imagekitio.exceptions.BadRequestException import BadRequestException -from imagekitio.exceptions.ForbiddenException import ForbiddenException -from imagekitio.exceptions.InternalServerException import InternalServerException -from imagekitio.exceptions.NotFoundException import NotFoundException -from imagekitio.exceptions.UnknownException import UnknownException -from imagekitio.models.CopyFolderRequestOptions import CopyFolderRequestOptions -from imagekitio.models.CreateFolderRequestOptions import CreateFolderRequestOptions -from imagekitio.models.DeleteFolderRequestOptions import DeleteFolderRequestOptions -from imagekitio.models.MoveFolderRequestOptions import MoveFolderRequestOptions -from imagekitio.utils.formatter import camel_dict_to_snake_dict -from tests.helpers import ( - ClientTestCase, - create_headers_for_test, -) - -imagekit_obj = ImageKit( - private_key="private_fake:", - public_key="public_fake123:", - url_endpoint="fake.com", -) - - -class TestFolders(ClientTestCase): - """ - TestFolders class used to test create and Delete folders - """ - - @responses.activate - def test_create_folder_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/folder".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.create_folder( - options=CreateFolderRequestOptions( - folder_name="folder_name", parent_folder_path="/test" - ) - ) - self.assertRaises(ForbiddenException) - except UnknownException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(e.response_metadata.http_status_code, 403) - - @responses.activate - def test_create_folder_succeeds(self): - """ - Tests if create_folder succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/folder".format(URL.API_BASE_URL) - headers = create_headers_for_test() - responses.add(responses.POST, url, status=201, body="{}", headers=headers) - resp = self.client.create_folder( - options=CreateFolderRequestOptions( - folder_name="folder_name", parent_folder_path="/test" - ) - ) - - mock_response_metadata = { - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 201, - "raw": {}, - } - - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("http://test.com/v1/folder", responses.calls[0].request.url) - self.assertEqual( - '{"folderName": "folder_name", "parentFolderPath": "/test"}', - responses.calls[0].request.body, - ) - - @responses.activate - def test_create_folder_fails_with_400(self): - """ - Tests if create folder fails with 400 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/folder".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=400, - body="""{"message": "folderName parameter cannot have a slash.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.create_folder( - options=CreateFolderRequestOptions( - folder_name="folder_name", parent_folder_path="/test" - ) - ) - self.assertRaises(BadRequestException) - except UnknownException as e: - self.assertEqual(e.message, "folderName parameter cannot have a slash.") - self.assertEqual(e.response_metadata.http_status_code, 400) - - @responses.activate - def test_delete_folder_fails_with_400(self): - """ - Tests if Delete folder fails with 400 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/folder".format(URL.API_BASE_URL) - try: - responses.add( - responses.DELETE, - url, - status=404, - body="""{ - "message": "No folder found with folderPath test", - "help": "For support kindly contact us at support@imagekit.io .", - "reason": "FOLDER_NOT_FOUND" - }""", - ) - self.client.delete_folder( - options=DeleteFolderRequestOptions(folder_path="/test") - ) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("No folder found with folderPath test", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - self.assertEqual("FOLDER_NOT_FOUND", e.response_metadata.raw["reason"]) - - @responses.activate - def test_delete_folder_succeeds(self): - """ - Tests if Delete folder succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/folder".format(URL.API_BASE_URL) - responses.add( - responses.DELETE, - url, - status=204, - ) - resp = self.client.delete_folder( - options=DeleteFolderRequestOptions(folder_path="/folderName") - ) - mock_response_metadata = { - "raw": None, - "httpStatusCode": 204, - "headers": {"Content-Type": "text/plain"}, - } - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("http://test.com/v1/folder", responses.calls[0].request.url) - self.assertEqual('{"folderPath": "/folderName"}', responses.calls[0].request.body) - - -class TestCopyFolder(ClientTestCase): - """ - TestCopyFolder class used to test copy folder - """ - - @responses.activate - def test_copy_folder_fails_with_400(self): - """ - Tests if Copy folder fails with 400 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/copyFolder".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=400, - body="""{ - "message": "sourceFolderPath and destinationPath cannot be same.", - "help": "For support kindly contact us at support@imagekit.io ." - }""", - ) - self.client.copy_folder( - options=CopyFolderRequestOptions( - source_folder_path="/test", - destination_path="/test", - include_file_versions=False, - ) - ) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual( - "sourceFolderPath and destinationPath cannot be same.", e.message - ) - self.assertEqual(400, e.response_metadata.http_status_code) - - @responses.activate - def test_copy_folder_fails_with_404(self): - """ - Tests if Copy folder fails with 404 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/copyFolder".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=404, - body="""{ - "message": "No files & folder found at sourceFolderPath /test", - "help": "For support kindly contact us at support@imagekit.io .", - "reason": "NO_FILES_FOLDER" - }""", - ) - self.client.copy_folder( - options=CopyFolderRequestOptions( - source_folder_path="/test", - destination_path="/test1", - include_file_versions=False, - ) - ) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual( - "No files & folder found at sourceFolderPath /test", e.message - ) - self.assertEqual(404, e.response_metadata.http_status_code) - self.assertEqual("NO_FILES_FOLDER", e.response_metadata.raw["reason"]) - - @responses.activate - def test_copy_folder_succeeds(self): - """ - Tests if Copy folder succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/copyFolder".format(URL.API_BASE_URL) - responses.add( - responses.POST, - url, - body='{"jobId": "62de84fb1b02a58936cc740c"}', - ) - resp = self.client.copy_folder( - options=CopyFolderRequestOptions( - source_folder_path="/test", - destination_path="/test1", - include_file_versions=True, - ) - ) - mock_response_metadata = { - "headers": {"Content-Type": "text/plain"}, - "httpStatusCode": 200, - "raw": {"jobId": "62de84fb1b02a58936cc740c"}, - } - request_body = json.dumps( - json.loads( - """{ - "sourceFolderPath": "/test", - "destinationPath": "/test1", - "includeFileVersions": true - }""" - ) - ) - self.assertEqual("62de84fb1b02a58936cc740c", resp.job_id) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/bulkJobs/copyFolder", responses.calls[0].request.url - ) - self.assertEqual(request_body, responses.calls[0].request.body) - - @responses.activate - def test_copy_folder_succeeds_with_include_file_versions_false(self): - """ - Tests if Copy folder succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/copyFolder".format(URL.API_BASE_URL) - responses.add( - responses.POST, - url, - body='{"jobId": "62de84fb1b02a58936cc740c"}', - ) - resp = self.client.copy_folder( - options=CopyFolderRequestOptions( - source_folder_path="/test", - destination_path="/test1", - include_file_versions=False, - ) - ) - mock_response_metadata = { - "headers": {"Content-Type": "text/plain"}, - "httpStatusCode": 200, - "raw": {"jobId": "62de84fb1b02a58936cc740c"}, - } - request_body = json.dumps( - json.loads( - """{ - "sourceFolderPath": "/test", - "destinationPath": "/test1", - "includeFileVersions": false - }""" - ) - ) - self.assertEqual("62de84fb1b02a58936cc740c", resp.job_id) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/bulkJobs/copyFolder", responses.calls[0].request.url - ) - self.assertEqual(request_body, responses.calls[0].request.body) - - @responses.activate - def test_copy_folder_succeeds_without_include_file_versions(self): - """ - Tests if Copy folder succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/copyFolder".format(URL.API_BASE_URL) - responses.add( - responses.POST, - url, - body='{"jobId": "62de84fb1b02a58936cc740c"}', - ) - resp = self.client.copy_folder( - options=CopyFolderRequestOptions( - source_folder_path="/test", - destination_path="/test1", - ) - ) - mock_response_metadata = { - "headers": {"Content-Type": "text/plain"}, - "httpStatusCode": 200, - "raw": {"jobId": "62de84fb1b02a58936cc740c"}, - } - request_body = json.dumps( - json.loads( - """{ - "sourceFolderPath": "/test", - "destinationPath": "/test1" - }""" - ) - ) - self.assertEqual("62de84fb1b02a58936cc740c", resp.job_id) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/bulkJobs/copyFolder", responses.calls[0].request.url - ) - self.assertEqual(request_body, responses.calls[0].request.body) - - -class TestMoveFolder(ClientTestCase): - """ - TestMoveFolder class used to test move folder - """ - - @responses.activate - def test_move_folder_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/moveFolder".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.move_folder( - options=MoveFolderRequestOptions( - source_folder_path="/test", destination_path="/test1" - ) - ) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_move_folder_fails_with_400(self): - """ - Tests if Move folder fails with 400 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/moveFolder".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=400, - body="""{ - "message": "sourceFolderPath and destinationPath cannot be same.", - "help": "For support kindly contact us at support@imagekit.io ." - }""", - ) - self.client.move_folder( - options=MoveFolderRequestOptions( - source_folder_path="/test", destination_path="/test" - ) - ) - self.assertRaises(BadRequestException) - except BadRequestException as e: - self.assertEqual( - "sourceFolderPath and destinationPath cannot be same.", e.message - ) - self.assertEqual(400, e.response_metadata.http_status_code) - - @responses.activate - def test_move_folder_fails_with_404(self): - """ - Tests if Move folder fails with 404 - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/moveFolder".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=404, - body="""{ - "message": "No files & folder found at sourceFolderPath /test", - "help": "For support kindly contact us at support@imagekit.io .", - "reason": "NO_FILES_FOLDER" - }""", - ) - self.client.move_folder( - options=MoveFolderRequestOptions( - source_folder_path="/test", destination_path="/test1" - ) - ) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual( - "No files & folder found at sourceFolderPath /test", e.message - ) - self.assertEqual(404, e.response_metadata.http_status_code) - self.assertEqual("NO_FILES_FOLDER", e.response_metadata.raw["reason"]) - - @responses.activate - def test_move_folder_succeeds(self): - """ - Tests if Move folder succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/moveFolder".format(URL.API_BASE_URL) - responses.add( - responses.POST, - url, - body='{"jobId": "62de84fb1b02a58936cc740c"}', - ) - resp = self.client.move_folder( - options=MoveFolderRequestOptions( - source_folder_path="/test", destination_path="/test1" - ) - ) - mock_response_metadata = { - "headers": {"Content-Type": "text/plain"}, - "httpStatusCode": 200, - "raw": {"jobId": "62de84fb1b02a58936cc740c"}, - } - request_body = json.dumps( - json.loads( - """{ - "sourceFolderPath": "/test", - "destinationPath": "/test1" - }""" - ) - ) - self.assertEqual("62de84fb1b02a58936cc740c", resp.job_id) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/bulkJobs/moveFolder", responses.calls[0].request.url - ) - self.assertEqual(request_body, responses.calls[0].request.body) - - -class TestGetBulkJobStatus(ClientTestCase): - """ - TestGetBulkJobStatus class used to get bulk job status - """ - - job_id = "mock_job_id" - - @responses.activate - def test_get_bulk_job_status_fails_with_500(self): - """ - Tests if get_bulk_job_status succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/{}".format(URL.API_BASE_URL, self.job_id) - try: - responses.add( - responses.GET, - url, - status=500, - body="""{"message": "We have experienced an internal error while processing your request.", - "help": "For support kindly contact us at support@imagekit.io ."}""", - ) - self.client.get_bulk_job_status(self.job_id) - self.assertRaises(InternalServerException) - except InternalServerException as e: - self.assertEqual( - "We have experienced an internal error while processing your request.", - e.message, - ) - self.assertEqual(500, e.response_metadata.http_status_code) - - @responses.activate - def test_get_bulk_job_status_succeeds(self): - """ - Tests if get_bulk_job_status succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/bulkJobs/{}".format(URL.API_BASE_URL, self.job_id) - headers = create_headers_for_test() - responses.add( - responses.GET, - url, - body="""{ - "jobId": "mock_job_id", - "type": "COPY_FOLDER", - "status": "Completed" - }""", - headers=headers, - ) - resp = self.client.get_bulk_job_status(self.job_id) - - mock_response_metadata = { - "headers": { - "Content-Type": "text/plain", - "Accept-Encoding": "gzip, deflate", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 200, - "raw": { - "jobId": "mock_job_id", - "status": "Completed", - "type": "COPY_FOLDER", - }, - } - - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual("mock_job_id", resp.job_id) - self.assertEqual("Completed", resp.status) - self.assertEqual("COPY_FOLDER", resp.type) - self.assertEqual( - "http://test.com/v1/bulkJobs/mock_job_id", responses.calls[0].request.url - ) diff --git a/tests/test_generate_url.py b/tests/test_generate_url.py deleted file mode 100644 index 29e18ff0..00000000 --- a/tests/test_generate_url.py +++ /dev/null @@ -1,512 +0,0 @@ -import unittest - -from imagekitio.client import ImageKit -from imagekitio.constants.defaults import Default -from imagekitio.url import Url - -class TestGenerateURL(unittest.TestCase): - def setUp(self) -> None: - self.client = ImageKit( - private_key="private_key_test", - public_key="public_key_test", - url_endpoint="https://test-domain.com/test-endpoint", - ) - def test_generate_url_with_path(self): - options = { - "path": "/default-image.jpg", - "transformation": [{"height": "300", "width": "400"}], - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/tr:h-300,w-400/default-image.jpg", - ) - def test_generate_url_with_path_with_ik_attachment(self): - options = { - "path": "/default-image.jpg", - "transformation": [{"height": "300", "width": "400"}], - "query_parameters": { - "ik-attachment":True - }, - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/tr:h-300,w-400/default-image.jpg?ik-attachment=true", - ) - - def test_generate_url_With_path_with_transformation_raw(self): - options = { - "path": "/default-image.jpg", - "transformation": [{"raw":"f-auto","height": "300", "width": "400"}], - - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/tr:f-auto,h-300,w-400/default-image.jpg", - ) - - def test_overriding_url_endpoint_generation_consists_new_url(self): - """ - Overriding urlEndpoint parameter. Passing a urlEndpoint value which is - different from what I've used during SDK initialization and see if the url - returned is using this new parameter - """ - options = { - "path": "/default-image.jpg", - "url_endpoint": "https://ik.imagekit.io/new/endpoint/", - "transformation": [{"height": "300", "width": "400"}], - } - - url = self.client.url(options) - self.assertEqual( - url, "https://ik.imagekit.io/new/endpoint/tr:h-300,w-400/default-image.jpg" - ) - - def test_generate_url_query_parameters(self): - options = { - "path": "/default-image.jpg", - "query_parameters": {"param1": "value1", "param2": "value2"}, - "transformation": [{"height": "300", "width": "400"}], - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/tr:h-300,w-400/default-image.jpg?param1=value1¶m2=value2", - ) - - def test_generate_url_with_src(self): - options = { - "src": "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg", - "transformation": [ - { - "height": "300", - "width": "400", - "format": "jpg", - "progressive": "true", - "effect_contrast": "1", - "raw": "ar-4-3,q-40", - }, - {"rotation": 90}, - ], - } - url = self.client.url(options) - self.assertEqual( - url, - "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg?tr=h-300%2Cw-400%2Cf-jpg%2Cpr-true%2Ce-contrast-1" - "%2Car-4-3%2Cq-40%3Art-90", - ) - - def test_generate_url_with_src_with_query_params_double(self): - options = { - "src": "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg?queryparam1=value1", - "query_parameters": {"param1": "value1"}, - "transformation": [ - { - "height": "300", - "width": "400", - "format": "jpg", - "progressive": "true", - "effect_contrast": "1", - }, - {"rotation": 90}, - ], - } - url = self.client.url(options) - # @TODO - adjust value of param1=value1 in test case but it should be there - self.assertEqual( - url, - "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg?queryparam1=value1¶m1=value1&tr=h-300%2Cw-400" - "%2Cf-jpg%2Cpr-true%2Ce-contrast-1%3Art-90", - ) - - def test_generate_url_with_path_and_signed(self): - options = { - "path": "/default-image.jpg", - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [{"height": "300", "width": "400"}], - "signed": True, - } - - url = self.client.url(options) - self.assertIsNot(url, "") - self.assertIn(options["url_endpoint"], url) - self.assertIn("300", url) - self.assertIn("300", url) - - self.assertNotIn("&&", url) - self.assertNotIn("??", url) - - url = self.client.url(options) - self.assertEqual(url.split("default-image.jpg")[1][:1], "?") - self.assertNotEqual(url.split("default-image.jpg")[0][-2:], "//") - - def test_generate_url_with_path_and_signed_in_proper_form(self): - """ - Check path param url generation doesn't contain double slash - """ - options = { - "path": "/test-signed-url.jpg", - "signed": True, - "transformation": [{"width": 100}], - } - - url = self.client.url(options) - self.assertIn(Default.SIGNATURE_PARAMETER.value, url) - - def test_generate_url_signed_without_expiry_does_not_have_timestamp_parameter(self): - """ - Check query params does not contain timestamp parameter if expire_seconds isn't specified. - """ - options = { - "path": "/test-signed-url.jpg", - "signed": True, - "transformation": [{"width": 100}], - } - - url = self.client.url(options) - self.assertNotIn(Default.TIMESTAMP_PARAMETER.value, url) - - def test_url_with_new_transformation_returns_as_it_is(self): - options = { - "path": "/default-image.jpg", - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [{"height": "300", "fake_xxxx": "400"}], - "transformation_position": "query", - } - - url = self.client.url(options) - self.assertIn("fake_xxxx", url) - self.assertEqual( - url, - "https://ik.imagekit.io/your_imagekit_id/endpoint/default-image.jpg?tr=h-300%2Cfake_xxxx-400", - ) - - def test_query_url_generation_transformation_as_query_and_transformations_in_url( - self, - ): - options = { - "path": "/default-image.jpg", - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [{"height": "300"}], - "transformation_position": "query", - } - - url = self.client.url(options) - self.assertEqual( - url, - "https://ik.imagekit.io/your_imagekit_id/endpoint/default-image.jpg?tr=h-300", - ) - - def test_generate_url_with_chained_transformations(self): - options = { - "src": "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg", - "transformation": [ - { - "height": "300", - "width": "400", - "format": "jpg", - "progressive": "true", - "effect_contrast": "1", - }, - {"rotation": 90}, - ], - } - url = self.client.url(options) - self.assertEqual( - url, - "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg?tr=h-300%2Cw-400%2Cf-jpg%2Cpr-true%2Ce-contrast-1" - "%3Art-90", - ) - - def test_url_check_query_param_are_added_correctly(self): - options = { - "path": "/default-image.jpg?client=123&user=5", - "transformation": [{"height": "300", "width": "400"}], - "transformation_position": "query", - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/default-image.jpg?client=123&user=5&tr=h-300%2Cw-400", - ) - - def test_generate_url_with_src_query_parameters_merge_correctly(self): - options = { - "src": "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg?client=123&ab=c", - "transformation": [ - { - "height": "300", - "width": "400", - "format": "jpg", - "progressive": "true", - "effect_contrast": "1", - }, - {"rotation": 90}, - ], - } - url = self.client.url(options) - self.assertEqual( - url, - "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg?client=123&ab=c&tr=h-300%2Cw-400%2Cf-jpg%2Cpr-true" - "%2Ce-contrast-1%3Art-90", - ) - - def test_generate_url_with_src_and_transformation_position_path(self): - options = { - "src": "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg", - "transformation": [ - { - "height": "300", - "width": "400", - "format": "jpg", - "progressive": "true", - "effect_contrast": "1", - }, - {"rotation": 90}, - ], - "transformation_position": "path", - } - url = self.client.url(options) - self.assertEqual( - url, - "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg?tr=h-300%2Cw-400%2Cf-jpg%2Cpr-true%2Ce-contrast-1%3Art-90", - ) - - def test_url_with_invalid_trans_pos(self): - options = { - "path": "/default-image.jpg", - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [{"height": "300", "width": "400"}], - "signed": True, - "transformation_position": "fake", - } - self.assertRaises((KeyError, ValueError), self.client.url, options) - - def test_url_without_path_and_src(self): - options = { - "url_endpoint": "https://ik.imagekit.io/your_imagekit_id/endpoint/", - "transformation": [{"height": "300", "width": "400"}], - "signed": True, - } - self.assertEqual(self.client.url(options), "") - - def test_url_contains_slash_if_transformation_position_is_path(self): - options = { - "path": "/default-image.jpg", - "transformation": [ - { - "height": "300", - "width": "400", - "format": "jpg", - "progressive": "true", - "effect_sharpen": "-", - "effect_contrast": "1", - }, - {"rotation": 90}, - ], - "transformation_position": "path", - } - url = self.client.url(options) - self.assertEqual(url.split("tr:h-300")[0][-1], "/") - self.assertNotEqual(url.split("default-image.jpg")[0][-2:], "//") - - def test_url_signed_with_expire_in_seconds(self): - options = { - "path": "/default-image.jpg", - "transformation": [ - { - "width": "400", - }, - ], - "signed": True, - "expire_seconds": 100, - } - url = self.client.url(options) - self.assertIn("ik-t", url) - - def test_url_signed_with_diacritic_in_filename(self): - url = "https://test-domain.com/test-endpoint/test_é_path_alt.jpg" - encodedUrl = Url.encode_string_if_required(url) - self.assertEqual( - encodedUrl, - "https://test-domain.com/test-endpoint/test_%C3%A9_path_alt.jpg", - ) - signature = Url.get_signature("private_key_test", url, "https://test-domain.com/test-endpoint", 9999999999) - options = { - "path": "/test_é_path_alt.jpg", - "signed": True, - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/test_é_path_alt.jpg?ik-s="+signature, - ) - - def test_url_signed_with_diacritic_in_filename_and_path(self): - url = "https://test-domain.com/test-endpoint/aéb/test_é_path_alt.jpg" - encodedUrl = Url.encode_string_if_required(url) - self.assertEqual( - encodedUrl, - "https://test-domain.com/test-endpoint/a%C3%A9b/test_%C3%A9_path_alt.jpg", - ) - signature = Url.get_signature("private_key_test", url, "https://test-domain.com/test-endpoint", 9999999999) - options = { - "path": "/aéb/test_é_path_alt.jpg", - "signed": True, - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/aéb/test_é_path_alt.jpg?ik-s="+signature, - ) - - def test_url_signed_with_diacritic_in_filename_path_transforamtion_in_path(self): - url = "https://test-domain.com/test-endpoint/tr:l-text,i-Imagekité,fs-50,l-end/aéb/test_é_path_alt.jpg" - encodedUrl = Url.encode_string_if_required(url) - self.assertEqual( - encodedUrl, - "https://test-domain.com/test-endpoint/tr:l-text,i-Imagekit%C3%A9,fs-50,l-end/a%C3%A9b/test_%C3%A9_path_alt.jpg", - ) - signature = Url.get_signature("private_key_test", url, "https://test-domain.com/test-endpoint", 9999999999) - options = { - "path": "/aéb/test_é_path_alt.jpg", - "transformation": [ - { - "raw": "l-text,i-Imagekité,fs-50,l-end" - }, - ], - "signed": True, - "transformation_position": "path" - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/tr:l-text,i-Imagekité,fs-50,l-end/aéb/test_é_path_alt.jpg?ik-s="+signature, - ) - - def test_url_signed_with_diacritic_in_filename_path_transforamtion_in_query(self): - url = "https://test-domain.com/test-endpoint/aéb/test_é_path_alt.jpg?tr=l-text%2Ci-Imagekit%C3%A9%2Cfs-50%2Cl-end" - encodedUrl = Url.encode_string_if_required(url) - self.assertEqual( - encodedUrl, - "https://test-domain.com/test-endpoint/a%C3%A9b/test_%C3%A9_path_alt.jpg?tr=l-text%2Ci-Imagekit%C3%A9%2Cfs-50%2Cl-end", - ) - signature = Url.get_signature("private_key_test", url, "https://test-domain.com/test-endpoint", 9999999999) - options = { - "path": "/aéb/test_é_path_alt.jpg", - "transformation": [ - { - "raw": "l-text,i-Imagekité,fs-50,l-end" - }, - ], - "signed": True, - "transformation_position": "query" - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/aéb/test_é_path_alt.jpg?tr=l-text%2Ci-Imagekit%C3%A9%2Cfs-50%2Cl-end&ik-s="+signature, - ) - - def test_generate_url_with_path_and_src_uses_path(self): - """ - In case when both path and src fields are provided, the `path` should be preferred - """ - options = { - "path": "/default-image.jpg", - "src": "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg", - "transformation": [{"height": "300", "width": "400"}], - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/tr:h-300,w-400/default-image.jpg", - ) - - def test_generate_url_with_all_params(self): - """ - In case where all transformation parameters are passed - """ - options = { - "path": "/test_path.jpg", - "src": "https://ik.imagekit.io/ldt7znpgpjs/test_YhNhoRxWt.jpg", - "transformation": [ - { - "height": 300, - "width": 400, - "aspect_ratio": "4-3", - "quality": 40, - "crop": "force", - "crop_mode": "extract", - "focus": "left", - "format": "jpeg", - "radius": 50, - "bg": "A94D34", - "border": "5-A94D34", - "rotation": 90, - "blur": 10, - "named": "some_name", - "progressive": "true", - "lossless": "true", - "trim": 5, - "metadata": "true", - "color_profile": "true", - "default_image": "folder/file.jpg/", # trailing slash case - "dpr": 3, - "effect_sharpen": 10, - "effect_usm": "2-2-0.8-0.024", - "effect_contrast": "true", - "effect_gray": "true", - "effect_shadow": "bl-15_st-40_x-10_y-N5", - "effect_gradient": "from-red_to-white", - "original": True, # Boolean handling - "raw": "w-200,h-200", - } - ], - } - url = self.client.url(options) - self.assertEqual( - url, - "https://test-domain.com/test-endpoint/tr:h-300,w-400,ar-4-3,q-40,c-force,cm-extract,fo-left,f-jpeg,r-50," - "bg-A94D34,b-5-A94D34,rt-90,bl-10,n-some_name,pr-true,lo-true,t-5,md-true,cp-true,di-folder@@file.jpg,dpr-3,e-sharpen-10,e-usm-2-2-0.8-0.024," - "e-contrast-true,e-grayscale-true,e-shadow-bl-15_st-40_x-10_y-N5,e-gradient-from-red_to-white,orig-true,w-200,h-200/test_path.jpg", - ) - - def test_get_signature_with_100_expire_seconds(self): - url = "https://test-domain.com/test-endpoint/tr:w-100/test-signed-url.png" - signature = self.client.url_obj.get_signature( - "private_key_test", url, "https://test-domain.com/test-endpoint/", 100 - ) - self.assertEqual(signature, "5e5037a31a7121cbe2964e220b4338cc6e1ba66d") - - def test_get_signature_without_expire_seconds(self): - url = "https://test-domain.com/test-endpoint/tr:w-100/test-signed-url.png" - signature = self.client.url_obj.get_signature( - "private_key_test", url, "https://test-domain.com/test-endpoint/", 0 - ) - self.assertEqual(signature, "41b3075c40bc84147eb71b8b49ae7fbf349d0f00") - - def test_get_signature_without_expire_seconds_without_slash(self): - url = "https://test-domain.com/test-endpoint/tr:w-100/test-signed-url.png" - signature = self.client.url_obj.get_signature( - "private_key_test", url, "https://test-domain.com/test-endpoint", 0 - ) - self.assertEqual(signature, "41b3075c40bc84147eb71b8b49ae7fbf349d0f00") - - def test_generate_url_without_transforms(self): - options = {"path": "/coffee.jpg", "signed": False, "expire_seconds": 10} - - url = self.client.url(options) - self.assertEqual(url, "https://test-domain.com/test-endpoint/coffee.jpg") - - def test_generate_url_without_transforms_src(self): - options = { - "src": "https://test-domain.com/test-endpoint/coffee.jpg", - "signed": False, - "expire_seconds": 10, - } - - url = self.client.url(options) - self.assertEqual(url, "https://test-domain.com/test-endpoint/coffee.jpg") diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 00000000..561b9831 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,963 @@ +import json +from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast +from datetime import datetime, timezone +from typing_extensions import Literal, Annotated, TypeAliasType + +import pytest +import pydantic +from pydantic import Field + +from imagekitio._utils import PropertyInfo +from imagekitio._compat import PYDANTIC_V1, parse_obj, model_dump, model_json +from imagekitio._models import DISCRIMINATOR_CACHE, BaseModel, construct_type + + +class BasicModel(BaseModel): + foo: str + + +@pytest.mark.parametrize("value", ["hello", 1], ids=["correct type", "mismatched"]) +def test_basic(value: object) -> None: + m = BasicModel.construct(foo=value) + assert m.foo == value + + +def test_directly_nested_model() -> None: + class NestedModel(BaseModel): + nested: BasicModel + + m = NestedModel.construct(nested={"foo": "Foo!"}) + assert m.nested.foo == "Foo!" + + # mismatched types + m = NestedModel.construct(nested="hello!") + assert cast(Any, m.nested) == "hello!" + + +def test_optional_nested_model() -> None: + class NestedModel(BaseModel): + nested: Optional[BasicModel] + + m1 = NestedModel.construct(nested=None) + assert m1.nested is None + + m2 = NestedModel.construct(nested={"foo": "bar"}) + assert m2.nested is not None + assert m2.nested.foo == "bar" + + # mismatched types + m3 = NestedModel.construct(nested={"foo"}) + assert isinstance(cast(Any, m3.nested), set) + assert cast(Any, m3.nested) == {"foo"} + + +def test_list_nested_model() -> None: + class NestedModel(BaseModel): + nested: List[BasicModel] + + m = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) + assert m.nested is not None + assert isinstance(m.nested, list) + assert len(m.nested) == 2 + assert m.nested[0].foo == "bar" + assert m.nested[1].foo == "2" + + # mismatched types + m = NestedModel.construct(nested=True) + assert cast(Any, m.nested) is True + + m = NestedModel.construct(nested=[False]) + assert cast(Any, m.nested) == [False] + + +def test_optional_list_nested_model() -> None: + class NestedModel(BaseModel): + nested: Optional[List[BasicModel]] + + m1 = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) + assert m1.nested is not None + assert isinstance(m1.nested, list) + assert len(m1.nested) == 2 + assert m1.nested[0].foo == "bar" + assert m1.nested[1].foo == "2" + + m2 = NestedModel.construct(nested=None) + assert m2.nested is None + + # mismatched types + m3 = NestedModel.construct(nested={1}) + assert cast(Any, m3.nested) == {1} + + m4 = NestedModel.construct(nested=[False]) + assert cast(Any, m4.nested) == [False] + + +def test_list_optional_items_nested_model() -> None: + class NestedModel(BaseModel): + nested: List[Optional[BasicModel]] + + m = NestedModel.construct(nested=[None, {"foo": "bar"}]) + assert m.nested is not None + assert isinstance(m.nested, list) + assert len(m.nested) == 2 + assert m.nested[0] is None + assert m.nested[1] is not None + assert m.nested[1].foo == "bar" + + # mismatched types + m3 = NestedModel.construct(nested="foo") + assert cast(Any, m3.nested) == "foo" + + m4 = NestedModel.construct(nested=[False]) + assert cast(Any, m4.nested) == [False] + + +def test_list_mismatched_type() -> None: + class NestedModel(BaseModel): + nested: List[str] + + m = NestedModel.construct(nested=False) + assert cast(Any, m.nested) is False + + +def test_raw_dictionary() -> None: + class NestedModel(BaseModel): + nested: Dict[str, str] + + m = NestedModel.construct(nested={"hello": "world"}) + assert m.nested == {"hello": "world"} + + # mismatched types + m = NestedModel.construct(nested=False) + assert cast(Any, m.nested) is False + + +def test_nested_dictionary_model() -> None: + class NestedModel(BaseModel): + nested: Dict[str, BasicModel] + + m = NestedModel.construct(nested={"hello": {"foo": "bar"}}) + assert isinstance(m.nested, dict) + assert m.nested["hello"].foo == "bar" + + # mismatched types + m = NestedModel.construct(nested={"hello": False}) + assert cast(Any, m.nested["hello"]) is False + + +def test_unknown_fields() -> None: + m1 = BasicModel.construct(foo="foo", unknown=1) + assert m1.foo == "foo" + assert cast(Any, m1).unknown == 1 + + m2 = BasicModel.construct(foo="foo", unknown={"foo_bar": True}) + assert m2.foo == "foo" + assert cast(Any, m2).unknown == {"foo_bar": True} + + assert model_dump(m2) == {"foo": "foo", "unknown": {"foo_bar": True}} + + +def test_strict_validation_unknown_fields() -> None: + class Model(BaseModel): + foo: str + + model = parse_obj(Model, dict(foo="hello!", user="Robert")) + assert model.foo == "hello!" + assert cast(Any, model).user == "Robert" + + assert model_dump(model) == {"foo": "hello!", "user": "Robert"} + + +def test_aliases() -> None: + class Model(BaseModel): + my_field: int = Field(alias="myField") + + m = Model.construct(myField=1) + assert m.my_field == 1 + + # mismatched types + m = Model.construct(myField={"hello": False}) + assert cast(Any, m.my_field) == {"hello": False} + + +def test_repr() -> None: + model = BasicModel(foo="bar") + assert str(model) == "BasicModel(foo='bar')" + assert repr(model) == "BasicModel(foo='bar')" + + +def test_repr_nested_model() -> None: + class Child(BaseModel): + name: str + age: int + + class Parent(BaseModel): + name: str + child: Child + + model = Parent(name="Robert", child=Child(name="Foo", age=5)) + assert str(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" + assert repr(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" + + +def test_optional_list() -> None: + class Submodel(BaseModel): + name: str + + class Model(BaseModel): + items: Optional[List[Submodel]] + + m = Model.construct(items=None) + assert m.items is None + + m = Model.construct(items=[]) + assert m.items == [] + + m = Model.construct(items=[{"name": "Robert"}]) + assert m.items is not None + assert len(m.items) == 1 + assert m.items[0].name == "Robert" + + +def test_nested_union_of_models() -> None: + class Submodel1(BaseModel): + bar: bool + + class Submodel2(BaseModel): + thing: str + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2] + + m = Model.construct(foo={"thing": "hello"}) + assert isinstance(m.foo, Submodel2) + assert m.foo.thing == "hello" + + +def test_nested_union_of_mixed_types() -> None: + class Submodel1(BaseModel): + bar: bool + + class Model(BaseModel): + foo: Union[Submodel1, Literal[True], Literal["CARD_HOLDER"]] + + m = Model.construct(foo=True) + assert m.foo is True + + m = Model.construct(foo="CARD_HOLDER") + assert m.foo == "CARD_HOLDER" + + m = Model.construct(foo={"bar": False}) + assert isinstance(m.foo, Submodel1) + assert m.foo.bar is False + + +def test_nested_union_multiple_variants() -> None: + class Submodel1(BaseModel): + bar: bool + + class Submodel2(BaseModel): + thing: str + + class Submodel3(BaseModel): + foo: int + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2, None, Submodel3] + + m = Model.construct(foo={"thing": "hello"}) + assert isinstance(m.foo, Submodel2) + assert m.foo.thing == "hello" + + m = Model.construct(foo=None) + assert m.foo is None + + m = Model.construct() + assert m.foo is None + + m = Model.construct(foo={"foo": "1"}) + assert isinstance(m.foo, Submodel3) + assert m.foo.foo == 1 + + +def test_nested_union_invalid_data() -> None: + class Submodel1(BaseModel): + level: int + + class Submodel2(BaseModel): + name: str + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2] + + m = Model.construct(foo=True) + assert cast(bool, m.foo) is True + + m = Model.construct(foo={"name": 3}) + if PYDANTIC_V1: + assert isinstance(m.foo, Submodel2) + assert m.foo.name == "3" + else: + assert isinstance(m.foo, Submodel1) + assert m.foo.name == 3 # type: ignore + + +def test_list_of_unions() -> None: + class Submodel1(BaseModel): + level: int + + class Submodel2(BaseModel): + name: str + + class Model(BaseModel): + items: List[Union[Submodel1, Submodel2]] + + m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) + assert len(m.items) == 2 + assert isinstance(m.items[0], Submodel1) + assert m.items[0].level == 1 + assert isinstance(m.items[1], Submodel2) + assert m.items[1].name == "Robert" + + m = Model.construct(items=[{"level": -1}, 156]) + assert len(m.items) == 2 + assert isinstance(m.items[0], Submodel1) + assert m.items[0].level == -1 + assert cast(Any, m.items[1]) == 156 + + +def test_union_of_lists() -> None: + class SubModel1(BaseModel): + level: int + + class SubModel2(BaseModel): + name: str + + class Model(BaseModel): + items: Union[List[SubModel1], List[SubModel2]] + + # with one valid entry + m = Model.construct(items=[{"name": "Robert"}]) + assert len(m.items) == 1 + assert isinstance(m.items[0], SubModel2) + assert m.items[0].name == "Robert" + + # with two entries pointing to different types + m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) + assert len(m.items) == 2 + assert isinstance(m.items[0], SubModel1) + assert m.items[0].level == 1 + assert isinstance(m.items[1], SubModel1) + assert cast(Any, m.items[1]).name == "Robert" + + # with two entries pointing to *completely* different types + m = Model.construct(items=[{"level": -1}, 156]) + assert len(m.items) == 2 + assert isinstance(m.items[0], SubModel1) + assert m.items[0].level == -1 + assert cast(Any, m.items[1]) == 156 + + +def test_dict_of_union() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + foo: str + + class Model(BaseModel): + data: Dict[str, Union[SubModel1, SubModel2]] + + m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) + assert len(list(m.data.keys())) == 2 + assert isinstance(m.data["hello"], SubModel1) + assert m.data["hello"].name == "there" + assert isinstance(m.data["foo"], SubModel2) + assert m.data["foo"].foo == "bar" + + # TODO: test mismatched type + + +def test_double_nested_union() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + bar: str + + class Model(BaseModel): + data: Dict[str, List[Union[SubModel1, SubModel2]]] + + m = Model.construct(data={"foo": [{"bar": "baz"}, {"name": "Robert"}]}) + assert len(m.data["foo"]) == 2 + + entry1 = m.data["foo"][0] + assert isinstance(entry1, SubModel2) + assert entry1.bar == "baz" + + entry2 = m.data["foo"][1] + assert isinstance(entry2, SubModel1) + assert entry2.name == "Robert" + + # TODO: test mismatched type + + +def test_union_of_dict() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + foo: str + + class Model(BaseModel): + data: Union[Dict[str, SubModel1], Dict[str, SubModel2]] + + m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) + assert len(list(m.data.keys())) == 2 + assert isinstance(m.data["hello"], SubModel1) + assert m.data["hello"].name == "there" + assert isinstance(m.data["foo"], SubModel1) + assert cast(Any, m.data["foo"]).foo == "bar" + + +def test_iso8601_datetime() -> None: + class Model(BaseModel): + created_at: datetime + + expected = datetime(2019, 12, 27, 18, 11, 19, 117000, tzinfo=timezone.utc) + + if PYDANTIC_V1: + expected_json = '{"created_at": "2019-12-27T18:11:19.117000+00:00"}' + else: + expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' + + model = Model.construct(created_at="2019-12-27T18:11:19.117Z") + assert model.created_at == expected + assert model_json(model) == expected_json + + model = parse_obj(Model, dict(created_at="2019-12-27T18:11:19.117Z")) + assert model.created_at == expected + assert model_json(model) == expected_json + + +def test_does_not_coerce_int() -> None: + class Model(BaseModel): + bar: int + + assert Model.construct(bar=1).bar == 1 + assert Model.construct(bar=10.9).bar == 10.9 + assert Model.construct(bar="19").bar == "19" # type: ignore[comparison-overlap] + assert Model.construct(bar=False).bar is False + + +def test_int_to_float_safe_conversion() -> None: + class Model(BaseModel): + float_field: float + + m = Model.construct(float_field=10) + assert m.float_field == 10.0 + assert isinstance(m.float_field, float) + + m = Model.construct(float_field=10.12) + assert m.float_field == 10.12 + assert isinstance(m.float_field, float) + + # number too big + m = Model.construct(float_field=2**53 + 1) + assert m.float_field == 2**53 + 1 + assert isinstance(m.float_field, int) + + +def test_deprecated_alias() -> None: + class Model(BaseModel): + resource_id: str = Field(alias="model_id") + + @property + def model_id(self) -> str: + return self.resource_id + + m = Model.construct(model_id="id") + assert m.model_id == "id" + assert m.resource_id == "id" + assert m.resource_id is m.model_id + + m = parse_obj(Model, {"model_id": "id"}) + assert m.model_id == "id" + assert m.resource_id == "id" + assert m.resource_id is m.model_id + + +def test_omitted_fields() -> None: + class Model(BaseModel): + resource_id: Optional[str] = None + + m = Model.construct() + assert m.resource_id is None + assert "resource_id" not in m.model_fields_set + + m = Model.construct(resource_id=None) + assert m.resource_id is None + assert "resource_id" in m.model_fields_set + + m = Model.construct(resource_id="foo") + assert m.resource_id == "foo" + assert "resource_id" in m.model_fields_set + + +def test_to_dict() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.to_dict() == {"FOO": "hello"} + assert m.to_dict(use_api_names=False) == {"foo": "hello"} + + m2 = Model() + assert m2.to_dict() == {} + assert m2.to_dict(exclude_unset=False) == {"FOO": None} + assert m2.to_dict(exclude_unset=False, exclude_none=True) == {} + assert m2.to_dict(exclude_unset=False, exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.to_dict() == {"FOO": None} + assert m3.to_dict(exclude_none=True) == {} + assert m3.to_dict(exclude_defaults=True) == {} + + class Model2(BaseModel): + created_at: datetime + + time_str = "2024-03-21T11:39:01.275859" + m4 = Model2.construct(created_at=time_str) + assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} + assert m4.to_dict(mode="json") == {"created_at": time_str} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.to_dict(warnings=False) + + +def test_forwards_compat_model_dump_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.model_dump() == {"foo": "hello"} + assert m.model_dump(include={"bar"}) == {} + assert m.model_dump(exclude={"foo"}) == {} + assert m.model_dump(by_alias=True) == {"FOO": "hello"} + + m2 = Model() + assert m2.model_dump() == {"foo": None} + assert m2.model_dump(exclude_unset=True) == {} + assert m2.model_dump(exclude_none=True) == {} + assert m2.model_dump(exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.model_dump() == {"foo": None} + assert m3.model_dump(exclude_none=True) == {} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump(warnings=False) + + +def test_compat_method_no_error_for_warnings() -> None: + class Model(BaseModel): + foo: Optional[str] + + m = Model(foo="hello") + assert isinstance(model_dump(m, warnings=False), dict) + + +def test_to_json() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.to_json()) == {"FOO": "hello"} + assert json.loads(m.to_json(use_api_names=False)) == {"foo": "hello"} + + if PYDANTIC_V1: + assert m.to_json(indent=None) == '{"FOO": "hello"}' + else: + assert m.to_json(indent=None) == '{"FOO":"hello"}' + + m2 = Model() + assert json.loads(m2.to_json()) == {} + assert json.loads(m2.to_json(exclude_unset=False)) == {"FOO": None} + assert json.loads(m2.to_json(exclude_unset=False, exclude_none=True)) == {} + assert json.loads(m2.to_json(exclude_unset=False, exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.to_json()) == {"FOO": None} + assert json.loads(m3.to_json(exclude_none=True)) == {} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.to_json(warnings=False) + + +def test_forwards_compat_model_dump_json_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.model_dump_json()) == {"foo": "hello"} + assert json.loads(m.model_dump_json(include={"bar"})) == {} + assert json.loads(m.model_dump_json(include={"foo"})) == {"foo": "hello"} + assert json.loads(m.model_dump_json(by_alias=True)) == {"FOO": "hello"} + + assert m.model_dump_json(indent=2) == '{\n "foo": "hello"\n}' + + m2 = Model() + assert json.loads(m2.model_dump_json()) == {"foo": None} + assert json.loads(m2.model_dump_json(exclude_unset=True)) == {} + assert json.loads(m2.model_dump_json(exclude_none=True)) == {} + assert json.loads(m2.model_dump_json(exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.model_dump_json()) == {"foo": None} + assert json.loads(m3.model_dump_json(exclude_none=True)) == {} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump_json(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump_json(warnings=False) + + +def test_type_compat() -> None: + # our model type can be assigned to Pydantic's model type + + def takes_pydantic(model: pydantic.BaseModel) -> None: # noqa: ARG001 + ... + + class OurModel(BaseModel): + foo: Optional[str] = None + + takes_pydantic(OurModel()) + + +def test_annotated_types() -> None: + class Model(BaseModel): + value: str + + m = construct_type( + value={"value": "foo"}, + type_=cast(Any, Annotated[Model, "random metadata"]), + ) + assert isinstance(m, Model) + assert m.value == "foo" + + +def test_discriminated_unions_invalid_data() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "a", "data": 100}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, A) + assert m.type == "a" + if PYDANTIC_V1: + # pydantic v1 automatically converts inputs to strings + # if the expected type is a str + assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] + + +def test_discriminated_unions_unknown_variant() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + m = construct_type( + value={"type": "c", "data": None, "new_thing": "bar"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + + # just chooses the first variant + assert isinstance(m, A) + assert m.type == "c" # type: ignore[comparison-overlap] + assert m.data == None # type: ignore[unreachable] + assert m.new_thing == "bar" + + +def test_discriminated_unions_invalid_data_nested_unions() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + class C(BaseModel): + type: Literal["c"] + + data: bool + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "c", "data": "foo"}, + type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, C) + assert m.type == "c" + assert m.data == "foo" # type: ignore[comparison-overlap] + + +def test_discriminated_unions_with_aliases_invalid_data() -> None: + class A(BaseModel): + foo_type: Literal["a"] = Field(alias="type") + + data: str + + class B(BaseModel): + foo_type: Literal["b"] = Field(alias="type") + + data: int + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), + ) + assert isinstance(m, B) + assert m.foo_type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "a", "data": 100}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), + ) + assert isinstance(m, A) + assert m.foo_type == "a" + if PYDANTIC_V1: + # pydantic v1 automatically converts inputs to strings + # if the expected type is a str + assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] + + +def test_discriminated_unions_overlapping_discriminators_invalid_data() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["a"] + + data: int + + m = construct_type( + value={"type": "a", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "a" + assert m.data == "foo" # type: ignore[comparison-overlap] + + +def test_discriminated_unions_invalid_data_uses_cache() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + UnionType = cast(Any, Union[A, B]) + + assert not DISCRIMINATOR_CACHE.get(UnionType) + + m = construct_type( + value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + discriminator = DISCRIMINATOR_CACHE.get(UnionType) + assert discriminator is not None + + m = construct_type( + value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + # if the discriminator details object stays the same between invocations then + # we hit the cache + assert DISCRIMINATOR_CACHE.get(UnionType) is discriminator + + +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") +def test_type_alias_type() -> None: + Alias = TypeAliasType("Alias", str) # pyright: ignore + + class Model(BaseModel): + alias: Alias + union: Union[int, Alias] + + m = construct_type(value={"alias": "foo", "union": "bar"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.alias, str) + assert m.alias == "foo" + assert isinstance(m.union, str) + assert m.union == "bar" + + +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") +def test_field_named_cls() -> None: + class Model(BaseModel): + cls: str + + m = construct_type(value={"cls": "foo"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.cls, str) + + +def test_discriminated_union_case() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["b"] + + data: List[Union[A, object]] + + class ModelA(BaseModel): + type: Literal["modelA"] + + data: int + + class ModelB(BaseModel): + type: Literal["modelB"] + + required: str + + data: Union[A, B] + + # when constructing ModelA | ModelB, value data doesn't match ModelB exactly - missing `required` + m = construct_type( + value={"type": "modelB", "data": {"type": "a", "data": True}}, + type_=cast(Any, Annotated[Union[ModelA, ModelB], PropertyInfo(discriminator="type")]), + ) + + assert isinstance(m, ModelB) + + +def test_nested_discriminated_union() -> None: + class InnerType1(BaseModel): + type: Literal["type_1"] + + class InnerModel(BaseModel): + inner_value: str + + class InnerType2(BaseModel): + type: Literal["type_2"] + some_inner_model: InnerModel + + class Type1(BaseModel): + base_type: Literal["base_type_1"] + value: Annotated[ + Union[ + InnerType1, + InnerType2, + ], + PropertyInfo(discriminator="type"), + ] + + class Type2(BaseModel): + base_type: Literal["base_type_2"] + + T = Annotated[ + Union[ + Type1, + Type2, + ], + PropertyInfo(discriminator="base_type"), + ] + + model = construct_type( + type_=T, + value={ + "base_type": "base_type_1", + "value": { + "type": "type_2", + }, + }, + ) + assert isinstance(model, Type1) + assert isinstance(model.value, InnerType2) + + +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2 for now") +def test_extra_properties() -> None: + class Item(BaseModel): + prop: int + + class Model(BaseModel): + __pydantic_extra__: Dict[str, Item] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + + other: str + + if TYPE_CHECKING: + + def __getattr__(self, attr: str) -> Item: ... + + model = construct_type( + type_=Model, + value={ + "a": {"prop": 1}, + "other": "foo", + }, + ) + assert isinstance(model, Model) + assert model.a.prop == 1 + assert isinstance(model.a, Item) + assert model.other == "foo" diff --git a/tests/test_models_results.py b/tests/test_models_results.py deleted file mode 100644 index 0b242ff2..00000000 --- a/tests/test_models_results.py +++ /dev/null @@ -1,257 +0,0 @@ -import unittest - -from imagekitio.utils.utils import convert_to_response_object -from imagekitio.models.results.UploadFileResult import UploadFileResult -from imagekitio.models.results.EmbeddedMetadata import EmbeddedMetadata -from imagekitio.models.results.ResponseMetadata import ResponseMetadata -from imagekitio.models.results.VersionInfo import VersionInfo -from imagekitio.models.results.TagsResult import TagsResult -from imagekitio.models.results.MetadataExifThumbnail import MetadataExifThumbnail -from imagekitio.models.results.MetadataExifImage import MetadataExifImage -from imagekitio.models.results.MetadataExifGPS import MetadataExifGPS -from imagekitio.models.results.MetadataExifExif import MetadataExifExif -from imagekitio.models.results.MetadataExif import MetadataExif -from imagekitio.models.results.GetMetadataResult import GetMetadataResult -from imagekitio.models.results.GetBulkJobStatusResult import GetBulkJobStatusResult -from imagekitio.models.results.FolderResult import FolderResult -from imagekitio.models.results.FileResultWithResponseMetadata import FileResultWithResponseMetadata -from imagekitio.models.results.FileResult import FileResult -from imagekitio.models.results.CustomMetadataFieldsResult import CustomMetadataFieldsResult -from imagekitio.models.results.CustomMetadataFieldsResultWithResponseMetadata import CustomMetadataFieldsResultWithResponseMetadata -from imagekitio.models.results.CustomMetadataSchema import CustomMetadataSchema -from imagekitio.models.results.BulkDeleteFileResult import BulkDeleteFileResult -from imagekitio.models.results.AITags import AITags -from imagekitio.models.results.MetadataExifInteroperability import MetadataExifInteroperability - - -from requests.models import Response - -import responses - - -gps = { - "GPSVersionId":"1.2" -} -thumbnail ={ - "compression":12, - "YResolution":1, - "XResolution":1, - "resolutionUnit":"DPI", - "thumbnailOffset":12, - "thumbnailLength":12, -} -interoperability = { - "interopVersion":1.1, - "interopIndex":1.2 -} -image = { - "make":None, - "model":None, - "orientation":None, - "XResolution":None, - "YResolution":None, - "resolutionUnit":None, - "software":None, - "modifyDate":None, - "YCbCrPositioning":None, - "exifOffset":None, - "gpsInfo":None, -} -exif = { - "exposureTime":10, - "FNumber":1, - "exposureProgram":None, - "ISO":"2001", - "exifVersion":1.2, - "dateTimeOriginal":None, - "createDate":None, - "shutterSpeedValue":122, - "apertureValue":12, - "exposureCompensation":12, - "meteringMode":None, - "flash":"white", - "focalLength":None, - "subSecTime":None, - "subSecTimeOriginal":None, - "subSecTimeDigitized":None, - "flashpixVersion":None, - "colorSpace":None, - "exifImageWidth":None, - "exifImageHeight":None, - "interopOffset":None, - "focalPlaneXResolution":None, - "focalPlaneYResolution":None, - "focalPlaneResolutionUnit":None, - "customRendered":None, - "exposureMode":None, - "whiteBalance":None, - "sceneCaptureType":None, -} -class TestModelsResults(unittest.TestCase): - - @responses.activate() - def test_models_results_embedded_metadata_with_variable_attributes(self): - withVariableAttributes = EmbeddedMetadata(12,12,dpi=14) - - self.assertEqual(withVariableAttributes.x_resolution,12) - self.assertEqual(withVariableAttributes.y_resolution,12) - self.assertEqual(withVariableAttributes.date_created,None) - self.assertEqual(withVariableAttributes.date_time_created,None) - self.assertEqual(withVariableAttributes.dpi,14) - self.assertEqual(withVariableAttributes.make,None) - - def test_models_results_response_metadata_with_variable_attributes(self): - meta = ResponseMetadata(None,200,None,message="Successfully Done") - - self.assertEqual(meta.raw,None) - self.assertEqual(meta.http_status_code,200) - self.assertEqual(meta.headers,None) - self.assertEqual(meta.message,"Successfully Done") - self.assertEqual(meta.a,None) - - def test_models_results_version_info_with_variable_attributes(self): - vi = VersionInfo(123,"0.1.12",description="Testing Purpose") - - self.assertEqual(vi.id,123) - self.assertEqual(vi.name,"0.1.12") - self.assertEqual(vi.description,"Testing Purpose") - self.assertEqual(vi.random,None) - - def test_models_results_tags_result_with_variable_attributes(self): - tr = TagsResult(successfully_updated_file_ids=None,random1="123") - - self.assertEqual(tr.successfully_updated_file_ids,None) - self.assertEqual(tr.random1,"123") - self.assertEqual(tr.random2,None) - - def test_models_results_metadata_exif_thumbnail_with_variable_attributes(self): - met = MetadataExifThumbnail(None,12,12,None,1,200,name="Thumbnail1",) - - self.assertEqual(met.compression,None) - self.assertEqual(met.x_resolution,12) - self.assertEqual(met.y_resolution,12) - self.assertEqual(met.resolution_unit,None) - self.assertEqual(met.thumbnail_offset,1) - self.assertEqual(met.thumbnail_length,200) - self.assertEqual(met.name,"Thumbnail1") - self.assertEqual(met.random,None) - - - def test_models_results_metadata_exif_image_with_variable_attributes(self): - mei = MetadataExifImage(software="picasso",name="Image1") - - self.assertEqual(mei.software,"picasso") - self.assertEqual(mei.name,"Image1") - self.assertEqual(mei.random,None) - - def test_models_results_metadata_exif_gps_with_variable_attributes(self): - meg = MetadataExifGPS("0.1",longitude="12124.4124",latitude="121523.12312") - - self.assertEqual(meg.gps_version_id,["0",".","1"]) - self.assertEqual(meg.longitude,"12124.4124") - self.assertEqual(meg.latitude,"121523.12312") - self.assertEqual(meg.random,None) - - def test_models_results_metadata_exif_exif_with_variable_attributes(self): - mee = MetadataExifExif(12,shutter_speed_value=120,camera_model="canon x") - - self.assertEqual(mee.exposure_time,12) - self.assertEqual(mee.shutter_speed_value,120) - self.assertEqual(mee.camera_model,"canon x") - self.assertEqual(mee.random,None) - - def test_models_results_metadata_exif_with_variable_attributes(self): - me = MetadataExif(gps=MetadataExifGPS("0.9"),name="exif") - - self.assertEqual(me.gps.gps_version_id,["0",".","9"]) - self.assertEqual(me.name,"exif") - self.assertEqual(me.random,None) - - def test_models_results_get_metadata_result_with_variable_attributes(self): - getMetadataResult = GetMetadataResult(height=100,width=100,device="canon") - - self.assertEqual(getMetadataResult.width,100) - self.assertEqual(getMetadataResult.height,100) - self.assertEqual(getMetadataResult.device,"canon") - self.assertEqual(getMetadataResult.random,None) - - def test_models_results_get_bulk_job_status_result_with_variable_attributes(self): - getBulkJobStatusResult = GetBulkJobStatusResult(job_id="123",type="jpg",status="active",num_job=2) - - self.assertEqual(getBulkJobStatusResult.job_id,"123") - self.assertEqual(getBulkJobStatusResult.type,"jpg") - self.assertEqual(getBulkJobStatusResult.status,"active") - self.assertEqual(getBulkJobStatusResult.num_job,2) - self.assertEqual(getBulkJobStatusResult.random,None) - - def test_folder_results_folder_result_with_variable_attributes(self): - folderResult = FolderResult("123",address="/abc/home") - - self.assertEqual(folderResult.job_id,"123") - self.assertEqual(folderResult.address,"/abc/home") - self.assertEqual(folderResult.random,None) - - def test_file_result_with_response_metadata_with_variable_attributes(self): - fileResultWithResponseMetadata = FileResultWithResponseMetadata("jpg",name="abc",file_health="healthy") - self.assertEqual(fileResultWithResponseMetadata.type,"jpg") - self.assertEqual(fileResultWithResponseMetadata.name,"abc") - self.assertEqual(fileResultWithResponseMetadata.file_health,"healthy") - self.assertEqual(fileResultWithResponseMetadata.random,None) - - def test_file_result_with_variable_attributes(self): - fileResult = FileResult("jpg",name="abc",file_health="healthy") - - self.assertEqual(fileResult.type,"jpg") - self.assertEqual(fileResult.name,"abc") - self.assertEqual(fileResult.file_health,"healthy") - self.assertEqual(fileResult.random,None) - - def test_custom_metadata_scheme_with_variable_attributes(self): - customMetadataSchema = CustomMetadataSchema("jpg",max_length=1000,name="abc") - - self.assertEqual(customMetadataSchema.max_length,1000) - self.assertEqual(customMetadataSchema.type,"jpg") - self.assertEqual(customMetadataSchema.name,"abc") - self.assertEqual(customMetadataSchema.random,None) - - def test_custom_metadata_fields_result_with_response_metadata_with_variable_attributes(self): - customMetadataFieldsResultWithResponseMetadata = CustomMetadataFieldsResultWithResponseMetadata(id="1234",label="123",num_fields=5) - - self.assertEqual(customMetadataFieldsResultWithResponseMetadata.id,"1234") - self.assertEqual(customMetadataFieldsResultWithResponseMetadata.label,"123") - self.assertEqual(customMetadataFieldsResultWithResponseMetadata.num_fields,5) - self.assertEqual(customMetadataFieldsResultWithResponseMetadata.random,None) - - def test_custom_metadata_fields_result_with_variable_attributes(self): - customMetadataFieldsResult = CustomMetadataFieldsResult(id="1234",label="123",num_fields=5) - - self.assertEqual(customMetadataFieldsResult.id,"1234") - self.assertEqual(customMetadataFieldsResult.label,"123") - self.assertEqual(customMetadataFieldsResult.num_fields,5) - self.assertEqual(customMetadataFieldsResult.random,None) - - def test_bulk_delete_file_result_with_variable_attributes(self): - bulkDeleteFileResult = BulkDeleteFileResult(successfully_deleted_file_ids=["1","2","232"],num_files_deleted=3) - - self.assertEqual(bulkDeleteFileResult.successfully_deleted_file_ids,["1","2","232"]) - self.assertEqual(bulkDeleteFileResult.num_files_deleted,3) - self.assertEqual(bulkDeleteFileResult.random,None) - - def test_ai_tags_with_variable_attributes(self): - aITags = AITags("abc",confidence="0.96",model="KNN") - - self.assertEqual(aITags.name,"abc") - self.assertEqual(aITags.confidence,"0.96") - self.assertEqual(aITags.random,None) - - def test_metadata_exif_interoperability_with_variable_attributes(self): - metadataExifInteroperability = MetadataExifInteroperability(1,"0.4.3",ri = 3) - - self.assertEqual(metadataExifInteroperability.interop_index,1) - self.assertEqual(metadataExifInteroperability.interop_version,"0.4.3") - self.assertEqual(metadataExifInteroperability.ri,3) - self.assertEqual(metadataExifInteroperability.random,None) - - - - \ No newline at end of file diff --git a/tests/test_qs.py b/tests/test_qs.py new file mode 100644 index 00000000..cfc5985a --- /dev/null +++ b/tests/test_qs.py @@ -0,0 +1,78 @@ +from typing import Any, cast +from functools import partial +from urllib.parse import unquote + +import pytest + +from imagekitio._qs import Querystring, stringify + + +def test_empty() -> None: + assert stringify({}) == "" + assert stringify({"a": {}}) == "" + assert stringify({"a": {"b": {"c": {}}}}) == "" + + +def test_basic() -> None: + assert stringify({"a": 1}) == "a=1" + assert stringify({"a": "b"}) == "a=b" + assert stringify({"a": True}) == "a=true" + assert stringify({"a": False}) == "a=false" + assert stringify({"a": 1.23456}) == "a=1.23456" + assert stringify({"a": None}) == "" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_nested_dotted(method: str) -> None: + if method == "class": + serialise = Querystring(nested_format="dots").stringify + else: + serialise = partial(stringify, nested_format="dots") + + assert unquote(serialise({"a": {"b": "c"}})) == "a.b=c" + assert unquote(serialise({"a": {"b": "c", "d": "e", "f": "g"}})) == "a.b=c&a.d=e&a.f=g" + assert unquote(serialise({"a": {"b": {"c": {"d": "e"}}}})) == "a.b.c.d=e" + assert unquote(serialise({"a": {"b": True}})) == "a.b=true" + + +def test_nested_brackets() -> None: + assert unquote(stringify({"a": {"b": "c"}})) == "a[b]=c" + assert unquote(stringify({"a": {"b": "c", "d": "e", "f": "g"}})) == "a[b]=c&a[d]=e&a[f]=g" + assert unquote(stringify({"a": {"b": {"c": {"d": "e"}}}})) == "a[b][c][d]=e" + assert unquote(stringify({"a": {"b": True}})) == "a[b]=true" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_comma(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="comma").stringify + else: + serialise = partial(stringify, array_format="comma") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in=foo,bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b]=true,false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b]=true,false,true" + + +def test_array_repeat() -> None: + assert unquote(stringify({"in": ["foo", "bar"]})) == "in=foo&in=bar" + assert unquote(stringify({"a": {"b": [True, False]}})) == "a[b]=true&a[b]=false" + assert unquote(stringify({"a": {"b": [True, False, None, True]}})) == "a[b]=true&a[b]=false&a[b]=true" + assert unquote(stringify({"in": ["foo", {"b": {"c": ["d", "e"]}}]})) == "in=foo&in[b][c]=d&in[b][c]=e" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_brackets(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="brackets").stringify + else: + serialise = partial(stringify, array_format="brackets") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in[]=foo&in[]=bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b][]=true&a[b][]=false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b][]=true&a[b][]=false&a[b][]=true" + + +def test_unknown_array_format() -> None: + with pytest.raises(NotImplementedError, match="Unknown array_format value: foo, choose from comma, repeat"): + stringify({"a": ["foo", "bar"]}, array_format=cast(Any, "foo")) diff --git a/tests/test_required_args.py b/tests/test_required_args.py new file mode 100644 index 00000000..e7cde47a --- /dev/null +++ b/tests/test_required_args.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import pytest + +from imagekitio._utils import required_args + + +def test_too_many_positional_params() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + with pytest.raises(TypeError, match=r"foo\(\) takes 1 argument\(s\) but 2 were given"): + foo("a", "b") # type: ignore + + +def test_positional_param() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + assert foo("a") == "a" + assert foo(None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_keyword_only_param() -> None: + @required_args(["a"]) + def foo(*, a: str | None = None) -> str | None: + return a + + assert foo(a="a") == "a" + assert foo(a=None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_multiple_params() -> None: + @required_args(["a", "b", "c"]) + def foo(a: str = "", *, b: str = "", c: str = "") -> str | None: + return f"{a} {b} {c}" + + assert foo(a="a", b="b", c="c") == "a b c" + + error_message = r"Missing required arguments.*" + + with pytest.raises(TypeError, match=error_message): + foo() + + with pytest.raises(TypeError, match=error_message): + foo(a="a") + + with pytest.raises(TypeError, match=error_message): + foo(b="b") + + with pytest.raises(TypeError, match=error_message): + foo(c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'a'"): + foo(b="a", c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'b'"): + foo("a", c="c") + + +def test_multiple_variants() -> None: + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: str | None = None) -> str | None: + return a if a is not None else b + + assert foo(a="foo") == "foo" + assert foo(b="bar") == "bar" + assert foo(a=None) is None + assert foo(b=None) is None + + # TODO: this error message could probably be improved + with pytest.raises( + TypeError, + match=r"Missing required arguments; Expected either \('a'\) or \('b'\) arguments to be given", + ): + foo() + + +def test_multiple_params_multiple_variants() -> None: + @required_args(["a", "b"], ["c"]) + def foo(*, a: str | None = None, b: str | None = None, c: str | None = None) -> str | None: + if a is not None: + return a + if b is not None: + return b + return c + + error_message = r"Missing required arguments; Expected either \('a' and 'b'\) or \('c'\) arguments to be given" + + with pytest.raises(TypeError, match=error_message): + foo(a="foo") + + with pytest.raises(TypeError, match=error_message): + foo(b="bar") + + with pytest.raises(TypeError, match=error_message): + foo() + + assert foo(a=None, b="bar") == "bar" + assert foo(c=None) is None + assert foo(c="foo") == "foo" diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 00000000..30b86cc6 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,277 @@ +import json +from typing import Any, List, Union, cast +from typing_extensions import Annotated + +import httpx +import pytest +import pydantic + +from imagekitio import ImageKit, BaseModel, AsyncImageKit +from imagekitio._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + BinaryAPIResponse, + AsyncBinaryAPIResponse, + extract_response_type, +) +from imagekitio._streaming import Stream +from imagekitio._base_client import FinalRequestOptions + + +class ConcreteBaseAPIResponse(APIResponse[bytes]): ... + + +class ConcreteAPIResponse(APIResponse[List[str]]): ... + + +class ConcreteAsyncAPIResponse(APIResponse[httpx.Response]): ... + + +def test_extract_response_type_direct_classes() -> None: + assert extract_response_type(BaseAPIResponse[str]) == str + assert extract_response_type(APIResponse[str]) == str + assert extract_response_type(AsyncAPIResponse[str]) == str + + +def test_extract_response_type_direct_class_missing_type_arg() -> None: + with pytest.raises( + RuntimeError, + match="Expected type to have a type argument at index 0 but it did not", + ): + extract_response_type(AsyncAPIResponse) + + +def test_extract_response_type_concrete_subclasses() -> None: + assert extract_response_type(ConcreteBaseAPIResponse) == bytes + assert extract_response_type(ConcreteAPIResponse) == List[str] + assert extract_response_type(ConcreteAsyncAPIResponse) == httpx.Response + + +def test_extract_response_type_binary_response() -> None: + assert extract_response_type(BinaryAPIResponse) == bytes + assert extract_response_type(AsyncBinaryAPIResponse) == bytes + + +class PydanticModel(pydantic.BaseModel): ... + + +def test_response_parse_mismatched_basemodel(client: ImageKit) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo"), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + with pytest.raises( + TypeError, + match="Pydantic models must subclass our base model type, e.g. `from imagekitio import BaseModel`", + ): + response.parse(to=PydanticModel) + + +@pytest.mark.asyncio +async def test_async_response_parse_mismatched_basemodel(async_client: AsyncImageKit) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo"), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + with pytest.raises( + TypeError, + match="Pydantic models must subclass our base model type, e.g. `from imagekitio import BaseModel`", + ): + await response.parse(to=PydanticModel) + + +def test_response_parse_custom_stream(client: ImageKit) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo"), + client=client, + stream=True, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + stream = response.parse(to=Stream[int]) + assert stream._cast_to == int + + +@pytest.mark.asyncio +async def test_async_response_parse_custom_stream(async_client: AsyncImageKit) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo"), + client=async_client, + stream=True, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + stream = await response.parse(to=Stream[int]) + assert stream._cast_to == int + + +class CustomModel(BaseModel): + foo: str + bar: int + + +def test_response_parse_custom_model(client: ImageKit) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse(to=CustomModel) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +@pytest.mark.asyncio +async def test_async_response_parse_custom_model(async_client: AsyncImageKit) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=CustomModel) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +def test_response_parse_annotated_type(client: ImageKit) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +async def test_async_response_parse_annotated_type(async_client: AsyncImageKit) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +@pytest.mark.parametrize( + "content, expected", + [ + ("false", False), + ("true", True), + ("False", False), + ("True", True), + ("TrUe", True), + ("FalSe", False), + ], +) +def test_response_parse_bool(client: ImageKit, content: str, expected: bool) -> None: + response = APIResponse( + raw=httpx.Response(200, content=content), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + result = response.parse(to=bool) + assert result is expected + + +@pytest.mark.parametrize( + "content, expected", + [ + ("false", False), + ("true", True), + ("False", False), + ("True", True), + ("TrUe", True), + ("FalSe", False), + ], +) +async def test_async_response_parse_bool(client: AsyncImageKit, content: str, expected: bool) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=content), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + result = await response.parse(to=bool) + assert result is expected + + +class OtherModel(BaseModel): + a: str + + +@pytest.mark.parametrize("client", [False], indirect=True) # loose validation +def test_response_parse_expect_model_union_non_json_content(client: ImageKit) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_client", [False], indirect=True) # loose validation +async def test_async_response_parse_expect_model_union_non_json_content(async_client: AsyncImageKit) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 00000000..7db128c2 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from typing import Iterator, AsyncIterator + +import httpx +import pytest + +from imagekitio import ImageKit, AsyncImageKit +from imagekitio._streaming import Stream, AsyncStream, ServerSentEvent + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_basic(sync: bool, client: ImageKit, async_client: AsyncImageKit) -> None: + def body() -> Iterator[bytes]: + yield b"event: completion\n" + yield b'data: {"foo":true}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_missing_event(sync: bool, client: ImageKit, async_client: AsyncImageKit) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"foo":true}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_event_missing_data(sync: bool, client: ImageKit, async_client: AsyncImageKit) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.data == "" + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events(sync: bool, client: ImageKit, async_client: AsyncImageKit) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + yield b"event: completion\n" + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.data == "" + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.data == "" + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events_with_data(sync: bool, client: ImageKit, async_client: AsyncImageKit) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo":true}\n' + yield b"\n" + yield b"event: completion\n" + yield b'data: {"bar":false}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.json() == {"bar": False} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines_with_empty_line(sync: bool, client: ImageKit, async_client: AsyncImageKit) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: \n" + yield b"data:\n" + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + assert sse.data == '{\n"foo":\n\n\ntrue}' + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_json_escaped_double_new_line(sync: bool, client: ImageKit, async_client: AsyncImageKit) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo": "my long\\n\\ncontent"}' + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": "my long\n\ncontent"} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines(sync: bool, client: ImageKit, async_client: AsyncImageKit) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_special_new_line_character( + sync: bool, + client: ImageKit, + async_client: AsyncImageKit, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":" culpa"}\n' + yield b"\n" + yield b'data: {"content":" \xe2\x80\xa8"}\n' + yield b"\n" + yield b'data: {"content":"foo"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " culpa"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " 
"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "foo"} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multi_byte_character_multiple_chunks( + sync: bool, + client: ImageKit, + async_client: AsyncImageKit, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":"' + # bytes taken from the string 'известни' and arbitrarily split + # so that some multi-byte characters span multiple chunks + yield b"\xd0" + yield b"\xb8\xd0\xb7\xd0" + yield b"\xb2\xd0\xb5\xd1\x81\xd1\x82\xd0\xbd\xd0\xb8" + yield b'"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "известни"} + + +async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]: + for chunk in iter: + yield chunk + + +async def iter_next(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> ServerSentEvent: + if isinstance(iter, AsyncIterator): + return await iter.__anext__() + + return next(iter) + + +async def assert_empty_iter(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> None: + with pytest.raises((StopAsyncIteration, RuntimeError)): + await iter_next(iter) + + +def make_event_iterator( + content: Iterator[bytes], + *, + sync: bool, + client: ImageKit, + async_client: AsyncImageKit, +) -> Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]: + if sync: + return Stream(cast_to=object, client=client, response=httpx.Response(200, content=content))._iter_events() + + return AsyncStream( + cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content)) + )._iter_events() diff --git a/tests/test_tags_ops.py b/tests/test_tags_ops.py deleted file mode 100644 index a7945cd0..00000000 --- a/tests/test_tags_ops.py +++ /dev/null @@ -1,337 +0,0 @@ -import json -import os - -import responses - -from imagekitio.client import ImageKit -from imagekitio.constants.url import URL -from imagekitio.exceptions.ForbiddenException import ForbiddenException -from imagekitio.exceptions.NotFoundException import NotFoundException -from imagekitio.utils.formatter import camel_dict_to_snake_dict -from tests.helpers import ( - ClientTestCase, - get_auth_headers_for_test, -) - -imagekit_obj = ImageKit( - private_key="private_fake:", - public_key="public_fake123:", - url_endpoint="fake.com", -) - - -class TestTags(ClientTestCase): - """ - TestTags class used to test Add and Remove methods - """ - - filename = "test" - - file_id = "fake_123" - - @responses.activate - def test_add_tags_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/addTags".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.add_tags( - file_ids=[self.file_id], tags=["add-tag-1", "add-tag-2"] - ) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_add_tags_succeeds(self): - """ - Tests if add tags succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/addTags".format(URL.API_BASE_URL) - headers = {} - headers.update(get_auth_headers_for_test()) - responses.add( - responses.POST, - url, - body='{"successfullyUpdatedFileIds": ["fake_123"]}', - headers=headers, - content_type="application/json", - ) - - resp = self.client.add_tags( - file_ids=[self.file_id], tags=["add-tag-1", "add-tag-2"] - ) - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 200, - "raw": {"successfullyUpdatedFileIds": ["fake_123"]}, - } - request_body = json.dumps( - json.loads( - """{ - "fileIds": ["fake_123"], - "tags": ["add-tag-1", "add-tag-2"] - }""" - ) - ) - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual(["fake_123"], resp.successfully_updated_file_ids) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/files/addTags", responses.calls[0].request.url - ) - - @responses.activate - def test_add_tags_fails_with_404_exception(self) -> None: - """Test add tags raises 404 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/addTags".format(URL.API_BASE_URL) - headers = {} - headers.update(get_auth_headers_for_test()) - try: - responses.add( - responses.POST, - url, - status=404, - body="""{ - "message": "The requested file(s) does not exist.", - "help": "For support kindly contact us at support@imagekit.io .", - "missingFileIds": ["fake_123"] - }""", - headers=headers, - ) - self.client.add_tags( - file_ids=[self.file_id], tags=["add-tag-1", "add-tag-2"] - ) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("The requested file(s) does not exist.", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - self.assertEqual(["fake_123"], e.response_metadata.raw["missingFileIds"]) - - @responses.activate - def test_remove_tags_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/removeTags".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.remove_tags( - file_ids=[self.file_id], tags=["remove-tag-1", "remove-tag-2"] - ) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual("Your account cannot be authenticated.", e.message) - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_remove_tags_succeeds(self): - """ - Tests if remove tags succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/removeTags".format(URL.API_BASE_URL) - headers = {} - headers.update(get_auth_headers_for_test()) - responses.add( - responses.POST, - url, - body='{"successfullyUpdatedFileIds": ["fake_123"]}', - headers=headers, - content_type="application/json", - ) - - resp = self.client.remove_tags( - file_ids=[self.file_id], tags=["remove-tag-1", "remove-tag-2"] - ) - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 200, - "raw": {"successfullyUpdatedFileIds": ["fake_123"]}, - } - request_body = json.dumps( - json.loads( - """{ - "fileIds": ["fake_123"], - "tags": ["remove-tag-1", "remove-tag-2"] - }""" - ) - ) - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual(["fake_123"], resp.successfully_updated_file_ids) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/files/removeTags", responses.calls[0].request.url - ) - - @responses.activate - def test_remove_tags_fails_with_404_exception(self) -> None: - """Test remove tags raises 404 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/removeTags".format(URL.API_BASE_URL) - headers = {} - headers.update(get_auth_headers_for_test()) - try: - responses.add( - responses.POST, - url, - status=404, - body="""{ - "message": "The requested file(s) does not exist.", - "help": "For support kindly contact us at support@imagekit.io .", - "missingFileIds": ["fake_123"] - }""", - headers=headers, - ) - self.client.remove_tags( - file_ids=[self.file_id], tags=["remove-tag-1", "remove-tag-2"] - ) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("The requested file(s) does not exist.", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - self.assertEqual(["fake_123"], e.response_metadata.raw["missingFileIds"]) - - -class TestAITags(ClientTestCase): - """ - TestAITags class used to test Remove AITags method - """ - - image = os.path.join( - os.path.dirname(os.path.realpath(__file__)), "dummy_data/image.png" - ) - filename = "test" - - file_id = "fake_123" - - @responses.activate - def test_remove_ai_tags_fails_on_unauthenticated_request(self): - """ - Tests if the unauthenticated request restricted - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/removeAITags".format(URL.API_BASE_URL) - try: - responses.add( - responses.POST, - url, - status=403, - body="""{'message': 'Your account cannot be authenticated.' - , 'help': 'For support kindly contact us at support@imagekit.io .'}""", - ) - self.client.remove_ai_tags( - file_ids=[self.file_id], ai_tags=["remove-ai-tag1", "remove-ai-tag2"] - ) - self.assertRaises(ForbiddenException) - except ForbiddenException as e: - self.assertEqual(e.message, "Your account cannot be authenticated.") - self.assertEqual(403, e.response_metadata.http_status_code) - - @responses.activate - def test_remove_ai_tags_succeeds(self): - """ - Tests if Remove AI tags succeeds - """ - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/removeAITags".format(URL.API_BASE_URL) - headers = {} - headers.update(get_auth_headers_for_test()) - responses.add( - responses.POST, - url, - body='{"successfullyUpdatedFileIds": ["fake_123"]}', - headers=headers, - content_type="application/json", - ) - - resp = self.client.remove_ai_tags( - file_ids=[self.file_id], ai_tags=["remove-ai-tag-1", "remove-ai-tag-2"] - ) - mock_response_metadata = { - "headers": { - "Content-Type": "application/json", - "Authorization": "Basic ZmFrZTEyMjo=", - }, - "httpStatusCode": 200, - "raw": {"successfullyUpdatedFileIds": ["fake_123"]}, - } - request_body = json.dumps( - json.loads( - """{ - "fileIds": ["fake_123"], - "AITags": ["remove-ai-tag-1", "remove-ai-tag-2"] - }""" - ) - ) - self.assertEqual(request_body, responses.calls[0].request.body) - self.assertEqual(["fake_123"], resp.successfully_updated_file_ids) - self.assertEqual( - camel_dict_to_snake_dict(mock_response_metadata), - resp.response_metadata.__dict__, - ) - self.assertEqual( - "http://test.com/v1/files/removeAITags", responses.calls[0].request.url - ) - - @responses.activate - def test_remove_ai_tags_fails_with_404_exception(self) -> None: - """Test Remove AI tags raises 404 error""" - - URL.API_BASE_URL = "http://test.com" - url = "{}/v1/files/removeAITags".format(URL.API_BASE_URL) - headers = {} - headers.update(get_auth_headers_for_test()) - try: - responses.add( - responses.POST, - url, - status=404, - body="""{ - "message": "The requested file(s) does not exist.", - "help": "For support kindly contact us at support@imagekit.io .", - "missingFileIds": ["fake_123"] - }""", - headers=headers, - ) - self.client.remove_ai_tags( - file_ids=[self.file_id], ai_tags=["remove-ai-tag-1", "remove-ai-tag-2"] - ) - self.assertRaises(NotFoundException) - except NotFoundException as e: - self.assertEqual("The requested file(s) does not exist.", e.message) - self.assertEqual(404, e.response_metadata.http_status_code) - self.assertEqual(["fake_123"], e.response_metadata.raw["missingFileIds"]) diff --git a/tests/test_transform.py b/tests/test_transform.py new file mode 100644 index 00000000..3520530e --- /dev/null +++ b/tests/test_transform.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +import io +import pathlib +from typing import Any, Dict, List, Union, TypeVar, Iterable, Optional, cast +from datetime import date, datetime +from typing_extensions import Required, Annotated, TypedDict + +import pytest + +from imagekitio._types import Base64FileInput, omit, not_given +from imagekitio._utils import ( + PropertyInfo, + transform as _transform, + parse_datetime, + async_transform as _async_transform, +) +from imagekitio._compat import PYDANTIC_V1 +from imagekitio._models import BaseModel + +_T = TypeVar("_T") + +SAMPLE_FILE_PATH = pathlib.Path(__file__).parent.joinpath("sample_file.txt") + + +async def transform( + data: _T, + expected_type: object, + use_async: bool, +) -> _T: + if use_async: + return await _async_transform(data, expected_type=expected_type) + + return _transform(data, expected_type=expected_type) + + +parametrize = pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + + +class Foo1(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +@parametrize +@pytest.mark.asyncio +async def test_top_level_alias(use_async: bool) -> None: + assert await transform({"foo_bar": "hello"}, expected_type=Foo1, use_async=use_async) == {"fooBar": "hello"} + + +class Foo2(TypedDict): + bar: Bar2 + + +class Bar2(TypedDict): + this_thing: Annotated[int, PropertyInfo(alias="this__thing")] + baz: Annotated[Baz2, PropertyInfo(alias="Baz")] + + +class Baz2(TypedDict): + my_baz: Annotated[str, PropertyInfo(alias="myBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_recursive_typeddict(use_async: bool) -> None: + assert await transform({"bar": {"this_thing": 1}}, Foo2, use_async) == {"bar": {"this__thing": 1}} + assert await transform({"bar": {"baz": {"my_baz": "foo"}}}, Foo2, use_async) == {"bar": {"Baz": {"myBaz": "foo"}}} + + +class Foo3(TypedDict): + things: List[Bar3] + + +class Bar3(TypedDict): + my_field: Annotated[str, PropertyInfo(alias="myField")] + + +@parametrize +@pytest.mark.asyncio +async def test_list_of_typeddict(use_async: bool) -> None: + result = await transform({"things": [{"my_field": "foo"}, {"my_field": "foo2"}]}, Foo3, use_async) + assert result == {"things": [{"myField": "foo"}, {"myField": "foo2"}]} + + +class Foo4(TypedDict): + foo: Union[Bar4, Baz4] + + +class Bar4(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz4(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_union_of_typeddict(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo4, use_async) == {"foo": {"fooBar": "bar"}} + assert await transform({"foo": {"foo_baz": "baz"}}, Foo4, use_async) == {"foo": {"fooBaz": "baz"}} + assert await transform({"foo": {"foo_baz": "baz", "foo_bar": "bar"}}, Foo4, use_async) == { + "foo": {"fooBaz": "baz", "fooBar": "bar"} + } + + +class Foo5(TypedDict): + foo: Annotated[Union[Bar4, List[Baz4]], PropertyInfo(alias="FOO")] + + +class Bar5(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz5(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_union_of_list(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo5, use_async) == {"FOO": {"fooBar": "bar"}} + assert await transform( + { + "foo": [ + {"foo_baz": "baz"}, + {"foo_baz": "baz"}, + ] + }, + Foo5, + use_async, + ) == {"FOO": [{"fooBaz": "baz"}, {"fooBaz": "baz"}]} + + +class Foo6(TypedDict): + bar: Annotated[str, PropertyInfo(alias="Bar")] + + +@parametrize +@pytest.mark.asyncio +async def test_includes_unknown_keys(use_async: bool) -> None: + assert await transform({"bar": "bar", "baz_": {"FOO": 1}}, Foo6, use_async) == { + "Bar": "bar", + "baz_": {"FOO": 1}, + } + + +class Foo7(TypedDict): + bar: Annotated[List[Bar7], PropertyInfo(alias="bAr")] + foo: Bar7 + + +class Bar7(TypedDict): + foo: str + + +@parametrize +@pytest.mark.asyncio +async def test_ignores_invalid_input(use_async: bool) -> None: + assert await transform({"bar": ""}, Foo7, use_async) == {"bAr": ""} + assert await transform({"foo": ""}, Foo7, use_async) == {"foo": ""} + + +class DatetimeDict(TypedDict, total=False): + foo: Annotated[datetime, PropertyInfo(format="iso8601")] + + bar: Annotated[Optional[datetime], PropertyInfo(format="iso8601")] + + required: Required[Annotated[Optional[datetime], PropertyInfo(format="iso8601")]] + + list_: Required[Annotated[Optional[List[datetime]], PropertyInfo(format="iso8601")]] + + union: Annotated[Union[int, datetime], PropertyInfo(format="iso8601")] + + +class DateDict(TypedDict, total=False): + foo: Annotated[date, PropertyInfo(format="iso8601")] + + +class DatetimeModel(BaseModel): + foo: datetime + + +class DateModel(BaseModel): + foo: Optional[date] + + +@parametrize +@pytest.mark.asyncio +async def test_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + tz = "+00:00" if PYDANTIC_V1 else "Z" + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] + + dt = dt.replace(tzinfo=None) + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + + assert await transform({"foo": None}, DateDict, use_async) == {"foo": None} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=None), Any, use_async) == {"foo": None} # type: ignore + assert await transform({"foo": date.fromisoformat("2023-02-23")}, DateDict, use_async) == {"foo": "2023-02-23"} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=date.fromisoformat("2023-02-23")), DateDict, use_async) == { + "foo": "2023-02-23" + } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_optional_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"bar": dt}, DatetimeDict, use_async) == {"bar": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + + assert await transform({"bar": None}, DatetimeDict, use_async) == {"bar": None} + + +@parametrize +@pytest.mark.asyncio +async def test_required_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"required": dt}, DatetimeDict, use_async) == { + "required": "2023-02-23T14:16:36.337692+00:00" + } # type: ignore[comparison-overlap] + + assert await transform({"required": None}, DatetimeDict, use_async) == {"required": None} + + +@parametrize +@pytest.mark.asyncio +async def test_union_datetime(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"union": dt}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] + "union": "2023-02-23T14:16:36.337692+00:00" + } + + assert await transform({"union": "foo"}, DatetimeDict, use_async) == {"union": "foo"} + + +@parametrize +@pytest.mark.asyncio +async def test_nested_list_iso6801_format(use_async: bool) -> None: + dt1 = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + dt2 = parse_datetime("2022-01-15T06:34:23Z") + assert await transform({"list_": [dt1, dt2]}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] + "list_": ["2023-02-23T14:16:36.337692+00:00", "2022-01-15T06:34:23+00:00"] + } + + +@parametrize +@pytest.mark.asyncio +async def test_datetime_custom_format(use_async: bool) -> None: + dt = parse_datetime("2022-01-15T06:34:23Z") + + result = await transform(dt, Annotated[datetime, PropertyInfo(format="custom", format_template="%H")], use_async) + assert result == "06" # type: ignore[comparison-overlap] + + +class DateDictWithRequiredAlias(TypedDict, total=False): + required_prop: Required[Annotated[date, PropertyInfo(format="iso8601", alias="prop")]] + + +@parametrize +@pytest.mark.asyncio +async def test_datetime_with_alias(use_async: bool) -> None: + assert await transform({"required_prop": None}, DateDictWithRequiredAlias, use_async) == {"prop": None} # type: ignore[comparison-overlap] + assert await transform( + {"required_prop": date.fromisoformat("2023-02-23")}, DateDictWithRequiredAlias, use_async + ) == {"prop": "2023-02-23"} # type: ignore[comparison-overlap] + + +class MyModel(BaseModel): + foo: str + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_model_to_dictionary(use_async: bool) -> None: + assert cast(Any, await transform(MyModel(foo="hi!"), Any, use_async)) == {"foo": "hi!"} + assert cast(Any, await transform(MyModel.construct(foo="hi!"), Any, use_async)) == {"foo": "hi!"} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_empty_model(use_async: bool) -> None: + assert cast(Any, await transform(MyModel.construct(), Any, use_async)) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_unknown_field(use_async: bool) -> None: + assert cast(Any, await transform(MyModel.construct(my_untyped_field=True), Any, use_async)) == { + "my_untyped_field": True + } + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_types(use_async: bool) -> None: + model = MyModel.construct(foo=True) + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: + with pytest.warns(UserWarning): + params = await transform(model, Any, use_async) + assert cast(Any, params) == {"foo": True} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_object_type(use_async: bool) -> None: + model = MyModel.construct(foo=MyModel.construct(hello="world")) + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: + with pytest.warns(UserWarning): + params = await transform(model, Any, use_async) + assert cast(Any, params) == {"foo": {"hello": "world"}} + + +class ModelNestedObjects(BaseModel): + nested: MyModel + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_nested_objects(use_async: bool) -> None: + model = ModelNestedObjects.construct(nested={"foo": "stainless"}) + assert isinstance(model.nested, MyModel) + assert cast(Any, await transform(model, Any, use_async)) == {"nested": {"foo": "stainless"}} + + +class ModelWithDefaultField(BaseModel): + foo: str + with_none_default: Union[str, None] = None + with_str_default: str = "foo" + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_default_field(use_async: bool) -> None: + # should be excluded when defaults are used + model = ModelWithDefaultField.construct() + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert cast(Any, await transform(model, Any, use_async)) == {} + + # should be included when the default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default=None, with_str_default="foo") + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": None, "with_str_default": "foo"} + + # should be included when a non-default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default="bar", with_str_default="baz") + assert model.with_none_default == "bar" + assert model.with_str_default == "baz" + assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": "bar", "with_str_default": "baz"} + + +class TypedDictIterableUnion(TypedDict): + foo: Annotated[Union[Bar8, Iterable[Baz8]], PropertyInfo(alias="FOO")] + + +class Bar8(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz8(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_iterable_of_dictionaries(use_async: bool) -> None: + assert await transform({"foo": [{"foo_baz": "bar"}]}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "bar"}] + } + assert cast(Any, await transform({"foo": ({"foo_baz": "bar"},)}, TypedDictIterableUnion, use_async)) == { + "FOO": [{"fooBaz": "bar"}] + } + + def my_iter() -> Iterable[Baz8]: + yield {"foo_baz": "hello"} + yield {"foo_baz": "world"} + + assert await transform({"foo": my_iter()}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "hello"}, {"fooBaz": "world"}] + } + + +@parametrize +@pytest.mark.asyncio +async def test_dictionary_items(use_async: bool) -> None: + class DictItems(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + assert await transform({"foo": {"foo_baz": "bar"}}, Dict[str, DictItems], use_async) == {"foo": {"fooBaz": "bar"}} + + +class TypedDictIterableUnionStr(TypedDict): + foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")] + + +@parametrize +@pytest.mark.asyncio +async def test_iterable_union_str(use_async: bool) -> None: + assert await transform({"foo": "bar"}, TypedDictIterableUnionStr, use_async) == {"FOO": "bar"} + assert cast(Any, await transform(iter([{"foo_baz": "bar"}]), Union[str, Iterable[Baz8]], use_async)) == [ + {"fooBaz": "bar"} + ] + + +class TypedDictBase64Input(TypedDict): + foo: Annotated[Union[str, Base64FileInput], PropertyInfo(format="base64")] + + +@parametrize +@pytest.mark.asyncio +async def test_base64_file_input(use_async: bool) -> None: + # strings are left as-is + assert await transform({"foo": "bar"}, TypedDictBase64Input, use_async) == {"foo": "bar"} + + # pathlib.Path is automatically converted to base64 + assert await transform({"foo": SAMPLE_FILE_PATH}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQo=" + } # type: ignore[comparison-overlap] + + # io instances are automatically converted to base64 + assert await transform({"foo": io.StringIO("Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + assert await transform({"foo": io.BytesIO(b"Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_transform_skipping(use_async: bool) -> None: + # lists of ints are left as-is + data = [1, 2, 3] + assert await transform(data, List[int], use_async) is data + + # iterables of ints are converted to a list + data = iter([1, 2, 3]) + assert await transform(data, Iterable[int], use_async) == [1, 2, 3] + + +@parametrize +@pytest.mark.asyncio +async def test_strips_notgiven(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": not_given}, Foo1, use_async) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_strips_omit(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": omit}, Foo1, use_async) == {} diff --git a/tests/test_utils/test_datetime_parse.py b/tests/test_utils/test_datetime_parse.py new file mode 100644 index 00000000..2e5023ad --- /dev/null +++ b/tests/test_utils/test_datetime_parse.py @@ -0,0 +1,110 @@ +""" +Copied from https://github.com/pydantic/pydantic/blob/v1.10.22/tests/test_datetime_parse.py +with modifications so it works without pydantic v1 imports. +""" + +from typing import Type, Union +from datetime import date, datetime, timezone, timedelta + +import pytest + +from imagekitio._utils import parse_date, parse_datetime + + +def create_tz(minutes: int) -> timezone: + return timezone(timedelta(minutes=minutes)) + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + ("1494012444.883309", date(2017, 5, 5)), + (b"1494012444.883309", date(2017, 5, 5)), + (1_494_012_444.883_309, date(2017, 5, 5)), + ("1494012444", date(2017, 5, 5)), + (1_494_012_444, date(2017, 5, 5)), + (0, date(1970, 1, 1)), + ("2012-04-23", date(2012, 4, 23)), + (b"2012-04-23", date(2012, 4, 23)), + ("2012-4-9", date(2012, 4, 9)), + (date(2012, 4, 9), date(2012, 4, 9)), + (datetime(2012, 4, 9, 12, 15), date(2012, 4, 9)), + # Invalid inputs + ("x20120423", ValueError), + ("2012-04-56", ValueError), + (19_999_999_999, date(2603, 10, 11)), # just before watershed + (20_000_000_001, date(1970, 8, 20)), # just after watershed + (1_549_316_052, date(2019, 2, 4)), # nowish in s + (1_549_316_052_104, date(2019, 2, 4)), # nowish in ms + (1_549_316_052_104_324, date(2019, 2, 4)), # nowish in μs + (1_549_316_052_104_324_096, date(2019, 2, 4)), # nowish in ns + ("infinity", date(9999, 12, 31)), + ("inf", date(9999, 12, 31)), + (float("inf"), date(9999, 12, 31)), + ("infinity ", date(9999, 12, 31)), + (int("1" + "0" * 100), date(9999, 12, 31)), + (1e1000, date(9999, 12, 31)), + ("-infinity", date(1, 1, 1)), + ("-inf", date(1, 1, 1)), + ("nan", ValueError), + ], +) +def test_date_parsing(value: Union[str, bytes, int, float], result: Union[date, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_date(value) + else: + assert parse_date(value) == result + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + # values in seconds + ("1494012444.883309", datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + (1_494_012_444.883_309, datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + ("1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (b"1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (1_494_012_444, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + # values in ms + ("1494012444000.883309", datetime(2017, 5, 5, 19, 27, 24, 883, tzinfo=timezone.utc)), + ("-1494012444000.883309", datetime(1922, 8, 29, 4, 32, 35, 999117, tzinfo=timezone.utc)), + (1_494_012_444_000, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + ("2012-04-23T09:15:00", datetime(2012, 4, 23, 9, 15)), + ("2012-4-9 4:8:16", datetime(2012, 4, 9, 4, 8, 16)), + ("2012-04-23T09:15:00Z", datetime(2012, 4, 23, 9, 15, 0, 0, timezone.utc)), + ("2012-4-9 4:8:16-0320", datetime(2012, 4, 9, 4, 8, 16, 0, create_tz(-200))), + ("2012-04-23T10:20:30.400+02:30", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(150))), + ("2012-04-23T10:20:30.400+02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(120))), + ("2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (b"2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (datetime(2017, 5, 5), datetime(2017, 5, 5)), + (0, datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)), + # Invalid inputs + ("x20120423091500", ValueError), + ("2012-04-56T09:15:90", ValueError), + ("2012-04-23T11:05:00-25:00", ValueError), + (19_999_999_999, datetime(2603, 10, 11, 11, 33, 19, tzinfo=timezone.utc)), # just before watershed + (20_000_000_001, datetime(1970, 8, 20, 11, 33, 20, 1000, tzinfo=timezone.utc)), # just after watershed + (1_549_316_052, datetime(2019, 2, 4, 21, 34, 12, 0, tzinfo=timezone.utc)), # nowish in s + (1_549_316_052_104, datetime(2019, 2, 4, 21, 34, 12, 104_000, tzinfo=timezone.utc)), # nowish in ms + (1_549_316_052_104_324, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in μs + (1_549_316_052_104_324_096, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in ns + ("infinity", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf ", datetime(9999, 12, 31, 23, 59, 59, 999999)), + (1e50, datetime(9999, 12, 31, 23, 59, 59, 999999)), + (float("inf"), datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("-infinity", datetime(1, 1, 1, 0, 0)), + ("-inf", datetime(1, 1, 1, 0, 0)), + ("nan", ValueError), + ], +) +def test_datetime_parsing(value: Union[str, bytes, int, float], result: Union[datetime, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_datetime(value) + else: + assert parse_datetime(value) == result diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py new file mode 100644 index 00000000..2c44f188 --- /dev/null +++ b/tests/test_utils/test_proxy.py @@ -0,0 +1,34 @@ +import operator +from typing import Any +from typing_extensions import override + +from imagekitio._utils import LazyProxy + + +class RecursiveLazyProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + return self + + def __call__(self, *_args: Any, **_kwds: Any) -> Any: + raise RuntimeError("This should never be called!") + + +def test_recursive_proxy() -> None: + proxy = RecursiveLazyProxy() + assert repr(proxy) == "RecursiveLazyProxy" + assert str(proxy) == "RecursiveLazyProxy" + assert dir(proxy) == [] + assert type(proxy).__name__ == "RecursiveLazyProxy" + assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" + + +def test_isinstance_does_not_error() -> None: + class AlwaysErrorProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + raise RuntimeError("Mocking missing dependency") + + proxy = AlwaysErrorProxy() + assert not isinstance(proxy, dict) + assert isinstance(proxy, LazyProxy) diff --git a/tests/test_utils/test_typing.py b/tests/test_utils/test_typing.py new file mode 100644 index 00000000..b44f2904 --- /dev/null +++ b/tests/test_utils/test_typing.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Generic, TypeVar, cast + +from imagekitio._utils import extract_type_var_from_base + +_T = TypeVar("_T") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") + + +class BaseGeneric(Generic[_T]): ... + + +class SubclassGeneric(BaseGeneric[_T]): ... + + +class BaseGenericMultipleTypeArgs(Generic[_T, _T2, _T3]): ... + + +class SubclassGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T, _T2, _T3]): ... + + +class SubclassDifferentOrderGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T2, _T, _T3]): ... + + +def test_extract_type_var() -> None: + assert ( + extract_type_var_from_base( + BaseGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_generic_subclass() -> None: + assert ( + extract_type_var_from_base( + SubclassGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_multiple() -> None: + typ = BaseGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_multiple() -> None: + typ = SubclassGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_different_ordering_multiple() -> None: + typ = SubclassDifferentOrderGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) diff --git a/tests/test_utils_calculation.py b/tests/test_utils_calculation.py deleted file mode 100644 index 9c77311f..00000000 --- a/tests/test_utils_calculation.py +++ /dev/null @@ -1,18 +0,0 @@ -import unittest -from imagekitio.client import ImageKit -from imagekitio.utils.calculation import get_authenticated_params - - -class TestUtilCalculation(unittest.TestCase): - def test_get_authenticated_params(self): - """Test authenticated_params returning proper value - :return: param dict - """ - result = get_authenticated_params( - token="your_token", expire="1582269249", private_key="private_key_test" - ) - self.assertEqual(result["token"], "your_token") - self.assertEqual(result["expire"], "1582269249") - self.assertEqual( - result["signature"], "e71bcd6031016b060d349d212e23e85c791decdd" - ) diff --git a/tests/test_utils_formatter.py b/tests/test_utils_formatter.py deleted file mode 100644 index 355a11d6..00000000 --- a/tests/test_utils_formatter.py +++ /dev/null @@ -1,21 +0,0 @@ -import unittest - -from imagekitio.utils.formatter import camel_to_snake, request_formatter - - -class TestFormatterClass(unittest.TestCase): - """ - TestFormatterClass tests if functions on formatter is working properly - """ - - def test_camel_to_snake(self) -> None: - """ - Test if CamelCase to snake_case is being converted - properly by camel_to_snake utility function - """ - self.assertEqual("abc", camel_to_snake("abc")) - self.assertEqual("_abc", camel_to_snake("_abc")) - self.assertEqual("", camel_to_snake("")) - self.assertEqual("my_name", camel_to_snake("myName")) - self.assertEqual("my_name_", camel_to_snake("myName_")) - self.assertEqual("url_endpoint", camel_to_snake("urlEndpoint")) diff --git a/tests/test_utils_utils.py b/tests/test_utils_utils.py deleted file mode 100644 index b2c65633..00000000 --- a/tests/test_utils_utils.py +++ /dev/null @@ -1,48 +0,0 @@ -import unittest - -from imagekitio.utils.utils import convert_to_response_object -from imagekitio.models.results.UploadFileResult import UploadFileResult - -from requests.models import Response - -import responses - -class TestUtilsUtils(unittest.TestCase): - - - @responses.activate() - def test_convert_to_response_object(self): - response = Response() - response.status_code = 200 - response._content = b'{"fileId":"abc123","name":"file.jpg","size":812557,"versionInfo":{"id":"abc123","name":"Version 1"},"filePath":"/file.jpg","url":"http://test.com","fileType":"image","height":398,"width":1000,"thumbnailUrl":"https://test.com","AITags":null,"embeddedMetadata":{"XResolution":1,"YResolution":1,"DateCreated":"2023-06-22T09:06:21.151Z","DateTimeCreated":"2023-06-22T09:06:21.151Z"}}' - u = convert_to_response_object(response,UploadFileResult) - expectedUploadFileResponse = {'file_id': 'abc123', 'name': 'file.jpg', 'size': 812557, 'version_info': {'id': 'abc123', 'name': 'Version 1'}, 'file_path': '/file.jpg', 'url': 'http://test.com', 'file_type': 'image', 'height': 398, 'width': 1000, 'thumbnail_url': 'https://test.com', 'ai_tags': None, 'embedded_metadata': {'x_resolution': 1, 'y_resolution': 1, 'date_created': '2023-06-22T09:06:21.151Z', 'date_time_created': '2023-06-22T09:06:21.151Z'}} - expectedUploadFileResult = UploadFileResult(**expectedUploadFileResponse) - - self.assertEqual(u.file_id,expectedUploadFileResult.file_id) - self.assertEqual(u.name,expectedUploadFileResult.name) - self.assertEqual(u.url,expectedUploadFileResult.url) - self.assertEqual(u.thumbnail_url,expectedUploadFileResult.thumbnail_url) - self.assertEqual(u.height,expectedUploadFileResult.height) - self.assertEqual(u.width,expectedUploadFileResult.width) - self.assertEqual(u.size,expectedUploadFileResult.size) - self.assertEqual(u.file_path,expectedUploadFileResult.file_path) - self.assertEqual(u.tags,expectedUploadFileResult.tags) - - for tag1,tag2 in zip(u.ai_tags,expectedUploadFileResult.ai_tags): - self.assertEqual(tag1.confidence,tag2.confidence) - self.assertEqual(tag1.name,tag2.name) - self.assertEqual(tag1.source,tag2.source) - - self.assertEqual(u.version_info.id,expectedUploadFileResult.version_info.id) - self.assertEqual(u.version_info.name,expectedUploadFileResult.version_info.name) - self.assertEqual(u.is_private_file,expectedUploadFileResult.is_private_file) - self.assertEqual(u.custom_coordinates,expectedUploadFileResult.custom_coordinates) - self.assertEqual(u.custom_metadata,expectedUploadFileResult.custom_metadata) - self.assertEqual(u.embedded_metadata.x_resolution,expectedUploadFileResult.embedded_metadata.x_resolution) - self.assertEqual(u.embedded_metadata.y_resolution,expectedUploadFileResult.embedded_metadata.y_resolution) - self.assertEqual(u.embedded_metadata.date_time_created,expectedUploadFileResult.embedded_metadata.date_time_created) - self.assertEqual(u.embedded_metadata.date_created,expectedUploadFileResult.embedded_metadata.date_created) - self.assertEqual(u.extension_status,expectedUploadFileResult.extension_status) - self.assertEqual(u.file_type,expectedUploadFileResult.file_type) - self.assertEqual(u.orientation,expectedUploadFileResult.orientation) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..d73dadfc --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import os +import inspect +import traceback +import contextlib +from typing import Any, TypeVar, Iterator, Sequence, cast +from datetime import date, datetime +from typing_extensions import Literal, get_args, get_origin, assert_type + +from imagekitio._types import Omit, NoneType +from imagekitio._utils import ( + is_dict, + is_list, + is_list_type, + is_union_type, + extract_type_arg, + is_sequence_type, + is_annotated_type, + is_type_alias_type, +) +from imagekitio._compat import PYDANTIC_V1, field_outer_type, get_model_fields +from imagekitio._models import BaseModel + +BaseModelT = TypeVar("BaseModelT", bound=BaseModel) + + +def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: + for name, field in get_model_fields(model).items(): + field_value = getattr(value, name) + if PYDANTIC_V1: + # in v1 nullability was structured differently + # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields + allow_none = getattr(field, "allow_none", False) + else: + allow_none = False + + assert_matches_type( + field_outer_type(field), + field_value, + path=[*path, name], + allow_none=allow_none, + ) + + return True + + +# Note: the `path` argument is only used to improve error messages when `--showlocals` is used +def assert_matches_type( + type_: Any, + value: object, + *, + path: list[str], + allow_none: bool = False, +) -> None: + if is_type_alias_type(type_): + type_ = type_.__value__ + + # unwrap `Annotated[T, ...]` -> `T` + if is_annotated_type(type_): + type_ = extract_type_arg(type_, 0) + + if allow_none and value is None: + return + + if type_ is None or type_ is NoneType: + assert value is None + return + + origin = get_origin(type_) or type_ + + if is_list_type(type_): + return _assert_list_type(type_, value) + + if is_sequence_type(type_): + assert isinstance(value, Sequence) + inner_type = get_args(type_)[0] + for entry in value: # type: ignore + assert_type(inner_type, entry) # type: ignore + return + + if origin == str: + assert isinstance(value, str) + elif origin == int: + assert isinstance(value, int) + elif origin == bool: + assert isinstance(value, bool) + elif origin == float: + assert isinstance(value, float) + elif origin == bytes: + assert isinstance(value, bytes) + elif origin == datetime: + assert isinstance(value, datetime) + elif origin == date: + assert isinstance(value, date) + elif origin == object: + # nothing to do here, the expected type is unknown + pass + elif origin == Literal: + assert value in get_args(type_) + elif origin == dict: + assert is_dict(value) + + args = get_args(type_) + key_type = args[0] + items_type = args[1] + + for key, item in value.items(): + assert_matches_type(key_type, key, path=[*path, ""]) + assert_matches_type(items_type, item, path=[*path, ""]) + elif is_union_type(type_): + variants = get_args(type_) + + try: + none_index = variants.index(type(None)) + except ValueError: + pass + else: + # special case Optional[T] for better error messages + if len(variants) == 2: + if value is None: + # valid + return + + return assert_matches_type(type_=variants[not none_index], value=value, path=path) + + for i, variant in enumerate(variants): + try: + assert_matches_type(variant, value, path=[*path, f"variant {i}"]) + return + except AssertionError: + traceback.print_exc() + continue + + raise AssertionError("Did not match any variants") + elif issubclass(origin, BaseModel): + assert isinstance(value, type_) + assert assert_matches_model(type_, cast(Any, value), path=path) + elif inspect.isclass(origin) and origin.__name__ == "HttpxBinaryResponseContent": + assert value.__class__.__name__ == "HttpxBinaryResponseContent" + else: + assert None, f"Unhandled field type: {type_}" + + +def _assert_list_type(type_: type[object], value: object) -> None: + assert is_list(value) + + inner_type = get_args(type_)[0] + for entry in value: + assert_type(inner_type, entry) # type: ignore + + +@contextlib.contextmanager +def update_env(**new_env: str | Omit) -> Iterator[None]: + old = os.environ.copy() + + try: + for name, value in new_env.items(): + if isinstance(value, Omit): + os.environ.pop(name, None) + else: + os.environ[name] = value + + yield None + finally: + os.environ.clear() + os.environ.update(old) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index ad884c34..00000000 --- a/tox.ini +++ /dev/null @@ -1,10 +0,0 @@ -[tox] -envlist = py36, py37, py38, py39, py310, py311, py312, py313 -skipsdist = True - -[testenv] -passenv = * -deps = -rrequirements/test.txt -commands = - coverage run --append -m unittest discover tests - coverage report From 81f0de954a0d531c6b98354386462f4186a58aba Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:54:04 +0000 Subject: [PATCH 2/8] chore: update SDK settings --- .github/workflows/publish-pypi.yml | 31 +++++++++ .github/workflows/release-doctor.yml | 21 ++++++ .release-please-manifest.json | 3 + .stats.yml | 2 +- CONTRIBUTING.md | 4 +- README.md | 14 ++-- bin/check-release-environment | 21 ++++++ pyproject.toml | 6 +- release-please-config.json | 66 +++++++++++++++++++ src/imagekitio/_files.py | 2 +- src/imagekitio/_version.py | 2 +- src/imagekitio/resources/accounts/accounts.py | 8 +-- src/imagekitio/resources/accounts/origins.py | 8 +-- .../resources/accounts/url_endpoints.py | 8 +-- src/imagekitio/resources/accounts/usage.py | 8 +-- src/imagekitio/resources/assets.py | 8 +-- src/imagekitio/resources/beta/beta.py | 8 +-- src/imagekitio/resources/beta/v2/files.py | 8 +-- src/imagekitio/resources/beta/v2/v2.py | 8 +-- src/imagekitio/resources/cache/cache.py | 8 +-- .../resources/cache/invalidation.py | 8 +-- .../resources/custom_metadata_fields.py | 8 +-- src/imagekitio/resources/dummy.py | 8 +-- src/imagekitio/resources/files/bulk.py | 8 +-- src/imagekitio/resources/files/files.py | 8 +-- src/imagekitio/resources/files/metadata.py | 8 +-- src/imagekitio/resources/files/versions.py | 8 +-- src/imagekitio/resources/folders/folders.py | 8 +-- src/imagekitio/resources/folders/job.py | 8 +-- 29 files changed, 229 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 .github/workflows/release-doctor.yml create mode 100644 .release-please-manifest.json create mode 100644 bin/check-release-environment create mode 100644 release-please-config.json diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 00000000..08adafc8 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,31 @@ +# This workflow is triggered when a GitHub release is created. +# It can also be run manually to re-publish to PyPI in case it failed for some reason. +# You can run this workflow by navigating to https://www.github.com/imagekit-developer/imagekit-python/actions/workflows/publish-pypi.yml +name: Publish PyPI +on: + workflow_dispatch: + + release: + types: [published] + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Publish to PyPI + run: | + bash ./bin/publish-pypi + env: + PYPI_TOKEN: ${{ secrets.IMAGE_KIT_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml new file mode 100644 index 00000000..c36a89df --- /dev/null +++ b/.github/workflows/release-doctor.yml @@ -0,0 +1,21 @@ +name: Release Doctor +on: + pull_request: + branches: + - master + workflow_dispatch: + +jobs: + release_doctor: + name: release doctor + runs-on: ubuntu-latest + if: github.repository == 'imagekit-developer/imagekit-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + + steps: + - uses: actions/checkout@v4 + + - name: Check release environment + run: | + bash ./bin/check-release-environment + env: + PYPI_TOKEN: ${{ secrets.IMAGE_KIT_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..1332969b --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.1" +} \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index a0c1a4ea..26cba713 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 43 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/imagekit-inc%2Fimagekit-9d184cb502ab32a85db2889c796cdfebe812f2a55a604df79c85dd4b5e7e2add.yml openapi_spec_hash: a9aa620376fce66532c84f9364209b0b -config_hash: fd112bd17c0c8e9f81a50d0e15ea70d6 +config_hash: 2a4d7992f6d3a0db0e9a430d513d94e6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edd1f2b1..a6c5c7c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ If you’d like to use the repository from source, you can either install from g To install via git: ```sh -$ pip install git+ssh://git@github.com/stainless-sdks/imagekit-python.git +$ pip install git+ssh://git@github.com/imagekit-developer/imagekit-python#master.git ``` Alternatively, you can build from source and install the wheel file: @@ -120,7 +120,7 @@ the changes aren't made through the automated pipeline, you may want to make rel ### Publish with a GitHub workflow -You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/stainless-sdks/imagekit-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. +You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/imagekit-developer/imagekit-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. ### Publish manually diff --git a/README.md b/README.md index c10409fa..182f5951 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ The REST API documentation can be found on [imagekit.io](https://imagekit.io/doc ## Installation ```sh -# install from this staging repo -pip install git+ssh://git@github.com/stainless-sdks/imagekit-python.git +# install from the production repo +pip install git+ssh://git@github.com/imagekit-developer/imagekit-python#master.git ``` > [!NOTE] @@ -159,8 +159,8 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from this staging repo -pip install 'imagekitio[aiohttp] @ git+ssh://git@github.com/stainless-sdks/imagekit-python.git' +# install from the production repo +pip install 'imagekitio[aiohttp] @ git+ssh://git@github.com/imagekit-developer/imagekit-python#master.git' ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: @@ -725,9 +725,9 @@ file = response.parse() # get the object that `files.upload()` would have retur print(file.file_id) ``` -These methods return an [`APIResponse`](https://github.com/stainless-sdks/imagekit-python/tree/main/src/imagekitio/_response.py) object. +These methods return an [`APIResponse`](https://github.com/imagekit-developer/imagekit-python/tree/master/src/imagekitio/_response.py) object. -The async client returns an [`AsyncAPIResponse`](https://github.com/stainless-sdks/imagekit-python/tree/main/src/imagekitio/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. +The async client returns an [`AsyncAPIResponse`](https://github.com/imagekit-developer/imagekit-python/tree/master/src/imagekitio/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. #### `.with_streaming_response` @@ -838,7 +838,7 @@ This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) con We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. -We are keen for your feedback; please open an [issue](https://www.github.com/stainless-sdks/imagekit-python/issues) with questions, bugs, or suggestions. +We are keen for your feedback; please open an [issue](https://www.github.com/imagekit-developer/imagekit-python/issues) with questions, bugs, or suggestions. ### Determining the installed version diff --git a/bin/check-release-environment b/bin/check-release-environment new file mode 100644 index 00000000..b845b0f4 --- /dev/null +++ b/bin/check-release-environment @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +errors=() + +if [ -z "${PYPI_TOKEN}" ]; then + errors+=("The PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") +fi + +lenErrors=${#errors[@]} + +if [[ lenErrors -gt 0 ]]; then + echo -e "Found the following errors in the release environment:\n" + + for error in "${errors[@]}"; do + echo -e "- $error\n" + done + + exit 1 +fi + +echo "The environment is ready to push releases!" diff --git a/pyproject.toml b/pyproject.toml index 43ba273f..48f905fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,8 +37,8 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/stainless-sdks/imagekit-python" -Repository = "https://github.com/stainless-sdks/imagekit-python" +Homepage = "https://github.com/imagekit-developer/imagekit-python" +Repository = "https://github.com/imagekit-developer/imagekit-python" [project.optional-dependencies] aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] @@ -127,7 +127,7 @@ path = "README.md" [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] # replace relative links with absolute links pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' -replacement = '[\1](https://github.com/stainless-sdks/imagekit-python/tree/main/\g<2>)' +replacement = '[\1](https://github.com/imagekit-developer/imagekit-python/tree/master/\g<2>)' [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..cd36a977 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,66 @@ +{ + "packages": { + ".": {} + }, + "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "include-component-in-tag": false, + "versioning": "prerelease", + "prerelease": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "pull-request-header": "Automated Release PR", + "pull-request-title-pattern": "release: ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Chores" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "style", + "section": "Styles" + }, + { + "type": "refactor", + "section": "Refactors" + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System" + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ], + "release-type": "python", + "extra-files": [ + "src/imagekitio/_version.py" + ] +} \ No newline at end of file diff --git a/src/imagekitio/_files.py b/src/imagekitio/_files.py index 3cf7941c..331bc44c 100644 --- a/src/imagekitio/_files.py +++ b/src/imagekitio/_files.py @@ -34,7 +34,7 @@ def assert_is_file_content(obj: object, *, key: str | None = None) -> None: if not is_file_content(obj): prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" raise RuntimeError( - f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead. See https://github.com/stainless-sdks/imagekit-python/tree/main#file-uploads" + f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead. See https://github.com/imagekit-developer/imagekit-python/tree/master#file-uploads" ) from None diff --git a/src/imagekitio/_version.py b/src/imagekitio/_version.py index e67f4eb2..2df83f1a 100644 --- a/src/imagekitio/_version.py +++ b/src/imagekitio/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "imagekitio" -__version__ = "0.0.1" +__version__ = "0.0.1" # x-release-please-version diff --git a/src/imagekitio/resources/accounts/accounts.py b/src/imagekitio/resources/accounts/accounts.py index dba376c5..461e8cff 100644 --- a/src/imagekitio/resources/accounts/accounts.py +++ b/src/imagekitio/resources/accounts/accounts.py @@ -51,7 +51,7 @@ def with_raw_response(self) -> AccountsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AccountsResourceWithRawResponse(self) @@ -60,7 +60,7 @@ def with_streaming_response(self) -> AccountsResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AccountsResourceWithStreamingResponse(self) @@ -84,7 +84,7 @@ def with_raw_response(self) -> AsyncAccountsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncAccountsResourceWithRawResponse(self) @@ -93,7 +93,7 @@ def with_streaming_response(self) -> AsyncAccountsResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncAccountsResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/accounts/origins.py b/src/imagekitio/resources/accounts/origins.py index 15354aad..27dc7af5 100644 --- a/src/imagekitio/resources/accounts/origins.py +++ b/src/imagekitio/resources/accounts/origins.py @@ -32,7 +32,7 @@ def with_raw_response(self) -> OriginsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return OriginsResourceWithRawResponse(self) @@ -41,7 +41,7 @@ def with_streaming_response(self) -> OriginsResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return OriginsResourceWithStreamingResponse(self) @@ -1094,7 +1094,7 @@ def with_raw_response(self) -> AsyncOriginsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncOriginsResourceWithRawResponse(self) @@ -1103,7 +1103,7 @@ def with_streaming_response(self) -> AsyncOriginsResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncOriginsResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/accounts/url_endpoints.py b/src/imagekitio/resources/accounts/url_endpoints.py index b5d9f368..ab8d4222 100644 --- a/src/imagekitio/resources/accounts/url_endpoints.py +++ b/src/imagekitio/resources/accounts/url_endpoints.py @@ -29,7 +29,7 @@ def with_raw_response(self) -> URLEndpointsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return URLEndpointsResourceWithRawResponse(self) @@ -38,7 +38,7 @@ def with_streaming_response(self) -> URLEndpointsResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return URLEndpointsResourceWithStreamingResponse(self) @@ -273,7 +273,7 @@ def with_raw_response(self) -> AsyncURLEndpointsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncURLEndpointsResourceWithRawResponse(self) @@ -282,7 +282,7 @@ def with_streaming_response(self) -> AsyncURLEndpointsResourceWithStreamingRespo """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncURLEndpointsResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/accounts/usage.py b/src/imagekitio/resources/accounts/usage.py index c044c9b8..b35d3c9b 100644 --- a/src/imagekitio/resources/accounts/usage.py +++ b/src/imagekitio/resources/accounts/usage.py @@ -31,7 +31,7 @@ def with_raw_response(self) -> UsageResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return UsageResourceWithRawResponse(self) @@ -40,7 +40,7 @@ def with_streaming_response(self) -> UsageResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return UsageResourceWithStreamingResponse(self) @@ -104,7 +104,7 @@ def with_raw_response(self) -> AsyncUsageResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncUsageResourceWithRawResponse(self) @@ -113,7 +113,7 @@ def with_streaming_response(self) -> AsyncUsageResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncUsageResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/assets.py b/src/imagekitio/resources/assets.py index 18828fb4..1d239da3 100644 --- a/src/imagekitio/resources/assets.py +++ b/src/imagekitio/resources/assets.py @@ -30,7 +30,7 @@ def with_raw_response(self) -> AssetsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AssetsResourceWithRawResponse(self) @@ -39,7 +39,7 @@ def with_streaming_response(self) -> AssetsResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AssetsResourceWithStreamingResponse(self) @@ -163,7 +163,7 @@ def with_raw_response(self) -> AsyncAssetsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncAssetsResourceWithRawResponse(self) @@ -172,7 +172,7 @@ def with_streaming_response(self) -> AsyncAssetsResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncAssetsResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/beta/beta.py b/src/imagekitio/resources/beta/beta.py index e37c117e..01e43aa0 100644 --- a/src/imagekitio/resources/beta/beta.py +++ b/src/imagekitio/resources/beta/beta.py @@ -27,7 +27,7 @@ def with_raw_response(self) -> BetaResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return BetaResourceWithRawResponse(self) @@ -36,7 +36,7 @@ def with_streaming_response(self) -> BetaResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return BetaResourceWithStreamingResponse(self) @@ -52,7 +52,7 @@ def with_raw_response(self) -> AsyncBetaResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncBetaResourceWithRawResponse(self) @@ -61,7 +61,7 @@ def with_streaming_response(self) -> AsyncBetaResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncBetaResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/beta/v2/files.py b/src/imagekitio/resources/beta/v2/files.py index 16c9b659..03b198fd 100644 --- a/src/imagekitio/resources/beta/v2/files.py +++ b/src/imagekitio/resources/beta/v2/files.py @@ -43,7 +43,7 @@ def with_raw_response(self) -> FilesResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return FilesResourceWithRawResponse(self) @@ -52,7 +52,7 @@ def with_streaming_response(self) -> FilesResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return FilesResourceWithStreamingResponse(self) @@ -297,7 +297,7 @@ def with_raw_response(self) -> AsyncFilesResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncFilesResourceWithRawResponse(self) @@ -306,7 +306,7 @@ def with_streaming_response(self) -> AsyncFilesResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncFilesResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/beta/v2/v2.py b/src/imagekitio/resources/beta/v2/v2.py index f552e26a..2fb98309 100644 --- a/src/imagekitio/resources/beta/v2/v2.py +++ b/src/imagekitio/resources/beta/v2/v2.py @@ -27,7 +27,7 @@ def with_raw_response(self) -> V2ResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return V2ResourceWithRawResponse(self) @@ -36,7 +36,7 @@ def with_streaming_response(self) -> V2ResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return V2ResourceWithStreamingResponse(self) @@ -52,7 +52,7 @@ def with_raw_response(self) -> AsyncV2ResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncV2ResourceWithRawResponse(self) @@ -61,7 +61,7 @@ def with_streaming_response(self) -> AsyncV2ResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncV2ResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/cache/cache.py b/src/imagekitio/resources/cache/cache.py index 47016b06..936b39f9 100644 --- a/src/imagekitio/resources/cache/cache.py +++ b/src/imagekitio/resources/cache/cache.py @@ -27,7 +27,7 @@ def with_raw_response(self) -> CacheResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return CacheResourceWithRawResponse(self) @@ -36,7 +36,7 @@ def with_streaming_response(self) -> CacheResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return CacheResourceWithStreamingResponse(self) @@ -52,7 +52,7 @@ def with_raw_response(self) -> AsyncCacheResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncCacheResourceWithRawResponse(self) @@ -61,7 +61,7 @@ def with_streaming_response(self) -> AsyncCacheResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncCacheResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/cache/invalidation.py b/src/imagekitio/resources/cache/invalidation.py index f1f6c72b..9c95dc82 100644 --- a/src/imagekitio/resources/cache/invalidation.py +++ b/src/imagekitio/resources/cache/invalidation.py @@ -29,7 +29,7 @@ def with_raw_response(self) -> InvalidationResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return InvalidationResourceWithRawResponse(self) @@ -38,7 +38,7 @@ def with_streaming_response(self) -> InvalidationResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return InvalidationResourceWithStreamingResponse(self) @@ -120,7 +120,7 @@ def with_raw_response(self) -> AsyncInvalidationResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncInvalidationResourceWithRawResponse(self) @@ -129,7 +129,7 @@ def with_streaming_response(self) -> AsyncInvalidationResourceWithStreamingRespo """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncInvalidationResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/custom_metadata_fields.py b/src/imagekitio/resources/custom_metadata_fields.py index 0c56e033..467e52ab 100644 --- a/src/imagekitio/resources/custom_metadata_fields.py +++ b/src/imagekitio/resources/custom_metadata_fields.py @@ -34,7 +34,7 @@ def with_raw_response(self) -> CustomMetadataFieldsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return CustomMetadataFieldsResourceWithRawResponse(self) @@ -43,7 +43,7 @@ def with_streaming_response(self) -> CustomMetadataFieldsResourceWithStreamingRe """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return CustomMetadataFieldsResourceWithStreamingResponse(self) @@ -252,7 +252,7 @@ def with_raw_response(self) -> AsyncCustomMetadataFieldsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncCustomMetadataFieldsResourceWithRawResponse(self) @@ -261,7 +261,7 @@ def with_streaming_response(self) -> AsyncCustomMetadataFieldsResourceWithStream """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncCustomMetadataFieldsResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/dummy.py b/src/imagekitio/resources/dummy.py index aa444522..072340e3 100644 --- a/src/imagekitio/resources/dummy.py +++ b/src/imagekitio/resources/dummy.py @@ -46,7 +46,7 @@ def with_raw_response(self) -> DummyResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return DummyResourceWithRawResponse(self) @@ -55,7 +55,7 @@ def with_streaming_response(self) -> DummyResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return DummyResourceWithStreamingResponse(self) @@ -181,7 +181,7 @@ def with_raw_response(self) -> AsyncDummyResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncDummyResourceWithRawResponse(self) @@ -190,7 +190,7 @@ def with_streaming_response(self) -> AsyncDummyResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncDummyResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/files/bulk.py b/src/imagekitio/resources/files/bulk.py index cd525b81..43c02cbf 100644 --- a/src/imagekitio/resources/files/bulk.py +++ b/src/imagekitio/resources/files/bulk.py @@ -31,7 +31,7 @@ def with_raw_response(self) -> BulkResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return BulkResourceWithRawResponse(self) @@ -40,7 +40,7 @@ def with_streaming_response(self) -> BulkResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return BulkResourceWithStreamingResponse(self) @@ -227,7 +227,7 @@ def with_raw_response(self) -> AsyncBulkResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncBulkResourceWithRawResponse(self) @@ -236,7 +236,7 @@ def with_streaming_response(self) -> AsyncBulkResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncBulkResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/files/files.py b/src/imagekitio/resources/files/files.py index af6c166f..cd9ff3e7 100644 --- a/src/imagekitio/resources/files/files.py +++ b/src/imagekitio/resources/files/files.py @@ -91,7 +91,7 @@ def with_raw_response(self) -> FilesResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return FilesResourceWithRawResponse(self) @@ -100,7 +100,7 @@ def with_streaming_response(self) -> FilesResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return FilesResourceWithStreamingResponse(self) @@ -764,7 +764,7 @@ def with_raw_response(self) -> AsyncFilesResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncFilesResourceWithRawResponse(self) @@ -773,7 +773,7 @@ def with_streaming_response(self) -> AsyncFilesResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncFilesResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/files/metadata.py b/src/imagekitio/resources/files/metadata.py index 1648e6ea..d9e05412 100644 --- a/src/imagekitio/resources/files/metadata.py +++ b/src/imagekitio/resources/files/metadata.py @@ -28,7 +28,7 @@ def with_raw_response(self) -> MetadataResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return MetadataResourceWithRawResponse(self) @@ -37,7 +37,7 @@ def with_streaming_response(self) -> MetadataResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return MetadataResourceWithStreamingResponse(self) @@ -125,7 +125,7 @@ def with_raw_response(self) -> AsyncMetadataResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncMetadataResourceWithRawResponse(self) @@ -134,7 +134,7 @@ def with_streaming_response(self) -> AsyncMetadataResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncMetadataResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/files/versions.py b/src/imagekitio/resources/files/versions.py index b2df5cb5..e6479e19 100644 --- a/src/imagekitio/resources/files/versions.py +++ b/src/imagekitio/resources/files/versions.py @@ -28,7 +28,7 @@ def with_raw_response(self) -> VersionsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return VersionsResourceWithRawResponse(self) @@ -37,7 +37,7 @@ def with_streaming_response(self) -> VersionsResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return VersionsResourceWithStreamingResponse(self) @@ -194,7 +194,7 @@ def with_raw_response(self) -> AsyncVersionsResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncVersionsResourceWithRawResponse(self) @@ -203,7 +203,7 @@ def with_streaming_response(self) -> AsyncVersionsResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncVersionsResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/folders/folders.py b/src/imagekitio/resources/folders/folders.py index a5fdf814..d986fd7f 100644 --- a/src/imagekitio/resources/folders/folders.py +++ b/src/imagekitio/resources/folders/folders.py @@ -50,7 +50,7 @@ def with_raw_response(self) -> FoldersResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return FoldersResourceWithRawResponse(self) @@ -59,7 +59,7 @@ def with_streaming_response(self) -> FoldersResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return FoldersResourceWithStreamingResponse(self) @@ -337,7 +337,7 @@ def with_raw_response(self) -> AsyncFoldersResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncFoldersResourceWithRawResponse(self) @@ -346,7 +346,7 @@ def with_streaming_response(self) -> AsyncFoldersResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncFoldersResourceWithStreamingResponse(self) diff --git a/src/imagekitio/resources/folders/job.py b/src/imagekitio/resources/folders/job.py index f731d083..5ccbd3bb 100644 --- a/src/imagekitio/resources/folders/job.py +++ b/src/imagekitio/resources/folders/job.py @@ -26,7 +26,7 @@ def with_raw_response(self) -> JobResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return JobResourceWithRawResponse(self) @@ -35,7 +35,7 @@ def with_streaming_response(self) -> JobResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return JobResourceWithStreamingResponse(self) @@ -80,7 +80,7 @@ def with_raw_response(self) -> AsyncJobResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/imagekit-developer/imagekit-python#accessing-raw-response-data-eg-headers """ return AsyncJobResourceWithRawResponse(self) @@ -89,7 +89,7 @@ def with_streaming_response(self) -> AsyncJobResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/imagekit-python#with_streaming_response + For more information, see https://www.github.com/imagekit-developer/imagekit-python#with_streaming_response """ return AsyncJobResourceWithStreamingResponse(self) From 8072dfd2eee562f98ac79fb5b11afe700e0dd6a3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:55:24 +0000 Subject: [PATCH 3/8] feat(api): python publish true --- .stats.yml | 2 +- README.md | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.stats.yml b/.stats.yml index 26cba713..f9a80044 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 43 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/imagekit-inc%2Fimagekit-9d184cb502ab32a85db2889c796cdfebe812f2a55a604df79c85dd4b5e7e2add.yml openapi_spec_hash: a9aa620376fce66532c84f9364209b0b -config_hash: 2a4d7992f6d3a0db0e9a430d513d94e6 +config_hash: b4f610d4f53fe5bb17b35cf77a7521ea diff --git a/README.md b/README.md index 182f5951..6ea03a48 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,10 @@ The REST API documentation can be found on [imagekit.io](https://imagekit.io/doc ## Installation ```sh -# install from the production repo -pip install git+ssh://git@github.com/imagekit-developer/imagekit-python#master.git +# install from PyPI +pip install imagekitio ``` -> [!NOTE] -> Once this package is [published to PyPI](https://www.stainless.com/docs/guides/publish), this will become: `pip install imagekitio` - ## Usage The full API of this library can be found in [api.md](api.md). @@ -159,8 +156,8 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from the production repo -pip install 'imagekitio[aiohttp] @ git+ssh://git@github.com/imagekit-developer/imagekit-python#master.git' +# install from PyPI +pip install imagekitio[aiohttp] ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: From 40c0345c77c7f72eaf9003f3dd91d3e6b63f3d37 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:00:52 +0000 Subject: [PATCH 4/8] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index f9a80044..333dfb4f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 43 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/imagekit-inc%2Fimagekit-9d184cb502ab32a85db2889c796cdfebe812f2a55a604df79c85dd4b5e7e2add.yml openapi_spec_hash: a9aa620376fce66532c84f9364209b0b -config_hash: b4f610d4f53fe5bb17b35cf77a7521ea +config_hash: 71cab8223bb5610c6c7ca6e9c4cc1f89 From 472790845ef7009aa3695fc084ef8c5d1d63f2ab Mon Sep 17 00:00:00 2001 From: Manu Chaudhary Date: Sat, 13 Dec 2025 08:18:41 +0000 Subject: [PATCH 5/8] chore: remove unused dummy methods from API documentation --- api.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/api.md b/api.md index c88d631a..b617936d 100644 --- a/api.md +++ b/api.md @@ -24,12 +24,6 @@ from imagekitio.types import ( ) ``` -# Dummy - -Methods: - -- client.dummy.create(\*\*params) -> None - # CustomMetadataFields Types: From 09ae37575b6b1eba57f67c6b1dea3d59e10d270d Mon Sep 17 00:00:00 2001 From: Manu Chaudhary Date: Sat, 13 Dec 2025 08:35:44 +0000 Subject: [PATCH 6/8] feat(webhooks): allow key parameter to accept bytes in unwrap method --- src/imagekitio/resources/webhooks.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/imagekitio/resources/webhooks.py b/src/imagekitio/resources/webhooks.py index d308cca7..0ca75b5c 100644 --- a/src/imagekitio/resources/webhooks.py +++ b/src/imagekitio/resources/webhooks.py @@ -25,7 +25,7 @@ def unsafe_unwrap(self, payload: str) -> UnsafeUnwrapWebhookEvent: ), ) - def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | None = None) -> UnwrapWebhookEvent: + def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | bytes | None = None) -> UnwrapWebhookEvent: try: from standardwebhooks import Webhook except ImportError as exc: @@ -41,7 +41,11 @@ def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | None = if not isinstance(headers, dict): headers = dict(headers) - encoded_key = base64.b64encode(key.encode("utf-8")).decode("ascii") + if isinstance(key, str): + key_bytes = key.encode("utf-8") + else: + key_bytes = key + encoded_key = base64.b64encode(key_bytes).decode("ascii") Webhook(encoded_key).verify(payload, headers) @@ -64,7 +68,7 @@ def unsafe_unwrap(self, payload: str) -> UnsafeUnwrapWebhookEvent: ), ) - def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | None = None) -> UnwrapWebhookEvent: + def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | bytes | None = None) -> UnwrapWebhookEvent: try: from standardwebhooks import Webhook except ImportError as exc: @@ -80,7 +84,11 @@ def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | None = if not isinstance(headers, dict): headers = dict(headers) - encoded_key = base64.b64encode(key.encode("utf-8")).decode("ascii") + if isinstance(key, str): + key_bytes = key.encode("utf-8") + else: + key_bytes = key + encoded_key = base64.b64encode(key_bytes).decode("ascii") Webhook(encoded_key).verify(payload, headers) From 6e3f2092cad4b2c3ed7d1f3086c7bfb2a9a51b08 Mon Sep 17 00:00:00 2001 From: Manu Chaudhary Date: Sat, 13 Dec 2025 08:58:58 +0000 Subject: [PATCH 7/8] fix(serialization): adjust custom_metadata type check for serialization --- src/imagekitio/lib/serialization_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/imagekitio/lib/serialization_utils.py b/src/imagekitio/lib/serialization_utils.py index 1d672f16..4fe5a473 100644 --- a/src/imagekitio/lib/serialization_utils.py +++ b/src/imagekitio/lib/serialization_utils.py @@ -37,7 +37,7 @@ def serialize_upload_options(upload_options: Dict[str, Any]) -> Dict[str, Any]: elif key == "extensions" and isinstance(value, list): # Extensions should be JSON stringified serialized[key] = json.dumps(value) - elif key == "custom_metadata" and isinstance(value, dict) and not isinstance(value, (list, tuple)): + elif key == "custom_metadata" and isinstance(value, dict): # Custom metadata should be JSON stringified serialized[key] = json.dumps(value) elif key == "transformation" and isinstance(value, dict): From 7c2db764b262add764f37418d897bac744000740 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 13 Dec 2025 09:00:33 +0000 Subject: [PATCH 8/8] release: 5.0.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 57 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/imagekitio/_version.py | 2 +- 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1332969b..8e76abb5 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.0.1" + ".": "5.0.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..e015abb4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## 5.0.0 (2025-12-13) + +Full Changelog: [v0.0.1...v5.0.0](https://github.com/imagekit-developer/imagekit-python/compare/v0.0.1...v5.0.0) + +### Features + +* add bulk delete options ([c1c4d32](https://github.com/imagekit-developer/imagekit-python/commit/c1c4d3206b06594ba77a8a1c4dab7d0c5b74de9a)) +* add file related functionalities ([681677b](https://github.com/imagekit-developer/imagekit-python/commit/681677bc60a207f433b4bc242c41e37f2d4c05a1)) +* add sdk version to url ([9c3e67d](https://github.com/imagekit-developer/imagekit-python/commit/9c3e67d20f78b799e974889420ead23f457b5cfa)) +* add url class for url genration ([5e615ed](https://github.com/imagekit-developer/imagekit-python/commit/5e615ed34386e3231c5c7963ff37ceb28ab7d2f1)) +* **api:** python publish true ([8072dfd](https://github.com/imagekit-developer/imagekit-python/commit/8072dfd2eee562f98ac79fb5b11afe700e0dd6a3)) +* implement client with all func. ([67dd4b2](https://github.com/imagekit-developer/imagekit-python/commit/67dd4b28822086009278e4ab3f85d52690e6e9b7)) +* implement get_remote_url_metadata ([1272740](https://github.com/imagekit-developer/imagekit-python/commit/12727400dc5bc6678f6769c5143c11962f58eea4)) +* **webhooks:** allow key parameter to accept bytes in unwrap method ([09ae375](https://github.com/imagekit-developer/imagekit-python/commit/09ae37575b6b1eba57f67c6b1dea3d59e10d270d)) + + +### Bug Fixes + +* binary file upload ([23c9c46](https://github.com/imagekit-developer/imagekit-python/commit/23c9c46f37a5b32144f86700227254e6f05bf491)) +* change ubuntu latest to ubuntu-20.04 in test.yml ([1e4b551](https://github.com/imagekit-developer/imagekit-python/commit/1e4b55192d08ebf1aa436fa56832322477605942)) +* Changes for CI/CD ([0bd2ac3](https://github.com/imagekit-developer/imagekit-python/commit/0bd2ac3e9b11e8269a2eacb2424d49ef58e37c5f)) +* fix issue [#35](https://github.com/imagekit-developer/imagekit-python/issues/35),[#37](https://github.com/imagekit-developer/imagekit-python/issues/37),[#41](https://github.com/imagekit-developer/imagekit-python/issues/41),[#44](https://github.com/imagekit-developer/imagekit-python/issues/44) ([1f913c8](https://github.com/imagekit-developer/imagekit-python/commit/1f913c8e34a06afbffa93adbbc79e8a174a02dac)) +* fix query params implementation ([2b7e6d4](https://github.com/imagekit-developer/imagekit-python/commit/2b7e6d4a148b6d94b52532846bd950d4eeeefac4)) +* make ik-attachment option handle True boolean value ([6eb9cd0](https://github.com/imagekit-developer/imagekit-python/commit/6eb9cd099021a1fd9bcc9dfeb080ec610d4bcfbd)) +* move the workflow to correct folder ([d9f933a](https://github.com/imagekit-developer/imagekit-python/commit/d9f933a8e78c61b8a61df1d74a28859f9e889378)) +* request toolbelt to 0.10.1 in requirements/test/txt ([c22ed89](https://github.com/imagekit-developer/imagekit-python/commit/c22ed89208f69f7d8fb21cc777049d72dad40093)) +* **serialization:** adjust custom_metadata type check for serialization ([6e3f209](https://github.com/imagekit-developer/imagekit-python/commit/6e3f2092cad4b2c3ed7d1f3086c7bfb2a9a51b08)) + + +### Chores + +* add func alias ([d7ce593](https://github.com/imagekit-developer/imagekit-python/commit/d7ce593318b24f33ba828b65042e16e892690b80)) +* add init file ([0cbbd27](https://github.com/imagekit-developer/imagekit-python/commit/0cbbd27f00ac3fe36d3fbc0bf6fa2b015308576c)) +* add publish github workflow script ([a275172](https://github.com/imagekit-developer/imagekit-python/commit/a275172c3e7096b7390665102bae4d95c718db9d)) +* add required constants ([48de1c0](https://github.com/imagekit-developer/imagekit-python/commit/48de1c02295fb42d522f8ee930c16ee763d7b93d)) +* add requirements files ([e8d3d9d](https://github.com/imagekit-developer/imagekit-python/commit/e8d3d9d60e946b036b3f8e37a9dbf1e68be5482d)) +* add sample file for devs ([65d1a3f](https://github.com/imagekit-developer/imagekit-python/commit/65d1a3f77eaa5a5c9dba5202a75dee3c70aa64a0)) +* add sample of get file metadata ([6d11584](https://github.com/imagekit-developer/imagekit-python/commit/6d115841c341df0f7a9d4d9bd0c33c1cf386d9c7)) +* change pacakge name & fix import ([2c1734a](https://github.com/imagekit-developer/imagekit-python/commit/2c1734a6e12c935bc80f72ec6b8cdd5a971e5a47)) +* fix package name ([c0c939d](https://github.com/imagekit-developer/imagekit-python/commit/c0c939d86fa5738855a0d6b606e33249ecd5a47a)) +* fix package name ([4bc8041](https://github.com/imagekit-developer/imagekit-python/commit/4bc8041e22c6333710645ddc95446c9c348eea5b)) +* fix sample ([2188038](https://github.com/imagekit-developer/imagekit-python/commit/2188038436aabfce68a3c1d7bb198ffda203dc72)) +* init ([febccef](https://github.com/imagekit-developer/imagekit-python/commit/febccef19d6ca6ae2b6c4272d44ae1625c9f3391)) +* remove unecessary workflow file ([97f19eb](https://github.com/imagekit-developer/imagekit-python/commit/97f19eb8284c5edfe164f98ad296ea1e69b21bf8)) +* remove unused dummy methods from API documentation ([4727908](https://github.com/imagekit-developer/imagekit-python/commit/472790845ef7009aa3695fc084ef8c5d1d63f2ab)) +* sync repo ([c6afd44](https://github.com/imagekit-developer/imagekit-python/commit/c6afd449e74ebb20ebc8d3390355219fccaf2178)) +* unused import removed ([22774ff](https://github.com/imagekit-developer/imagekit-python/commit/22774fff1ac08c0573efc06ab10f3fe31e6d3f69)) +* update SDK settings ([81f0de9](https://github.com/imagekit-developer/imagekit-python/commit/81f0de954a0d531c6b98354386462f4186a58aba)) + + +### Build System + +* add url and requirements ([211228e](https://github.com/imagekit-developer/imagekit-python/commit/211228ef91fe29b83507c89f3bf22cfb6b1c8184)) +* add url and requirements ([683ad01](https://github.com/imagekit-developer/imagekit-python/commit/683ad016099d4e4614b6f369bff69d9a7422029e)) +* add url and requirements ([#2](https://github.com/imagekit-developer/imagekit-python/issues/2)) ([211228e](https://github.com/imagekit-developer/imagekit-python/commit/211228ef91fe29b83507c89f3bf22cfb6b1c8184)) diff --git a/pyproject.toml b/pyproject.toml index 48f905fd..9902514a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "imagekitio" -version = "0.0.1" +version = "5.0.0" description = "The official Python library for the ImageKit API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/imagekitio/_version.py b/src/imagekitio/_version.py index 2df83f1a..32a263a1 100644 --- a/src/imagekitio/_version.py +++ b/src/imagekitio/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "imagekitio" -__version__ = "0.0.1" # x-release-please-version +__version__ = "5.0.0" # x-release-please-version