diff --git a/.speakeasy/gen.lock b/.speakeasy/gen.lock index 7726e10..0d312fb 100644 --- a/.speakeasy/gen.lock +++ b/.speakeasy/gen.lock @@ -1,24 +1,24 @@ lockVersion: 2.0.0 id: 1927f304-b110-462d-9e13-326cfa243f23 management: - docChecksum: 1704b660dc7442e84b53d8042f384977 + docChecksum: f703ea3bff2299c0b86f26325b70ba35 docVersion: v0.1 - speakeasyVersion: 1.769.2 - generationVersion: 2.892.5 - releaseVersion: 0.2.2 - configChecksum: df1e23ce8ae29e8cafd9aaa9507a0a3f + speakeasyVersion: 1.784.0 + generationVersion: 2.911.0 + releaseVersion: 0.2.3 + configChecksum: a1cf2214a18589fec6c94d566447a1b9 repoURL: https://github.com/thetradedesk/ttd-data-python.git installationURL: https://github.com/thetradedesk/ttd-data-python.git published: true persistentEdits: - generation_id: e0f70291-a137-4e04-9d3f-5a22dc8ce132 - pristine_commit_hash: 758652983c99cf5b221a913984ff0479e1023471 - pristine_tree_hash: b8486ab95078f8a1dfaea0d0ec448366930ac0fd + generation_id: b0586244-25ac-4c95-9d51-508b567366c4 + pristine_commit_hash: 4c213a2cdcf54e44b86e45d5ac24ccb7c3167e8e + pristine_tree_hash: fe2e7a066615f945a791f8f752744588475c062e features: python: additionalDependencies: 1.1.0 constsAndDefaults: 1.0.7 - core: 6.0.24 + core: 6.0.30 defaultEnabledRetries: 0.2.0 devContainers: 3.0.0 enumUnions: 0.1.1 @@ -27,13 +27,13 @@ features: flattening: 3.1.1 globalSecurityCallbacks: 1.0.0 globalServerURLs: 3.2.1 - methodArguments: 1.1.0 + methodArguments: 1.1.1 methodServerURLs: 3.1.2 nameOverrides: 3.0.3 nullables: 1.0.2 responseFormat: 1.1.0 - retries: 3.0.5 - sdkHooks: 1.2.1 + retries: 3.0.7 + sdkHooks: 1.2.2 trackedFiles: .devcontainer/devcontainer.json: id: b34062a34eb1 @@ -114,20 +114,20 @@ trackedFiles: pristine_git_object: 3b1d22daa091ac98e81dcebc9ce395c927a30462 docs/models/baseadvertiserdataitem.md: id: 97e86ecee3d8 - last_write_checksum: sha1:64980fbdc0921ceef19ed62406a12992fde9ed09 - pristine_git_object: 3cfde39c6bb45549c0c941d4e282fc79cd763ddb + last_write_checksum: sha1:bddc51b8fefdfea1493af92b654cdef531c879d7 + pristine_git_object: 6211c9500c87a6cdf6d93d2e430834e20c569949 docs/models/baseofflineconversiondataitem.md: id: 13bfd96ba4aa last_write_checksum: sha1:3a236a50f5d08f4751b96863c110a00f0c800b79 pristine_git_object: 69e0b5abce0893419f0aec3b3360397825a3b3f8 docs/models/basepartnerdsrdataitem.md: id: bb2738e1cf3b - last_write_checksum: sha1:7220e8ad7c7d0ac8035486b98b73149078d22e33 - pristine_git_object: 2d39ff01099ec32980af100d2b2760b4e5a29366 + last_write_checksum: sha1:e2b7d2d5356b04909fdd6d333882a9c90fc87cf8 + pristine_git_object: 79e25e2d75b010f64c15c7b83ef62acc20f8101b docs/models/basethirdpartydataitem.md: id: 20f1f84d4720 - last_write_checksum: sha1:67211335d418fd0c1c7b1c96c461c24067593395 - pristine_git_object: 84a6fe24f107018816eecade533e1caa351d8cd9 + last_write_checksum: sha1:b43f7c8eca189920c8e1e50614db72357bbe9038 + pristine_git_object: 654c8c84d79f2775a1371ca7824d382d6a94ea4f docs/models/dataorigin.md: id: 1d98f1297ba8 last_write_checksum: sha1:4c6d538b85d82101a460bf10664af23f61453d83 @@ -278,8 +278,8 @@ trackedFiles: pristine_git_object: f456032107a9387ba6c98afd1c981df2f4b3d636 pyproject.toml: id: 5d07e7d72637 - last_write_checksum: sha1:10545d30b83db420604ef560afa20ae110bdf033 - pristine_git_object: e0bb74f29173a8a0beee6760481bb7add97973be + last_write_checksum: sha1:c245295e0dafa117199d9394a7feca41f99be2e7 + pristine_git_object: 7d4c874462b3295e462aa87f4993cc61f8bfbfe6 scripts/prepare_readme.py: id: e0c5957a6035 last_write_checksum: sha1:5a79be5a1346a05f9099f1177f4b3605849c3b6b @@ -306,20 +306,20 @@ trackedFiles: pristine_git_object: 4056118bd6df7bb0989a9734404c082cfca19e09 src/ttd_data/_version.py: id: 7feb4586507e - last_write_checksum: sha1:03542071cc0231f1af341d110caf7d38c647f6f2 - pristine_git_object: 74f8faae55b5c8c22b61b56c7b1f8327a244d92f + last_write_checksum: sha1:417ad04eae1c1e407975549b64080daa3f7a963b + pristine_git_object: a5bf26be4ad2d4b247e966c258ebc9154bb315bd src/ttd_data/advertiser.py: id: 392ead635b4f - last_write_checksum: sha1:3069a1cb2d2393436adbc5e22cb1aa9961aafdd8 - pristine_git_object: eeb11f9b3e324308e8897e2ba284a3d55241ee26 + last_write_checksum: sha1:ec894846317d09fa8c5d1764e97bb80d06e4a628 + pristine_git_object: 4c4131c6a31a8a74aad66247f206d1408df518a3 src/ttd_data/basesdk.py: id: 28e634bcfb11 last_write_checksum: sha1:94fb8380b03b78312f2951f566bab98f1d0bceee pristine_git_object: b3e7ee7ab69e5875ef7bf64033f4198e51dc8995 src/ttd_data/deletionoptout.py: id: 8da72b51c89e - last_write_checksum: sha1:d9e4f10eb9506b11f814c0608899fd1a75b68160 - pristine_git_object: 7383244c2e2cbbfc406fee1e6a555bfba09c6c54 + last_write_checksum: sha1:71397de8add8287d8f7e6747a4398de1b012874c + pristine_git_object: 089983a64badf0b1f01bf280affb0cef2adc7c09 src/ttd_data/errors/__init__.py: id: c4bbecf8701c last_write_checksum: sha1:8992fce5c10ff19811e66f6a85e3f761e241fd10 @@ -406,20 +406,20 @@ trackedFiles: pristine_git_object: 14345dfe926d07f815a6d10d80a2a1265b8f08a5 src/ttd_data/models/baseadvertiserdataitem.py: id: df8b7792ffe1 - last_write_checksum: sha1:f062c78b1653630551d8becf749c5ea7ce4d2be0 - pristine_git_object: 70abb146958371f23d53dbcd98813994cbfff211 + last_write_checksum: sha1:37c312fcb2214accd42332ca46e4dcb3e2bb405b + pristine_git_object: 03672bc702f8d9cde3394707c92b96ec398e6c1e src/ttd_data/models/baseofflineconversiondataitem.py: id: 7922701d5b7f last_write_checksum: sha1:852315b9cd570acf13a0b126c68a2f839b2fcc62 pristine_git_object: 11c67b6ccde738b33b453add063aea927fad54ad src/ttd_data/models/basepartnerdsrdataitem.py: id: 4be97bf202da - last_write_checksum: sha1:c36ce4a3d9639c8f2d9abb2f3d70172521401e0e - pristine_git_object: 776f5c78b93ef2feda3fe90bf7754432ef9cfd37 + last_write_checksum: sha1:010e54fa4fbbff708f43af2ec50d5576a1f1abcf + pristine_git_object: 2cf8831ed688fe557d9a538c5b9a42396d466630 src/ttd_data/models/basethirdpartydataitem.py: id: 38edda88d345 - last_write_checksum: sha1:4df2372c943fe2caae6dce56df227fdcb149d1c6 - pristine_git_object: bdd21855aec1e1d6242921235a1ed85cc5256e13 + last_write_checksum: sha1:e3b85439f7a149c40387e975393fadefc54fd4eb + pristine_git_object: 88ef7d095b0116d5553d612653d4c14799c3bd1e src/ttd_data/models/dataorigin.py: id: 01488b30ef74 last_write_checksum: sha1:b1ac1ca17a73abdc3fa8f946f35d77e02a37e9a2 @@ -534,8 +534,8 @@ trackedFiles: pristine_git_object: b8960ee34603991114cae808f15a75001ac2c6a8 src/ttd_data/offlineconversion.py: id: 9cbf832f0f9b - last_write_checksum: sha1:7bb18e893d1a2fd4aca3d57d6be481fb408dbe5a - pristine_git_object: 6afe42275a5a4f9a2edd552cb3a07b7e604a1296 + last_write_checksum: sha1:08df51bb641f6caf0e8271c9b71841bb38eae75b + pristine_git_object: a5fed907604420cf175d7d8b05f162c0d13126e3 src/ttd_data/py.typed: id: 6369ae030577 last_write_checksum: sha1:8efc425ffe830805ffcc0f3055871bdcdc542c60 @@ -550,16 +550,16 @@ trackedFiles: pristine_git_object: 914672ad7183223ef1b4fcaf06b06356fb5374c7 src/ttd_data/thirdparty.py: id: ad2d973ed3be - last_write_checksum: sha1:6e6e8352bf00b6a1bc9c58909b312057b5e5392a - pristine_git_object: e99faab4918d9c60fc387d4c76eedcdba3e2c5fa + last_write_checksum: sha1:8cde49b9b532d7020cf20418c7360516dd13cf85 + pristine_git_object: 6f9e1bb2cfafc9b94301288c33734fe43000e876 src/ttd_data/types/__init__.py: id: 219a89572292 last_write_checksum: sha1:ec1219cdc8f39cd32347b66eda50794bc082c9d8 pristine_git_object: e7c65008bf8f354a28a2a677a6f0876fbf730a37 src/ttd_data/types/base64fileinput.py: id: 7768112fd4b5 - last_write_checksum: sha1:12164551aaf833144e0b3095812e136fc0994a22 - pristine_git_object: c29ab0aee3e722406fa844110b8a4a2d73404281 + last_write_checksum: sha1:58e1c9b5f4a21f18b261aec52c2829712f75a165 + pristine_git_object: 52ccdc8c335caa53bfddfbb9f9242d1549e2c532 src/ttd_data/types/basemodel.py: id: 00b5490305bc last_write_checksum: sha1:b3399632dd5bc83ae6673822f38b2c1b1fc76dfa @@ -586,12 +586,12 @@ trackedFiles: pristine_git_object: 2471fa555a0e355a3fe15eece4e9166a701309b7 src/ttd_data/utils/eventstreaming.py: id: 88cef70df5ca - last_write_checksum: sha1:065c09e76a08a3c04a8d470c3044aca74c1f54c9 - pristine_git_object: 613d2fffbde6adc39b17935b8aa93a034dea9014 + last_write_checksum: sha1:fe752bc8c198ae59b8b2f1690b392b451cb158e6 + pristine_git_object: 6eb4b133fd9f1d8916fe6d4136d027461a21c237 src/ttd_data/utils/forms.py: id: 3b8d93e597bb - last_write_checksum: sha1:ab6d3f8cf0bfbe0ccebd7d927a25329602fb16ec - pristine_git_object: 7928ef2118082336aad561f80c1fc5ad7815b9ce + last_write_checksum: sha1:1ee02b3fc3d83879aad6a0d81d3abd09535ca024 + pristine_git_object: 5992275601a8388459b89873b9cc0b3f36f9f19f src/ttd_data/utils/headers.py: id: 4681366f5995 last_write_checksum: sha1:bb7097db485e750d0094e2d94ce6ff328a52fb3c @@ -614,16 +614,16 @@ trackedFiles: pristine_git_object: 5bc70856007fcb6807f1799f8a7900ae7a58aa1a src/ttd_data/utils/retries.py: id: 04accebbe68a - last_write_checksum: sha1:19e8a7c769ed8aa3d050f91667eba9b811e85a4b - pristine_git_object: 0934a56cbd3eb4dde5b54ac683e5c16730244c5d + last_write_checksum: sha1:72b69874b569187013180f4ee1729e033016aa57 + pristine_git_object: 5cef7c40b61d7110bb49931d20ba1a8da02769a8 src/ttd_data/utils/security.py: id: e38af000ccc5 last_write_checksum: sha1:853c8f9f500bcffaf6880e27feafaab0e9146da8 pristine_git_object: e56eff29be2cf5406dc94fbb1c9d8bd96cd3b257 src/ttd_data/utils/serializers.py: id: 625de2eedcad - last_write_checksum: sha1:c29ac2153c26e5566b5b988712b38704b72ef2d1 - pristine_git_object: b3c3d4b55ad65fc3f813ec2521a9e0e63adeccdb + last_write_checksum: sha1:dba8cf4b28b0da4830c53a76c700cf6bb358dc9e + pristine_git_object: 4a461beeb891ca09e458dc5939aa032a297ef5ad src/ttd_data/utils/unmarshal_json_response.py: id: 2f612fb73d96 last_write_checksum: sha1:4a6f87d7632a4364ee64b338e2a7404bc0074885 @@ -711,3 +711,4 @@ examples: application/json: {} examplesVersion: 1.0.2 generatedTests: {} +releaseNotes: "## Python SDK Changes:\n* `base_data_client.advertiser.ingest_advertiser_data()`: \n * `request.items[].utiq_id` **Added**\n* `base_data_client.third_party.ingest_third_party_data()`: \n * `request.items[].utiq_id` **Added**\n* `base_data_client.deletion_opt_out.data_subject_request_advertiser_data()`: \n * `request.items[].utiq_id` **Added**\n* `base_data_client.deletion_opt_out.data_subject_request_merchant_data()`: \n * `request.items[].utiq_id` **Added**\n* `base_data_client.deletion_opt_out.data_subject_request_third_party_data()`: \n * `request.items[].utiq_id` **Added**\n" diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml index efa5125..7146747 100644 --- a/.speakeasy/gen.yaml +++ b/.speakeasy/gen.yaml @@ -35,7 +35,7 @@ generation: generateNewTests: false skipResponseBodyAssertions: false python: - version: 0.2.2 + version: 0.2.3 additionalDependencies: dev: {} main: @@ -56,6 +56,10 @@ python: enableCustomCodeRegions: false enumFormat: enum envVarPrefix: TTD_DATA + errorSchemaValidation: true + eventStreamClassNames: + async: EventStreamAsync + sync: EventStream fixFlags: asyncPaginationSep2025: true conflictResistantModelImportsFeb2026: false @@ -75,6 +79,7 @@ python: webhooks: "" inferUnionDiscriminators: true inputModelSuffix: input + inputTypedDictSuffix: TypedDict license: name: Apache License 2.0 shortName: Apache-2.0 @@ -92,7 +97,9 @@ python: preApplyUnionDiscriminators: true pytestFilterWarnings: [] pytestTimeout: 0 + rawResponseHelpers: false responseFormat: envelope-http + responseSchemaValidation: true sseFlatResponse: false templateVersion: v2 useAsyncHooks: false diff --git a/.speakeasy/out.openapi.yaml b/.speakeasy/out.openapi.yaml index 929597a..e76dcbe 100644 --- a/.speakeasy/out.openapi.yaml +++ b/.speakeasy/out.openapi.yaml @@ -419,6 +419,9 @@ components: FirstID: type: "string" nullable: true + UtiqID: + type: "string" + nullable: true MerkuryID: type: "string" nullable: true @@ -842,6 +845,9 @@ components: FirstID: type: "string" nullable: true + UtiqID: + type: "string" + nullable: true MerkuryID: type: "string" nullable: true @@ -954,6 +960,9 @@ components: FirstID: type: "string" nullable: true + UtiqID: + type: "string" + nullable: true MerkuryID: type: "string" nullable: true diff --git a/.speakeasy/workflow.lock b/.speakeasy/workflow.lock index 1a59634..f2cc40f 100644 --- a/.speakeasy/workflow.lock +++ b/.speakeasy/workflow.lock @@ -1,9 +1,9 @@ -speakeasyVersion: 1.769.2 +speakeasyVersion: 1.784.0 sources: Data API: sourceNamespace: data-api - sourceRevisionDigest: sha256:89b1cbae91ae63ec4179affa58bb25552db0fd9cdaa350a9324d0cdd77c370b0 - sourceBlobDigest: sha256:bee86197f6bb6a124db5b555f9be5177a078d122a153f21beefdf3e545ff8545 + sourceRevisionDigest: sha256:2b7e448bdcbb3bf0f11804199b710bfcc292a892c9e47fb6cb7a28a0674ecb38 + sourceBlobDigest: sha256:5de4bcfe6ed9231d20833531fccea11c67b6f99145c704b56c407e6c51641f63 tags: - latest - v0.1 @@ -18,10 +18,10 @@ targets: data-api: source: Data API sourceNamespace: data-api - sourceRevisionDigest: sha256:89b1cbae91ae63ec4179affa58bb25552db0fd9cdaa350a9324d0cdd77c370b0 - sourceBlobDigest: sha256:bee86197f6bb6a124db5b555f9be5177a078d122a153f21beefdf3e545ff8545 + sourceRevisionDigest: sha256:2b7e448bdcbb3bf0f11804199b710bfcc292a892c9e47fb6cb7a28a0674ecb38 + sourceBlobDigest: sha256:5de4bcfe6ed9231d20833531fccea11c67b6f99145c704b56c407e6c51641f63 codeSamplesNamespace: data-api-python-code-samples - codeSamplesRevisionDigest: sha256:4e1e349fd6184ee70d6a2afb462799618cf6685cc37115a3e8edaddf17e0aad8 + codeSamplesRevisionDigest: sha256:3bba5c9729abb1474e02615293d58ebb0f80909088aa1a2b8a86f225c153ab04 data-api-local: source: Data API Local sourceNamespace: data-api-local diff --git a/RELEASES.md b/RELEASES.md index 40ba8ad..81d660a 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -118,4 +118,14 @@ Based on: ### Generated - [python v0.2.2] . ### Releases -- [PyPI v0.2.2] https://pypi.org/project/ttd-data/0.2.2 - . \ No newline at end of file +- [PyPI v0.2.2] https://pypi.org/project/ttd-data/0.2.2 - . + +## 2026-06-20 02:33:35 +### Changes +Based on: +- OpenAPI Doc +- Speakeasy CLI 1.784.0 (2.911.0) https://github.com/speakeasy-api/speakeasy +### Generated +- [python v0.2.3] . +### Releases +- [PyPI v0.2.3] https://pypi.org/project/ttd-data/0.2.3 - . \ No newline at end of file diff --git a/docs/models/baseadvertiserdataitem.md b/docs/models/baseadvertiserdataitem.md index 3cfde39..6211c95 100644 --- a/docs/models/baseadvertiserdataitem.md +++ b/docs/models/baseadvertiserdataitem.md @@ -16,6 +16,7 @@ | `id5` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `net_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `first_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | +| `utiq_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `merkury_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `iqvia_ppid` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `data` | List[[models.AdvertiserData](../models/advertiserdata.md)] | :heavy_check_mark: | N/A | diff --git a/docs/models/basepartnerdsrdataitem.md b/docs/models/basepartnerdsrdataitem.md index 2d39ff0..79e25e2 100644 --- a/docs/models/basepartnerdsrdataitem.md +++ b/docs/models/basepartnerdsrdataitem.md @@ -16,5 +16,6 @@ | `id5` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `net_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `first_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | +| `utiq_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `merkury_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `iqvia_ppid` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/docs/models/basethirdpartydataitem.md b/docs/models/basethirdpartydataitem.md index 84a6fe2..654c8c8 100644 --- a/docs/models/basethirdpartydataitem.md +++ b/docs/models/basethirdpartydataitem.md @@ -17,6 +17,7 @@ | `id5` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `net_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `first_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | +| `utiq_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `merkury_id` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `iqvia_ppid` | *OptionalNullable[str]* | :heavy_minus_sign: | N/A | | `data` | List[[models.ThirdPartyData](../models/thirdpartydata.md)] | :heavy_check_mark: | N/A | diff --git a/pyproject.toml b/pyproject.toml index 63aa158..365f45e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ttd-data" -version = "0.2.2" +version = "0.2.3" description = "Python Client SDK for TTD Data API." authors = [{ name = "Speakeasy" },] readme = "README-PYPI.md" diff --git a/src/ttd_data/_version.py b/src/ttd_data/_version.py index 74f8faa..a5bf26b 100644 --- a/src/ttd_data/_version.py +++ b/src/ttd_data/_version.py @@ -4,10 +4,10 @@ import importlib.metadata __title__: str = "ttd-data" -__version__: str = "0.2.2" +__version__: str = "0.2.3" __openapi_doc_version__: str = "v0.1" -__gen_version__: str = "2.892.5" -__user_agent__: str = "speakeasy-sdk/python 0.2.2 2.892.5 v0.1 ttd-data" +__gen_version__: str = "2.911.0" +__user_agent__: str = "speakeasy-sdk/python 0.2.3 2.911.0 v0.1 ttd-data" try: if __package__ is not None: diff --git a/src/ttd_data/advertiser.py b/src/ttd_data/advertiser.py index eeb11f9..4c4131c 100644 --- a/src/ttd_data/advertiser.py +++ b/src/ttd_data/advertiser.py @@ -6,7 +6,7 @@ from ttd_data._hooks import HookContext from ttd_data.types import OptionalNullable, UNSET from ttd_data.utils.unmarshal_json_response import unmarshal_json_response -from typing import Any, List, Mapping, Optional, Union +from typing import Any, Iterable, List, Mapping, Optional, Union class Advertiser(BaseSDK): @@ -18,13 +18,13 @@ def ingest_advertiser_data( data_provider_id: OptionalNullable[str] = UNSET, items: OptionalNullable[ Union[ - List[models.BaseAdvertiserDataItem], - List[models.BaseAdvertiserDataItemTypedDict], + Iterable[models.BaseAdvertiserDataItem], + Iterable[models.BaseAdvertiserDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, data_origins: OptionalNullable[ - Union[List[models.DataOrigin], List[models.DataOriginTypedDict]] + Union[Iterable[models.DataOrigin], Iterable[models.DataOriginTypedDict]] ] = UNSET, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, @@ -142,13 +142,13 @@ async def ingest_advertiser_data_async( data_provider_id: OptionalNullable[str] = UNSET, items: OptionalNullable[ Union[ - List[models.BaseAdvertiserDataItem], - List[models.BaseAdvertiserDataItemTypedDict], + Iterable[models.BaseAdvertiserDataItem], + Iterable[models.BaseAdvertiserDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, data_origins: OptionalNullable[ - Union[List[models.DataOrigin], List[models.DataOriginTypedDict]] + Union[Iterable[models.DataOrigin], Iterable[models.DataOriginTypedDict]] ] = UNSET, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, diff --git a/src/ttd_data/deletionoptout.py b/src/ttd_data/deletionoptout.py index 7383244..089983a 100644 --- a/src/ttd_data/deletionoptout.py +++ b/src/ttd_data/deletionoptout.py @@ -6,7 +6,7 @@ from ttd_data._hooks import HookContext from ttd_data.types import OptionalNullable, UNSET from ttd_data.utils.unmarshal_json_response import unmarshal_json_response -from typing import Any, List, Mapping, Optional, Union +from typing import Any, Iterable, List, Mapping, Optional, Union class DeletionOptOut(BaseSDK): @@ -18,8 +18,8 @@ def data_subject_request_advertiser_data( data_provider_id: OptionalNullable[str] = UNSET, items: OptionalNullable[ Union[ - List[models.BasePartnerDsrDataItem], - List[models.BasePartnerDsrDataItemTypedDict], + Iterable[models.BasePartnerDsrDataItem], + Iterable[models.BasePartnerDsrDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, @@ -138,8 +138,8 @@ async def data_subject_request_advertiser_data_async( data_provider_id: OptionalNullable[str] = UNSET, items: OptionalNullable[ Union[ - List[models.BasePartnerDsrDataItem], - List[models.BasePartnerDsrDataItemTypedDict], + Iterable[models.BasePartnerDsrDataItem], + Iterable[models.BasePartnerDsrDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, @@ -257,8 +257,8 @@ def data_subject_request_merchant_data( merchant_id: OptionalNullable[int] = UNSET, items: OptionalNullable[ Union[ - List[models.BasePartnerDsrDataItem], - List[models.BasePartnerDsrDataItemTypedDict], + Iterable[models.BasePartnerDsrDataItem], + Iterable[models.BasePartnerDsrDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, @@ -374,8 +374,8 @@ async def data_subject_request_merchant_data_async( merchant_id: OptionalNullable[int] = UNSET, items: OptionalNullable[ Union[ - List[models.BasePartnerDsrDataItem], - List[models.BasePartnerDsrDataItemTypedDict], + Iterable[models.BasePartnerDsrDataItem], + Iterable[models.BasePartnerDsrDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, @@ -492,8 +492,8 @@ def data_subject_request_third_party_data( brand_id: OptionalNullable[str] = UNSET, items: OptionalNullable[ Union[ - List[models.BasePartnerDsrDataItem], - List[models.BasePartnerDsrDataItemTypedDict], + Iterable[models.BasePartnerDsrDataItem], + Iterable[models.BasePartnerDsrDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, @@ -612,8 +612,8 @@ async def data_subject_request_third_party_data_async( brand_id: OptionalNullable[str] = UNSET, items: OptionalNullable[ Union[ - List[models.BasePartnerDsrDataItem], - List[models.BasePartnerDsrDataItemTypedDict], + Iterable[models.BasePartnerDsrDataItem], + Iterable[models.BasePartnerDsrDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, diff --git a/src/ttd_data/models/baseadvertiserdataitem.py b/src/ttd_data/models/baseadvertiserdataitem.py index 70abb14..03672bc 100644 --- a/src/ttd_data/models/baseadvertiserdataitem.py +++ b/src/ttd_data/models/baseadvertiserdataitem.py @@ -23,6 +23,7 @@ class BaseAdvertiserDataItemTypedDict(TypedDict): id5: NotRequired[Nullable[str]] net_id: NotRequired[Nullable[str]] first_id: NotRequired[Nullable[str]] + utiq_id: NotRequired[Nullable[str]] merkury_id: NotRequired[Nullable[str]] iqvia_ppid: NotRequired[Nullable[str]] cookie_mapping_partner_id: NotRequired[Nullable[str]] @@ -57,6 +58,8 @@ class BaseAdvertiserDataItem(BaseModel): first_id: Annotated[OptionalNullable[str], pydantic.Field(alias="FirstID")] = UNSET + utiq_id: Annotated[OptionalNullable[str], pydantic.Field(alias="UtiqID")] = UNSET + merkury_id: Annotated[OptionalNullable[str], pydantic.Field(alias="MerkuryID")] = ( UNSET ) @@ -84,6 +87,7 @@ def serialize_model(self, handler): "ID5", "NetID", "FirstID", + "UtiqID", "MerkuryID", "IqviaPPID", "CookieMappingPartnerId", @@ -102,6 +106,7 @@ def serialize_model(self, handler): "ID5", "NetID", "FirstID", + "UtiqID", "MerkuryID", "IqviaPPID", "CookieMappingPartnerId", diff --git a/src/ttd_data/models/basepartnerdsrdataitem.py b/src/ttd_data/models/basepartnerdsrdataitem.py index 776f5c7..2cf8831 100644 --- a/src/ttd_data/models/basepartnerdsrdataitem.py +++ b/src/ttd_data/models/basepartnerdsrdataitem.py @@ -20,6 +20,7 @@ class BasePartnerDsrDataItemTypedDict(TypedDict): id5: NotRequired[Nullable[str]] net_id: NotRequired[Nullable[str]] first_id: NotRequired[Nullable[str]] + utiq_id: NotRequired[Nullable[str]] merkury_id: NotRequired[Nullable[str]] iqvia_ppid: NotRequired[Nullable[str]] @@ -51,6 +52,8 @@ class BasePartnerDsrDataItem(BaseModel): first_id: Annotated[OptionalNullable[str], pydantic.Field(alias="FirstID")] = UNSET + utiq_id: Annotated[OptionalNullable[str], pydantic.Field(alias="UtiqID")] = UNSET + merkury_id: Annotated[OptionalNullable[str], pydantic.Field(alias="MerkuryID")] = ( UNSET ) @@ -74,6 +77,7 @@ def serialize_model(self, handler): "ID5", "NetID", "FirstID", + "UtiqID", "MerkuryID", "IqviaPPID", ] @@ -91,6 +95,7 @@ def serialize_model(self, handler): "ID5", "NetID", "FirstID", + "UtiqID", "MerkuryID", "IqviaPPID", ] diff --git a/src/ttd_data/models/basethirdpartydataitem.py b/src/ttd_data/models/basethirdpartydataitem.py index bdd2185..88ef7d0 100644 --- a/src/ttd_data/models/basethirdpartydataitem.py +++ b/src/ttd_data/models/basethirdpartydataitem.py @@ -24,6 +24,7 @@ class BaseThirdPartyDataItemTypedDict(TypedDict): id5: NotRequired[Nullable[str]] net_id: NotRequired[Nullable[str]] first_id: NotRequired[Nullable[str]] + utiq_id: NotRequired[Nullable[str]] merkury_id: NotRequired[Nullable[str]] iqvia_ppid: NotRequired[Nullable[str]] cookie_mapping_partner_id: NotRequired[Nullable[str]] @@ -62,6 +63,8 @@ class BaseThirdPartyDataItem(BaseModel): first_id: Annotated[OptionalNullable[str], pydantic.Field(alias="FirstID")] = UNSET + utiq_id: Annotated[OptionalNullable[str], pydantic.Field(alias="UtiqID")] = UNSET + merkury_id: Annotated[OptionalNullable[str], pydantic.Field(alias="MerkuryID")] = ( UNSET ) @@ -90,6 +93,7 @@ def serialize_model(self, handler): "ID5", "NetID", "FirstID", + "UtiqID", "MerkuryID", "IqviaPPID", "CookieMappingPartnerId", @@ -109,6 +113,7 @@ def serialize_model(self, handler): "ID5", "NetID", "FirstID", + "UtiqID", "MerkuryID", "IqviaPPID", "CookieMappingPartnerId", diff --git a/src/ttd_data/offlineconversion.py b/src/ttd_data/offlineconversion.py index 6afe422..a5fed90 100644 --- a/src/ttd_data/offlineconversion.py +++ b/src/ttd_data/offlineconversion.py @@ -6,7 +6,7 @@ from ttd_data._hooks import HookContext from ttd_data.types import OptionalNullable, UNSET from ttd_data.utils.unmarshal_json_response import unmarshal_json_response -from typing import Any, List, Mapping, Optional, Union +from typing import Any, Iterable, List, Mapping, Optional, Union class OfflineConversion(BaseSDK): @@ -15,16 +15,16 @@ def ingest_offline_conversion_data( *, ttd_auth: str, data_provider_id: str, - user_id_array_metadata_format: OptionalNullable[List[str]] = UNSET, + user_id_array_metadata_format: OptionalNullable[Iterable[str]] = UNSET, items: OptionalNullable[ Union[ - List[models.BaseOfflineConversionDataItem], - List[models.BaseOfflineConversionDataItemTypedDict], + Iterable[models.BaseOfflineConversionDataItem], + Iterable[models.BaseOfflineConversionDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, data_origins: OptionalNullable[ - Union[List[models.DataOrigin], List[models.DataOriginTypedDict]] + Union[Iterable[models.DataOrigin], Iterable[models.DataOriginTypedDict]] ] = UNSET, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, @@ -58,7 +58,9 @@ def ingest_offline_conversion_data( ttd_auth=ttd_auth, body=models.OfflineConversionDataRequest( data_provider_id=data_provider_id, - user_id_array_metadata_format=user_id_array_metadata_format, + user_id_array_metadata_format=utils.unmarshal( + user_id_array_metadata_format, OptionalNullable[List[str]] + ), items=utils.get_pydantic_model( items, OptionalNullable[List[models.BaseOfflineConversionDataItem]] ), @@ -141,16 +143,16 @@ async def ingest_offline_conversion_data_async( *, ttd_auth: str, data_provider_id: str, - user_id_array_metadata_format: OptionalNullable[List[str]] = UNSET, + user_id_array_metadata_format: OptionalNullable[Iterable[str]] = UNSET, items: OptionalNullable[ Union[ - List[models.BaseOfflineConversionDataItem], - List[models.BaseOfflineConversionDataItemTypedDict], + Iterable[models.BaseOfflineConversionDataItem], + Iterable[models.BaseOfflineConversionDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, data_origins: OptionalNullable[ - Union[List[models.DataOrigin], List[models.DataOriginTypedDict]] + Union[Iterable[models.DataOrigin], Iterable[models.DataOriginTypedDict]] ] = UNSET, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, @@ -184,7 +186,9 @@ async def ingest_offline_conversion_data_async( ttd_auth=ttd_auth, body=models.OfflineConversionDataRequest( data_provider_id=data_provider_id, - user_id_array_metadata_format=user_id_array_metadata_format, + user_id_array_metadata_format=utils.unmarshal( + user_id_array_metadata_format, OptionalNullable[List[str]] + ), items=utils.get_pydantic_model( items, OptionalNullable[List[models.BaseOfflineConversionDataItem]] ), diff --git a/src/ttd_data/thirdparty.py b/src/ttd_data/thirdparty.py index e99faab..6f9e1bb 100644 --- a/src/ttd_data/thirdparty.py +++ b/src/ttd_data/thirdparty.py @@ -6,7 +6,7 @@ from ttd_data._hooks import HookContext from ttd_data.types import OptionalNullable, UNSET from ttd_data.utils.unmarshal_json_response import unmarshal_json_response -from typing import Any, List, Mapping, Optional, Union +from typing import Any, Iterable, List, Mapping, Optional, Union class ThirdParty(BaseSDK): @@ -17,14 +17,14 @@ def ingest_third_party_data( data_provider_id: str, items: OptionalNullable[ Union[ - List[models.BaseThirdPartyDataItem], - List[models.BaseThirdPartyDataItemTypedDict], + Iterable[models.BaseThirdPartyDataItem], + Iterable[models.BaseThirdPartyDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, is_user_id_already_hashed: Optional[bool] = False, data_origins: OptionalNullable[ - Union[List[models.DataOrigin], List[models.DataOriginTypedDict]] + Union[Iterable[models.DataOrigin], Iterable[models.DataOriginTypedDict]] ] = UNSET, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, @@ -141,14 +141,14 @@ async def ingest_third_party_data_async( data_provider_id: str, items: OptionalNullable[ Union[ - List[models.BaseThirdPartyDataItem], - List[models.BaseThirdPartyDataItemTypedDict], + Iterable[models.BaseThirdPartyDataItem], + Iterable[models.BaseThirdPartyDataItemTypedDict], ] ] = UNSET, data_load_trace_id: OptionalNullable[str] = UNSET, is_user_id_already_hashed: Optional[bool] = False, data_origins: OptionalNullable[ - Union[List[models.DataOrigin], List[models.DataOriginTypedDict]] + Union[Iterable[models.DataOrigin], Iterable[models.DataOriginTypedDict]] ] = UNSET, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, diff --git a/src/ttd_data/types/base64fileinput.py b/src/ttd_data/types/base64fileinput.py index c29ab0a..52ccdc8 100644 --- a/src/ttd_data/types/base64fileinput.py +++ b/src/ttd_data/types/base64fileinput.py @@ -24,7 +24,11 @@ def encode_base64_file_input(value: Any) -> Any: with open(value, "rb") as fh: binary = fh.read() else: + # Restore position after reading: pydantic may validate the same stream more than once. + position = value.tell() if value.seekable() else None binary = value.read() + if position is not None: + value.seek(position) if isinstance(binary, str): binary = binary.encode() if not isinstance(binary, (bytes, bytearray)): diff --git a/src/ttd_data/utils/eventstreaming.py b/src/ttd_data/utils/eventstreaming.py index 613d2ff..6eb4b13 100644 --- a/src/ttd_data/utils/eventstreaming.py +++ b/src/ttd_data/utils/eventstreaming.py @@ -8,6 +8,7 @@ Any, Callable, Generic, + List, TypeVar, Optional, Generator, @@ -54,6 +55,9 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def close(self): self._closed = True self.response.close() @@ -93,6 +97,9 @@ async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + async def close(self): self._closed = True await self.response.aclose() @@ -115,6 +122,7 @@ class ServerEvent: b"\n\r", b"\n\n", ] +MAX_BOUNDARY_LEN = max(len(b) for b in MESSAGE_BOUNDARIES) UTF8_BOM = b"\xef\xbb\xbf" @@ -125,52 +133,56 @@ async def stream_events_async( sentinel: Optional[str] = None, data_required: bool = True, ) -> AsyncGenerator[T, None]: - buffer = bytearray() - position = 0 - event_id: Optional[str] = None - async for chunk in response.aiter_bytes(): - if len(buffer) == 0 and chunk.startswith(UTF8_BOM): - chunk = chunk[len(UTF8_BOM) :] - buffer += chunk - for i in range(position, len(buffer)): - char = buffer[i : i + 1] - seq: Optional[bytes] = None - if char in [b"\r", b"\n"]: - for boundary in MESSAGE_BOUNDARIES: - seq = _peek_sequence(i, buffer, boundary) - if seq is not None: - break - if seq is None: - continue - - block = buffer[position:i] - position = i + len(seq) - event, discard, event_id = _parse_event( - raw=block, - decoder=decoder, - sentinel=sentinel, - event_id=event_id, - data_required=data_required, - ) - if event is not None: - yield event - if discard: - await response.aclose() - return - - if position > 0: - buffer = buffer[position:] - position = 0 - - event, discard, _ = _parse_event( - raw=buffer, - decoder=decoder, - sentinel=sentinel, - event_id=event_id, - data_required=data_required, - ) - if event is not None: - yield event + try: + buffer = bytearray() + position = 0 + event_id: Optional[str] = None + async for chunk in response.aiter_bytes(): + if len(buffer) == 0 and chunk.startswith(UTF8_BOM): + chunk = chunk[len(UTF8_BOM) :] + old_len = len(buffer) + buffer += chunk + search_start = max(position, old_len - MAX_BOUNDARY_LEN + 1) + for i in range(search_start, len(buffer)): + char = buffer[i : i + 1] + seq: Optional[bytes] = None + if char in [b"\r", b"\n"]: + for boundary in MESSAGE_BOUNDARIES: + seq = _peek_sequence(i, buffer, boundary) + if seq is not None: + break + if seq is None: + continue + + block = buffer[position:i] + position = i + len(seq) + event, discard, event_id = _parse_event( + raw=block, + decoder=decoder, + sentinel=sentinel, + event_id=event_id, + data_required=data_required, + ) + if event is not None: + yield event + if discard: + return + + if position > 0: + buffer = buffer[position:] + position = 0 + + event, discard, _ = _parse_event( + raw=buffer, + decoder=decoder, + sentinel=sentinel, + event_id=event_id, + data_required=data_required, + ) + if event is not None: + yield event + finally: + await response.aclose() def stream_events( @@ -179,52 +191,56 @@ def stream_events( sentinel: Optional[str] = None, data_required: bool = True, ) -> Generator[T, None, None]: - buffer = bytearray() - position = 0 - event_id: Optional[str] = None - for chunk in response.iter_bytes(): - if len(buffer) == 0 and chunk.startswith(UTF8_BOM): - chunk = chunk[len(UTF8_BOM) :] - buffer += chunk - for i in range(position, len(buffer)): - char = buffer[i : i + 1] - seq: Optional[bytes] = None - if char in [b"\r", b"\n"]: - for boundary in MESSAGE_BOUNDARIES: - seq = _peek_sequence(i, buffer, boundary) - if seq is not None: - break - if seq is None: - continue - - block = buffer[position:i] - position = i + len(seq) - event, discard, event_id = _parse_event( - raw=block, - decoder=decoder, - sentinel=sentinel, - event_id=event_id, - data_required=data_required, - ) - if event is not None: - yield event - if discard: - response.close() - return - - if position > 0: - buffer = buffer[position:] - position = 0 - - event, discard, _ = _parse_event( - raw=buffer, - decoder=decoder, - sentinel=sentinel, - event_id=event_id, - data_required=data_required, - ) - if event is not None: - yield event + try: + buffer = bytearray() + position = 0 + event_id: Optional[str] = None + for chunk in response.iter_bytes(): + if len(buffer) == 0 and chunk.startswith(UTF8_BOM): + chunk = chunk[len(UTF8_BOM) :] + old_len = len(buffer) + buffer += chunk + search_start = max(position, old_len - MAX_BOUNDARY_LEN + 1) + for i in range(search_start, len(buffer)): + char = buffer[i : i + 1] + seq: Optional[bytes] = None + if char in [b"\r", b"\n"]: + for boundary in MESSAGE_BOUNDARIES: + seq = _peek_sequence(i, buffer, boundary) + if seq is not None: + break + if seq is None: + continue + + block = buffer[position:i] + position = i + len(seq) + event, discard, event_id = _parse_event( + raw=block, + decoder=decoder, + sentinel=sentinel, + event_id=event_id, + data_required=data_required, + ) + if event is not None: + yield event + if discard: + return + + if position > 0: + buffer = buffer[position:] + position = 0 + + event, discard, _ = _parse_event( + raw=buffer, + decoder=decoder, + sentinel=sentinel, + event_id=event_id, + data_required=data_required, + ) + if event is not None: + yield event + finally: + response.close() def _parse_event( @@ -239,7 +255,7 @@ def _parse_event( lines = re.split(r"\r?\n|\r", block) publish = False event = ServerEvent() - data = "" + data_parts: List[str] = [] for line in lines: if not line: continue @@ -260,7 +276,7 @@ def _parse_event( event.event = value publish = True elif field == "data": - data += value + "\n" + data_parts.append(value) publish = True elif field == "id": publish = True @@ -272,16 +288,17 @@ def _parse_event( publish = True event.id = event_id + has_data = bool(data_parts) + data = "\n".join(data_parts) - if sentinel and data == f"{sentinel}\n": + if sentinel and has_data and data == sentinel: return None, True, event_id # Skip data-less events when data is required - if not data and publish and data_required: + if not has_data and publish and data_required: return None, False, event_id - if data: - data = data[:-1] + if has_data: try: event.data = json.loads(data) except json.JSONDecodeError: @@ -292,7 +309,7 @@ def _parse_event( out_dict = { k: v for k, v in asdict(event).items() - if v is not None or (k == "data" and data) + if v is not None or (k == "data" and has_data) } out = decoder(json.dumps(out_dict)) diff --git a/src/ttd_data/utils/forms.py b/src/ttd_data/utils/forms.py index 7928ef2..5992275 100644 --- a/src/ttd_data/utils/forms.py +++ b/src/ttd_data/utils/forms.py @@ -1,6 +1,7 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" # @generated-id: 3b8d93e597bb +import io from typing import ( Any, Dict, @@ -104,6 +105,10 @@ def _extract_file_properties(file_obj: Any) -> Tuple[str, Any, Any]: if file_metadata.content: content = getattr(file_obj, file_field_name, None) + if isinstance(content, io.TextIOBase): + content = content.read().encode( + getattr(content, "encoding", None) or "utf-8" + ) elif file_field_name == "content_type": content_type = getattr(file_obj, file_field_name, None) else: diff --git a/src/ttd_data/utils/retries.py b/src/ttd_data/utils/retries.py index 0934a56..5cef7c4 100644 --- a/src/ttd_data/utils/retries.py +++ b/src/ttd_data/utils/retries.py @@ -12,10 +12,13 @@ class BackoffStrategy: + """Exponential backoff strategy configuration.""" + initial_interval: int max_interval: int exponent: float max_elapsed_time: int + jitter_ms: Optional[int] def __init__( self, @@ -23,24 +26,63 @@ def __init__( max_interval: int, exponent: float, max_elapsed_time: int, + jitter_ms: Optional[int] = None, ): + """Initialize a backoff strategy. + + Args: + initial_interval: Initial retry interval in milliseconds. + max_interval: Maximum retry interval in milliseconds. + exponent: Base of the exponential backoff; the interval grows as + ``initial_interval * exponent ** retries``. + max_elapsed_time: Maximum total elapsed time in milliseconds. + jitter_ms: Additive jitter bound in milliseconds. When set, adds a random + value in ``[0, jitter_ms]`` to each computed backoff interval (default + ``+[0, 1s]``). + + Note: + When a response carries a ``Retry-After`` or ``retry-after-ms`` header, + that delay is used as-is and the sleep-shaping parameters + (``initial_interval``, ``max_interval``, ``exponent``, ``jitter_ms``) are + ignored for that attempt. + """ + if jitter_ms is not None and jitter_ms < 0: + raise ValueError("jitter_ms must be >= 0") self.initial_interval = initial_interval self.max_interval = max_interval self.exponent = exponent self.max_elapsed_time = max_elapsed_time + self.jitter_ms = jitter_ms class RetryConfig: + """Runtime retry configuration.""" + strategy: str backoff: BackoffStrategy retry_connection_errors: bool + status_codes_override: Optional[List[str]] def __init__( - self, strategy: str, backoff: BackoffStrategy, retry_connection_errors: bool + self, + strategy: str, + backoff: BackoffStrategy, + retry_connection_errors: bool, + status_codes_override: Optional[List[str]] = None, ): + """Initialize a retry configuration. + + Args: + strategy: Retry strategy: ``"none"`` or ``"backoff"``. + backoff: Backoff parameters. + retry_connection_errors: Whether to also retry transport-level connection errors. + status_codes_override: Retryable HTTP status codes that take precedence over the + per-operation defaults when non-empty. + """ self.strategy = strategy self.backoff = backoff self.retry_connection_errors = retry_connection_errors + self.status_codes_override = status_codes_override class Retries: @@ -49,7 +91,7 @@ class Retries: def __init__(self, config: RetryConfig, status_codes: List[str]): self.config = config - self.status_codes = status_codes + self.status_codes = config.status_codes_override or status_codes class TemporaryError(Exception): @@ -94,12 +136,28 @@ def _parse_retry_after_header(response: httpx.Response) -> Optional[int]: return None +def _parse_retry_after_ms_header(response: httpx.Response) -> Optional[int]: + retry_after_ms_header = response.headers.get("retry-after-ms") + if not retry_after_ms_header: + return None + + try: + milliseconds = float(retry_after_ms_header) + if milliseconds >= 0: + return round(milliseconds) + except (OverflowError, ValueError): + pass + + return None + + def _get_sleep_interval( exception: Exception, initial_interval: int, max_interval: int, exponent: float, retries: int, + jitter_ms: Optional[int] = None, ) -> float: """Get sleep interval for retry with exponential backoff. @@ -109,6 +167,7 @@ def _get_sleep_interval( max_interval: Maximum retry interval in milliseconds. exponent: Base for exponential backoff calculation. retries: Current retry attempt count. + jitter_ms: Additive jitter bound in ms; see ``BackoffStrategy.jitter_ms``. Returns: Sleep interval in seconds. @@ -120,7 +179,11 @@ def _get_sleep_interval( ): return exception.retry_after / 1000 - sleep = (initial_interval / 1000) * exponent**retries + random.uniform(0, 1) + sleep = (initial_interval / 1000) * exponent**retries + if jitter_ms is not None: + sleep += random.uniform(0, jitter_ms / 1000) + else: + sleep += random.uniform(0, 1) return min(sleep, max_interval / 1000) @@ -163,6 +226,7 @@ def do_request() -> httpx.Response: retries.config.backoff.max_interval, retries.config.backoff.exponent, retries.config.backoff.max_elapsed_time, + retries.config.backoff.jitter_ms, ) return func() @@ -207,6 +271,7 @@ async def do_request() -> httpx.Response: retries.config.backoff.max_interval, retries.config.backoff.exponent, retries.config.backoff.max_elapsed_time, + retries.config.backoff.jitter_ms, ) return await func() @@ -218,6 +283,7 @@ def retry_with_backoff( max_interval=60000, exponent=1.5, max_elapsed_time=3600000, + jitter_ms=None, ): start = round(time.time() * 1000) retries = 0 @@ -235,8 +301,17 @@ def retry_with_backoff( raise + if isinstance(exception, TemporaryError): + retry_after_ms = _parse_retry_after_ms_header(exception.response) + if retry_after_ms is not None: + exception.retry_after = retry_after_ms sleep = _get_sleep_interval( - exception, initial_interval, max_interval, exponent, retries + exception, + initial_interval, + max_interval, + exponent, + retries, + jitter_ms=jitter_ms, ) time.sleep(sleep) retries += 1 @@ -248,6 +323,7 @@ async def retry_with_backoff_async( max_interval=60000, exponent=1.5, max_elapsed_time=3600000, + jitter_ms=None, ): start = round(time.time() * 1000) retries = 0 @@ -265,8 +341,17 @@ async def retry_with_backoff_async( raise + if isinstance(exception, TemporaryError): + retry_after_ms = _parse_retry_after_ms_header(exception.response) + if retry_after_ms is not None: + exception.retry_after = retry_after_ms sleep = _get_sleep_interval( - exception, initial_interval, max_interval, exponent, retries + exception, + initial_interval, + max_interval, + exponent, + retries, + jitter_ms=jitter_ms, ) await asyncio.sleep(sleep) retries += 1 diff --git a/src/ttd_data/utils/serializers.py b/src/ttd_data/utils/serializers.py index b3c3d4b..4a461be 100644 --- a/src/ttd_data/utils/serializers.py +++ b/src/ttd_data/utils/serializers.py @@ -5,7 +5,7 @@ import functools import json import typing -from typing import Any, Dict, List, Tuple, Union, get_args +from typing import Any, Dict, Iterable, List, Mapping, Tuple, Union, get_args import typing_extensions from typing_extensions import get_origin @@ -114,10 +114,12 @@ def validate(c): def unmarshal_json(raw, typ: Any) -> Any: - return unmarshal(from_json(raw), typ) + return unmarshal(from_json(raw), typ, coerce_iterables=False) -def unmarshal(val, typ: Any) -> Any: +def unmarshal(val, typ: Any, coerce_iterables: bool = True) -> Any: + if coerce_iterables: + val = _coerce_iterables_for_type(val, typ) unmarshaller = create_model( "Unmarshaller", body=(typ, ...), @@ -194,9 +196,88 @@ def get_pydantic_model(data: Any, typ: Any) -> Any: if not _contains_pydantic_model(data): return unmarshal(data, typ) + return _coerce_iterables_for_type(data, typ) + + +def _coerce_iterables_for_type(data: Any, typ: Any) -> Any: + if data is None or isinstance(data, (BaseModel, Unset)): + return data + + typ = _resolve_type_alias(typ) + origin = get_origin(typ) + + if _is_annotated_type(origin): + args = get_args(typ) + return _coerce_iterables_for_type(data, args[0]) if args else data + + if is_union(origin): + for arg in (arg for arg in get_args(typ) if arg is not type(None)): + coerced = _coerce_iterables_for_type(data, arg) + if coerced is not data: + return coerced + return data + + if _is_list_type(typ): + item_type = get_args(typ)[0] if get_args(typ) else Any + if isinstance(data, (str, bytes, bytearray, Mapping)): + return data + if isinstance(data, Iterable): + return [_coerce_iterables_for_type(item, item_type) for item in data] + return data + + if _is_mapping_type(typ): + value_type = get_args(typ)[1] if len(get_args(typ)) > 1 else Any + if isinstance(data, Mapping): + return { + key: _coerce_iterables_for_type(value, value_type) + for key, value in data.items() + } + return data + + if _is_pydantic_model_type(typ) and isinstance(data, Mapping): + coerced = None + for field_name, field in typ.model_fields.items(): + field_type = field.annotation + for key in (field_name, field.alias): + if key is not None and key in data: + value = data[key] if coerced is None else coerced[key] + coerced_value = _coerce_iterables_for_type(value, field_type) + if coerced_value is not value: + if coerced is None: + coerced = dict(data) + coerced[key] = coerced_value + return coerced if coerced is not None else data + return data +def _resolve_type_alias(typ: Any) -> Any: + return getattr(typ, "__value__", typ) + + +def _is_annotated_type(origin: Any) -> bool: + return any( + origin is typing_obj + for typing_obj in _get_typing_objects_by_name_of("Annotated") + ) + + +def _is_list_type(typ: Any) -> bool: + typ = _resolve_type_alias(typ) + return typ is list or get_origin(typ) is list + + +def _is_mapping_type(typ: Any) -> bool: + typ = _resolve_type_alias(typ) + origin = get_origin(typ) + mapping_origin = get_origin(Mapping[Any, Any]) + return typ in (dict, Dict, Mapping) or origin in (dict, Mapping, mapping_origin) + + +def _is_pydantic_model_type(typ: Any) -> bool: + return isinstance(typ, type) and issubclass(typ, BaseModel) + + def _contains_pydantic_model(data: Any) -> bool: if isinstance(data, BaseModel): return True diff --git a/uv.lock b/uv.lock index 9cd3ff4..32feaa8 100644 --- a/uv.lock +++ b/uv.lock @@ -543,7 +543,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -554,9 +554,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -638,7 +638,7 @@ wheels = [ [[package]] name = "ttd-data" -version = "0.2.2" +version = "0.2.3" source = { editable = "." } dependencies = [ { name = "httpcore" },