diff --git a/.gitignore b/.gitignore index 2812c15..cd7bbf5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,8 @@ restructure.sh .agents .claude .cursor +# Impala subgraph SDL used by scripts/validate_graphql.py and the offline +# schema test. Never committed: it carries internal feature-flag/permission +# names, tenant-specific policy, and staff email addresses from deprecation +# directives. Keep it local, or point TTD_GRAPHQL_SCHEMA_PATH elsewhere. +impala.graphql diff --git a/.speakeasy/gen.lock b/.speakeasy/gen.lock index 4cfc9cb..1d74102 100644 --- a/.speakeasy/gen.lock +++ b/.speakeasy/gen.lock @@ -3,22 +3,22 @@ id: 1927f304-b110-462d-9e13-326cfa243f23 management: docChecksum: d8ab6b95255a18e53aa5e5a1f8ff52b0 docVersion: v0.1 - speakeasyVersion: 1.794.0 - generationVersion: 2.930.0 - releaseVersion: 0.2.7 - configChecksum: 3e005f2c6958dc23cad0cb65b91d35e1 + speakeasyVersion: 1.795.3 + generationVersion: 2.932.9 + releaseVersion: 0.2.9 + configChecksum: 153e54c090c5a414beef76fce7f51742 repoURL: https://github.com/thetradedesk/ttd-data-python.git installationURL: https://github.com/thetradedesk/ttd-data-python.git published: true persistentEdits: - generation_id: cfa7ff55-d2ad-4d35-8bc4-ed4e49274c63 - pristine_commit_hash: 1c1798b01807040436e88f60bc96d3832305800b - pristine_tree_hash: 6061fee8fcd73d260b64df3269dea75c27a3a336 + generation_id: b86c8610-31b4-487d-abf3-5e92c8bd81ef + pristine_commit_hash: 5c15ec9724cdd187cd993a0ead507987dbab6f4d + pristine_tree_hash: 8e995a0bbf8b5a158ff9322d2e1c65496845b2c3 features: python: additionalDependencies: 1.1.0 constsAndDefaults: 1.0.7 - core: 6.0.36 + core: 6.1.0 defaultEnabledRetries: 0.2.0 devContainers: 3.0.0 enumUnions: 0.1.1 @@ -32,7 +32,7 @@ features: nameOverrides: 3.0.3 nullables: 1.0.2 responseFormat: 1.1.0 - retries: 3.0.7 + retries: 3.1.0 sdkHooks: 1.3.0 trackedFiles: .devcontainer/devcontainer.json: @@ -51,7 +51,6 @@ trackedFiles: id: 89aa447020cd last_write_checksum: sha1:f84632c81029fcdda8c3b0c768d02b836fc80526 pristine_git_object: 8d79f0abb72526f1fb34a4c03e5bba612c6ba2ae - deleted: true USAGE.md: id: 3aed33ce6e6f last_write_checksum: sha1:a00281d1a5789209e12b9ca30653cf208599b71a @@ -326,8 +325,8 @@ trackedFiles: pristine_git_object: f456032107a9387ba6c98afd1c981df2f4b3d636 pyproject.toml: id: 5d07e7d72637 - last_write_checksum: sha1:66c9c417edfe4081f5c9a313dba7d05cd2426b51 - pristine_git_object: f5321c4b4113ad3f9250b6e9a5a9dc4a41c9979c + last_write_checksum: sha1:1f6edfda2b5870e72c79377bc617bcfeb7a673b7 + pristine_git_object: 53494ea96222f68d09882cee12d16bf2946eb3c9 scripts/prepare_readme.py: id: e0c5957a6035 last_write_checksum: sha1:5a79be5a1346a05f9099f1177f4b3605849c3b6b @@ -354,8 +353,8 @@ trackedFiles: pristine_git_object: 4d1beeaa986919cd72ba7191f1b01a5670b3f38c src/ttd_data/_version.py: id: 7feb4586507e - last_write_checksum: sha1:2d1ae82a991adcc1e6382392325499fe967d293a - pristine_git_object: dfefd69bdfafe2d0380cfb6f1aa73cb91a5b78bc + last_write_checksum: sha1:5f0609135261ccfa443650f9554b95f17eab29ea + pristine_git_object: c1cc0be08a9b93b2d996aa0c1889bfc8863102c4 src/ttd_data/advertiser.py: id: 392ead635b4f last_write_checksum: sha1:db4154de6127aac2fc36c05a5561f49e349fbe84 @@ -706,8 +705,8 @@ trackedFiles: pristine_git_object: 5bc70856007fcb6807f1799f8a7900ae7a58aa1a src/ttd_data/utils/retries.py: id: 04accebbe68a - last_write_checksum: sha1:72b69874b569187013180f4ee1729e033016aa57 - pristine_git_object: 5cef7c40b61d7110bb49931d20ba1a8da02769a8 + last_write_checksum: sha1:ec14524be2802965a615b7a57d5d2bfe2149968f + pristine_git_object: f20abf23165385c311373cd59acafdbb26b82f01 src/ttd_data/utils/security.py: id: e38af000ccc5 last_write_checksum: sha1:853c8f9f500bcffaf6880e27feafaab0e9146da8 @@ -726,8 +725,8 @@ trackedFiles: pristine_git_object: 178fae653a8b5c5e166f124ad4ce590f87085cb9 src/ttd_data/utils/values.py: id: 45979a7770a1 - last_write_checksum: sha1:3a4ec34b2221dd825f7432e2d913b21d8ff92506 - pristine_git_object: 53c869685607f3756168d82e0cfaa581d333efbc + last_write_checksum: sha1:430ffa8f56b20db092f98952b2f4c909fbcee6f0 + pristine_git_object: a75c6e1622dcdc3eeef51fc51acbcb8f5a190ac2 examples: IngestAdvertiserData: speakeasy-default-ingest-advertiser-data: @@ -827,4 +826,3 @@ examples: application/json: {} examplesVersion: 1.0.2 generatedTests: {} -releaseNotes: "## Python SDK Changes:\n* `base_data_client.ip_address.ingest_first_party_ip_address_data()`: **Added**\n* `base_data_client.ip_address.ingest_third_party_ip_address_data()`: **Added**\n* `base_data_client.advertiser.ingest_advertiser_data()`: \n * `request.items[].real_id` **Added**\n* `base_data_client.third_party.ingest_third_party_data()`: \n * `request.items[].real_id` **Added**\n* `base_data_client.deletion_opt_out.data_subject_request_advertiser_data()`: \n * `request.items[].real_id` **Added**\n* `base_data_client.deletion_opt_out.data_subject_request_merchant_data()`: \n * `request.items[].real_id` **Added**\n* `base_data_client.deletion_opt_out.data_subject_request_third_party_data()`: \n * `request.items[].real_id` **Added**\n" diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml index 4129cd7..d5385fd 100644 --- a/.speakeasy/gen.yaml +++ b/.speakeasy/gen.yaml @@ -35,7 +35,7 @@ generation: generateNewTests: false skipResponseBodyAssertions: false python: - version: 0.2.7 + version: 0.2.9 additionalDependencies: dev: {} main: @@ -70,6 +70,7 @@ python: flatteningOrder: parameters-first forwardCompatibleEnumsByDefault: false forwardCompatibleUnionsByDefault: "false" + httpClientLibrary: httpx imports: option: openapi paths: diff --git a/.speakeasy/workflow.lock b/.speakeasy/workflow.lock index b12a998..7d45b16 100644 --- a/.speakeasy/workflow.lock +++ b/.speakeasy/workflow.lock @@ -1,4 +1,4 @@ -speakeasyVersion: 1.794.0 +speakeasyVersion: 1.795.3 sources: Data API: sourceNamespace: data-api @@ -21,7 +21,7 @@ targets: sourceRevisionDigest: sha256:c3853f233352223392fd1936aa89274767fa404b4cfcf730b2c1bf6b14e6bdd0 sourceBlobDigest: sha256:bacb5bab3c306ab125fc18801edfd0dcb025c0ea7f2000245711d24f91e5809c codeSamplesNamespace: data-api-python-code-samples - codeSamplesRevisionDigest: sha256:cee102bdae52db657cbcb3c1ef5c51fed24f5d3e4cf3c22906d1083a41198cdb + codeSamplesRevisionDigest: sha256:072b979485d922bb1c0bbf92c4a584b930d91e8d800dd192b426c4c09a53f762 data-api-local: source: Data API Local sourceNamespace: data-api-local diff --git a/README-PYPI.md b/README-PYPI.md index 13feb98..104670c 100644 --- a/README-PYPI.md +++ b/README-PYPI.md @@ -335,6 +335,57 @@ async def main(): asyncio.run(main()) ``` +### 9. Third-Party Data Taxonomy (GraphQL) + +Taxonomy operations run as GraphQL requests against the Platform API supergraph. They live under `client.third_party_taxonomy`, and the platform token is passed per call as `ttd_auth`. + +| SDK function | REST equivalent | What it does | +| --- | --- | --- | +| `query_segments(...)` | [`POST /v3/thirdpartydata/query`](https://open.thetradedesk.com/provider/docsApp/GuidesProvider/audience/ref/post-thirdpartydata-query) | Lists a provider's segments, optionally filtered by element ID. | +| `query_segment_taxonomy_status(...)` | [`GET /v3/thirdpartydata/status/{providerId}/{providerElementId}`](https://open.thetradedesk.com/provider/docsApp/GuidesProvider/audience/ref/get-thirdpartydata-status-providerid-providerelementid) | Returns one segment's taxonomy approval status. | +| `upsert_segments(...)` | [`POST`](https://open.thetradedesk.com/provider/docsApp/GuidesProvider/audience/ref/post-thirdpartydata) / [`PUT /v3/thirdpartydata`](https://open.thetradedesk.com/provider/docsApp/GuidesProvider/audience/ref/put-thirdpartydata) | Creates or updates up to 1000 segments in one call. | +| `client.graphql.execute(...)` | — | Sends any GraphQL document and returns the parsed response body. | + +```python +from ttd_data import DataClient + +client = DataClient() + +# List segments. Paginate with page.end_cursor while page.has_next_page. +page = client.third_party_taxonomy.query_segments( + ttd_auth=TTD_AUTH_TOKEN, provider_id=PROVIDER_ID, first=100 +) +for node in page.nodes: + print(node["providerElementId"], node["taxonomyApprovalStatus"]) + +# One segment's approval status: APPROVED, DENIED, PENDING, NOT_IN_QUEUE, or None. +status = client.third_party_taxonomy.query_segment_taxonomy_status( + ttd_auth=TTD_AUTH_TOKEN, provider_id=PROVIDER_ID, provider_element_id="auto/in-market/ev" +) + +# Create or update. The server decides per segment and reports it as `mode`. +result = client.third_party_taxonomy.upsert_segments( + ttd_auth=TTD_AUTH_TOKEN, + segments=[ + { + "providerId": PROVIDER_ID, + "providerElementId": "auto/in-market/ev", + "displayName": "Interest > Auto > In-Market > EV", + "parentElementId": "ROOT", + "buyable": True, + } + ], +) +for entry in result.succeeded: + print(entry["mode"], entry["segment"]["id"]) # CREATE or UPDATE +if result.failed: + print(result.failed) # a batch can partially succeed; always check this +``` + +Each segment is a `SegmentInput`. `providerId` and `providerElementId` are always required; `displayName`, `parentElementId` and `buyable` are additionally required when creating. Keys are camelCase to match the schema, and omitting a key leaves that field unchanged on update. + +The supergraph reports authorization and policy failures as HTTP 200 with a top-level `errors` array, so those raise `GraphQLError` — which, like `APIError`, derives from `DataError`, so one `except DataError` covers the REST and GraphQL suites. Retries use the same configuration as the REST operations. + diff --git a/README.md b/README.md index ee957d6..f50c97f 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,57 @@ async def main(): asyncio.run(main()) ``` +### 9. Third-Party Data Taxonomy (GraphQL) + +Taxonomy operations run as GraphQL requests against the Platform API supergraph. They live under `client.third_party_taxonomy`, and the platform token is passed per call as `ttd_auth`. + +| SDK function | REST equivalent | What it does | +| --- | --- | --- | +| `query_segments(...)` | [`POST /v3/thirdpartydata/query`](https://open.thetradedesk.com/provider/docsApp/GuidesProvider/audience/ref/post-thirdpartydata-query) | Lists a provider's segments, optionally filtered by element ID. | +| `query_segment_taxonomy_status(...)` | [`GET /v3/thirdpartydata/status/{providerId}/{providerElementId}`](https://open.thetradedesk.com/provider/docsApp/GuidesProvider/audience/ref/get-thirdpartydata-status-providerid-providerelementid) | Returns one segment's taxonomy approval status. | +| `upsert_segments(...)` | [`POST`](https://open.thetradedesk.com/provider/docsApp/GuidesProvider/audience/ref/post-thirdpartydata) / [`PUT /v3/thirdpartydata`](https://open.thetradedesk.com/provider/docsApp/GuidesProvider/audience/ref/put-thirdpartydata) | Creates or updates up to 1000 segments in one call. | +| `client.graphql.execute(...)` | — | Sends any GraphQL document and returns the parsed response body. | + +```python +from ttd_data import DataClient + +client = DataClient() + +# List segments. Paginate with page.end_cursor while page.has_next_page. +page = client.third_party_taxonomy.query_segments( + ttd_auth=TTD_AUTH_TOKEN, provider_id=PROVIDER_ID, first=100 +) +for node in page.nodes: + print(node["providerElementId"], node["taxonomyApprovalStatus"]) + +# One segment's approval status: APPROVED, DENIED, PENDING, NOT_IN_QUEUE, or None. +status = client.third_party_taxonomy.query_segment_taxonomy_status( + ttd_auth=TTD_AUTH_TOKEN, provider_id=PROVIDER_ID, provider_element_id="auto/in-market/ev" +) + +# Create or update. The server decides per segment and reports it as `mode`. +result = client.third_party_taxonomy.upsert_segments( + ttd_auth=TTD_AUTH_TOKEN, + segments=[ + { + "providerId": PROVIDER_ID, + "providerElementId": "auto/in-market/ev", + "displayName": "Interest > Auto > In-Market > EV", + "parentElementId": "ROOT", + "buyable": True, + } + ], +) +for entry in result.succeeded: + print(entry["mode"], entry["segment"]["id"]) # CREATE or UPDATE +if result.failed: + print(result.failed) # a batch can partially succeed; always check this +``` + +Each segment is a `SegmentInput`. `providerId` and `providerElementId` are always required; `displayName`, `parentElementId` and `buyable` are additionally required when creating. Keys are camelCase to match the schema, and omitting a key leaves that field unchanged on update. + +The supergraph reports authorization and policy failures as HTTP 200 with a top-level `errors` array, so those raise `GraphQLError` — which, like `APIError`, derives from `DataError`, so one `except DataError` covers the REST and GraphQL suites. Retries use the same configuration as the REST operations. + diff --git a/examples/graphql_example.py b/examples/graphql_example.py new file mode 100644 index 0000000..3da88b1 --- /dev/null +++ b/examples/graphql_example.py @@ -0,0 +1,123 @@ +"""Example: third-party data taxonomy GraphQL operations via +ttd-data-python's DataClient. + + TTD_AUTH_TOKEN=... required. Platform token, sent as `TTD-Auth`. + GRAPHQL_EXAMPLE_PROVIDER_ID=... required. Provider to operate on. + GRAPHQL_EXAMPLE_ELEMENT_ID=... optional. Segment to operate on. + +Nothing is guessed: every operation acts on exactly what you name. Without +GRAPHQL_EXAMPLE_ELEMENT_ID the file lists segments and runs the escape-hatch +query only. With it, it also reads that segment's approval status, filters to +it, and upserts it — which creates or updates it in the provider's taxonomy. + + TTD_AUTH_TOKEN=... GRAPHQL_EXAMPLE_PROVIDER_ID=... \ + python examples/graphql_example.py +""" + +import json +import os + +from ttd_data import DataClient +from ttd_data.graphql import Page + + +def required(name: str, description: str) -> str: + value = os.getenv(name, "").strip() + if not value: + raise SystemExit(f"Set {name} to {description}.") + return value + + +token = required("TTD_AUTH_TOKEN", "a platform token") +PROVIDER_ID = required("GRAPHQL_EXAMPLE_PROVIDER_ID", "the provider to operate on") + +# Optional: the upsert is skipped entirely when this is unset. +ELEMENT_ID = os.getenv("GRAPHQL_EXAMPLE_ELEMENT_ID", "").strip() + +# The token is passed per call, not baked into the client. +client = DataClient() + + +def show(label: str, page: Page) -> None: + print(f"\n{'=' * 60}\n {label} ({page.total_count} total)\n{'=' * 60}") + print(json.dumps(page.nodes, indent=2)) + + +# --------------------------------------------------------------------------- +# Taxonomy +# --------------------------------------------------------------------------- + +segments = client.third_party_taxonomy.query_segments( + ttd_auth=token, provider_id=PROVIDER_ID, first=5 +) +show("Segments for provider", segments) + +if segments.has_next_page: + show( + "Segments (next page)", + client.third_party_taxonomy.query_segments( + ttd_auth=token, + provider_id=PROVIDER_ID, + first=5, + after=segments.end_cursor, + ), + ) + +if not ELEMENT_ID: + print("\nSkipping status and filter (set GRAPHQL_EXAMPLE_ELEMENT_ID to run them).") +else: + status = client.third_party_taxonomy.query_segment_taxonomy_status( + ttd_auth=token, provider_id=PROVIDER_ID, provider_element_id=ELEMENT_ID + ) + print(f"\nTaxonomy approval status for {ELEMENT_ID}: {status}") + show( + f"Segments filtered to {ELEMENT_ID}", + client.third_party_taxonomy.query_segments( + ttd_auth=token, provider_id=PROVIDER_ID, provider_element_ids=[ELEMENT_ID] + ), + ) + +# --------------------------------------------------------------------------- +# Escape hatch: anything the typed methods do not cover +# --------------------------------------------------------------------------- + +# `execute` returns the raw body — the typed methods are what wrap it. +raw = client.graphql.execute( + ttd_auth=token, + query=""" + query ThirdPartyDataProvider($id: ID!) { + thirdPartyDataProvider(id: $id) { + id + name + } + } + """, + variables={"id": PROVIDER_ID}, +) +print(f"\n{'=' * 60}\n Arbitrary query via execute()\n{'=' * 60}") +print(json.dumps(raw.get("data"), indent=2)) + +# --------------------------------------------------------------------------- +# Mutation — this writes to the provider's taxonomy +# --------------------------------------------------------------------------- + +if not ELEMENT_ID: + print("\nSkipping upsert (set GRAPHQL_EXAMPLE_ELEMENT_ID to run it).") +else: + result = client.third_party_taxonomy.upsert_segments( + ttd_auth=token, + segments=[ + { + "providerId": PROVIDER_ID, + "providerElementId": ELEMENT_ID, + "displayName": "Example > SDK Test Segment", + "parentElementId": "ROOT", + "buyable": True, + "description": "Created by examples/graphql_example.py", + } + ], + ) + print(f"\n{'=' * 60}\n Upsert segment\n{'=' * 60}") + print(f"accepted: {json.dumps(result.succeeded, indent=2)}") + if result.failed: + print(f"rejected: {json.dumps(result.failed, indent=2)}") diff --git a/pyproject.toml b/pyproject.toml index 8e50611..7d3401a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ttd-data" -version = "0.2.7" +version = "0.2.9" description = "Python Client SDK for TTD Data API." authors = [{ name = "Speakeasy" },] readme = "README-PYPI.md" @@ -16,6 +16,7 @@ license = { text = "map[name:Apache License 2.0 shortName:Apache-2.0 url:https:/ [dependency-groups] dev = [ + "graphql-core >=3.2.0,<4.0.0", "mypy ==1.15.0", "pylint ==3.2.3", "pyright ==1.1.398", diff --git a/scripts/validate_graphql.py b/scripts/validate_graphql.py new file mode 100755 index 0000000..3649b18 --- /dev/null +++ b/scripts/validate_graphql.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python +"""Validate every GraphQL document the SDK sends against the live schema. + +Introspects the Platform API supergraph and validates each document in +`ttd_data.graphql.QUERY_DOCUMENTS`. Requires a platform credential: + + TTD_AUTH_TOKEN=... python scripts/validate_graphql.py + +Introspection needs authentication (the endpoint returns 401 without it), so +this runs on demand rather than as a pull-request check. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import Dict, Tuple + +import httpx +from graphql import build_client_schema, get_introspection_query, parse, print_schema, validate + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +# pylint: disable=wrong-import-position # sys.path is set up just above +from ttd_data.graphql import QUERY_DOCUMENTS # noqa: E402 +from ttd_data.graphql import GraphQLTransport # noqa: E402 + +HTTP_TIMEOUT_S = 60.0 + + +def resolve_auth_headers() -> Tuple[Dict[str, str], str]: + """Resolve credentials from the environment. Never returns the token value + in the description.""" + bearer = os.environ.get("BEARER_TOKEN", "").strip() + if bearer: + value = bearer if bearer.lower().startswith("bearer ") else f"Bearer {bearer}" + return {"Authorization": value}, "BEARER_TOKEN" + ttd_auth = os.environ.get("TTD_AUTH_TOKEN", "").strip() + if ttd_auth: + return {"TTD-Auth": ttd_auth}, "TTD_AUTH_TOKEN" + return {}, "none" + + +def fetch_schema(url: str, headers: Dict[str, str]): + """POST the introspection query and build a client schema from the result.""" + query = get_introspection_query(descriptions=False, directive_is_repeatable=True) + try: + response = httpx.post( + url, + json={"query": query}, + headers={"Content-Type": "application/json", **headers}, + timeout=HTTP_TIMEOUT_S, + ) + except httpx.HTTPError as exc: + sys.exit(f"Failed to reach {url}: {exc}. Check your network/VPN connection.") + + if response.status_code in (401, 403): + sys.exit(f"Auth rejected ({response.status_code}) by {url}. Refresh your token and retry.") + if response.status_code != 200: + sys.exit(f"Introspection failed: HTTP {response.status_code} from {url}.") + + body = response.json() + if body.get("errors"): + sys.exit(f"Introspection returned errors: {body['errors']}") + schema_root = (body.get("data") or {}).get("__schema") + if not schema_root: + sys.exit(f"Response from {url} contained no '__schema'.") + return build_client_schema({"__schema": schema_root}) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--url", default=GraphQLTransport.DEFAULT_ENDPOINT, help="GraphQL endpoint") + parser.add_argument("--write-sdl", type=Path, help="Also write the composed SDL here") + args = parser.parse_args() + + headers, source = resolve_auth_headers() + if not headers: + sys.exit("No credentials found. Set TTD_AUTH_TOKEN or BEARER_TOKEN.") + + print(f"Introspecting {args.url} (auth from {source})...", flush=True) + schema = fetch_schema(args.url, headers) + + if args.write_sdl: + args.write_sdl.parent.mkdir(parents=True, exist_ok=True) + args.write_sdl.write_text(print_schema(schema), encoding="utf-8") + print(f"Wrote SDL to {args.write_sdl}") + + failed = 0 + for name, document in sorted(QUERY_DOCUMENTS.items()): + errors = validate(schema, parse(document)) + if errors: + failed += 1 + print(f"\nFAIL {name}") + for error in errors: + print(f" {error.message}") + if error.locations: + location = error.locations[0] + print(f" at line {location.line}, column {location.column}") + else: + print(f"ok {name}") + + total = len(QUERY_DOCUMENTS) + if failed: + print(f"\n{failed}/{total} documents failed validation.") + return 1 + print(f"\nAll {total} documents valid.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ttd_data/_version.py b/src/ttd_data/_version.py index dfefd69..c1cc0be 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.7" +__version__: str = "0.2.9" __openapi_doc_version__: str = "v0.1" -__gen_version__: str = "2.930.0" -__user_agent__: str = "speakeasy-sdk/python 0.2.7 2.930.0 v0.1 ttd-data" +__gen_version__: str = "2.932.9" +__user_agent__: str = "speakeasy-sdk/python 0.2.9 2.932.9 v0.1 ttd-data" try: if __package__ is not None: diff --git a/src/ttd_data/client.py b/src/ttd_data/client.py index 0368110..cae32f3 100644 --- a/src/ttd_data/client.py +++ b/src/ttd_data/client.py @@ -12,6 +12,7 @@ from uid2_client import IdentityMapV3Client, IdentityMapV3Input # type: ignore[import-not-found,import-untyped] +from ttd_data.graphql import GraphQLTransport, TaxonomyOperations from ttd_data.sdk import BaseDataClient from ttd_data.types import BaseModel, OptionalNullable from ttd_data.utils import RetryConfig @@ -59,6 +60,7 @@ class ClientConfig: retry_config: OptionalNullable[RetryConfig] timeout_ms: Optional[int] uid2_config: Optional[UID2Config] + graphql_server_url: Optional[str] class DataClient: @@ -76,10 +78,12 @@ def __init__( self, uid2_config: Optional[UID2Config] = None, data_client: Optional[BaseDataClient] = None, + graphql_server_url: Optional[str] = None, **data_client_kwargs: Any, ) -> None: self.uid2_config = uid2_config self.data_client = data_client or BaseDataClient(**data_client_kwargs) + self._graphql_server_url = graphql_server_url @property def config(self) -> ClientConfig: @@ -91,6 +95,7 @@ def config(self) -> ClientConfig: retry_config=sdk_config.retry_config, timeout_ms=sdk_config.timeout_ms, uid2_config=self.uid2_config, + graphql_server_url=self._graphql_server_url, ) @classmethod @@ -296,6 +301,34 @@ def offline_conversion(self) -> "_OfflineConversionProxy": def deletion_opt_out(self) -> "_DeletionOptOutProxy": return _DeletionOptOutProxy(self) + @cached_property + def _graphql_transport(self) -> GraphQLTransport: + """Shared GraphQL transport. Reuses the REST client's httpx client, so + both suites share one connection pool.""" + sdk_config = self.data_client.sdk_configuration + return GraphQLTransport( + server_url=self._graphql_server_url, + client=sdk_config.client, + retry_config=sdk_config.retry_config, + timeout_ms=sdk_config.timeout_ms, + debug_logger=sdk_config.debug_logger, + ) + + @cached_property + def third_party_taxonomy(self) -> TaxonomyOperations: + """Third-party data taxonomy operations over GraphQL: segment upsert, + segment query, and taxonomy approval status. + + Not part of the UID2 pipeline: these operate on segment metadata, + never on user identifiers.""" + return TaxonomyOperations(self._graphql_transport) + + @cached_property + def graphql(self) -> GraphQLTransport: + """Escape hatch for GraphQL operations the typed namespaces above do + not cover. Send a document with `client.graphql.execute(...)`.""" + return self._graphql_transport + # ----- Pass-through for any sub-SDK without a UID2 wrapper ----- def __getattr__(self, name: str) -> Any: diff --git a/src/ttd_data/graphql/__init__.py b/src/ttd_data/graphql/__init__.py new file mode 100644 index 0000000..2e61800 --- /dev/null +++ b/src/ttd_data/graphql/__init__.py @@ -0,0 +1,18 @@ +from ttd_data.graphql._response import GraphQLError, Page, UpsertResult +from ttd_data.graphql._transport import GraphQLTransport +from ttd_data.graphql.taxonomy import QUERY_DOCUMENTS as _TAXONOMY_DOCUMENTS +from ttd_data.graphql.taxonomy import SegmentInput, TaxonomyOperations + +# Every document the typed methods send, keyed by method name. The schema +# validator reads this so it can never drift from what callers actually send. +QUERY_DOCUMENTS = dict(_TAXONOMY_DOCUMENTS) + +__all__ = [ + "GraphQLError", + "GraphQLTransport", + "Page", + "SegmentInput", + "TaxonomyOperations", + "UpsertResult", + "QUERY_DOCUMENTS", +] diff --git a/src/ttd_data/graphql/_response.py b/src/ttd_data/graphql/_response.py new file mode 100644 index 0000000..9947242 --- /dev/null +++ b/src/ttd_data/graphql/_response.py @@ -0,0 +1,100 @@ +"""Response envelopes for the typed GraphQL operations. + +These model the *envelope* — the `data`/`errors` wrapper and the Relay +connection shape — not the fields inside each node. The envelope is fixed by +the Relay spec and by our own query documents, so it cannot drift when the +schema gains or renames a segment field; node contents stay plain dicts, and +`raw` always carries the untouched body. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import httpx + +from ttd_data.errors import DataError + + +class GraphQLError(DataError): + """Top-level `errors` in a GraphQL response. + + Raised even on HTTP 200, which is how the supergraph reports authorization + and policy failures. `data` carries whatever did resolve, since GraphQL can + return both. Derives from `DataError` so one `except` covers both suites. + """ + + def __init__( + self, + errors: List[Dict[str, Any]], + raw_response: httpx.Response, + data: Optional[Dict[str, Any]] = None, + ) -> None: + messages = "; ".join(e.get("message", "") for e in errors) or "unknown error" + super().__init__(messages, raw_response) + self.errors = errors + self.data = data + + +@dataclass(frozen=True) +class Page: + """One page of a Relay connection.""" + + nodes: List[Dict[str, Any]] = field(default_factory=list) + total_count: Optional[int] = None + end_cursor: Optional[str] = None + has_next_page: bool = False + raw: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class UpsertResult: + """Outcome of a segment upsert, which succeeds per segment rather than + all-or-nothing. `failed` being non-empty is the only signal that part of + the batch was rejected. + """ + + succeeded: List[Dict[str, Any]] = field(default_factory=list) + """Accepted segments, each entry carrying `mode` (CREATE/UPDATE) and `segment`.""" + + failed: List[Dict[str, Any]] = field(default_factory=list) + """Per-segment rejections, each carrying a `reason` and the identifiers.""" + + raw: Dict[str, Any] = field(default_factory=dict) + + +def _resolve(raw: Dict[str, Any], *path: str) -> Dict[str, Any]: + """Walk `path` under `data`, yielding `{}` at the first missing or null link.""" + node: Any = raw.get("data") or {} + for key in path: + node = (node or {}).get(key) or {} + return node + + +def build_page(raw: Dict[str, Any], *path: str) -> Page: + """Unwrap the connection at `path` under `data` into a `Page`. + + `path` mirrors the selection in the document that produced `raw`; a missing + or null link along it yields an empty page rather than raising, so a body + that does not match the document degrades instead of throwing. Denials do + not arrive this way: the connection fields are non-null, so they surface as + top-level errors and raise in the transport. + """ + node = _resolve(raw, *path) + page_info = node.get("pageInfo") or {} + return Page( + nodes=node.get("nodes") or [], + total_count=node.get("totalCount"), + end_cursor=page_info.get("endCursor"), + has_next_page=bool(page_info.get("hasNextPage")), + raw=raw, + ) + + +def build_upsert_result(raw: Dict[str, Any], *path: str) -> UpsertResult: + """Split the mutation payload at `path` into accepted and rejected entries.""" + payload = _resolve(raw, *path) + return UpsertResult( + succeeded=payload.get("data") or [], + failed=payload.get("errors") or [], + raw=raw, + ) diff --git a/src/ttd_data/graphql/_transport.py b/src/ttd_data/graphql/_transport.py new file mode 100644 index 0000000..e6156c6 --- /dev/null +++ b/src/ttd_data/graphql/_transport.py @@ -0,0 +1,153 @@ +"""GraphQL request execution against the TTD Platform API supergraph. + +Knows nothing about any particular domain: the operation classes hold one of +these and call `execute`. +""" + +from typing import Any, Dict, Final, List, Mapping, Optional + +import httpx +from ttd_data import utils +from ttd_data.errors import APIError +from ttd_data.graphql._response import GraphQLError +from ttd_data.httpclient import HttpClient +from ttd_data.sdkconfiguration import SDKConfiguration +from ttd_data.types import OptionalNullable, UNSET +from ttd_data.utils import RetryConfig +from ttd_data.utils.logger import Logger, get_default_logger + + +AUTH_HEADER: Final = "TTD-Auth" + +# Same set the generated REST operations retry on. +RETRYABLE_STATUS_CODES: Final[List[str]] = ["429", "500", "502", "503", "504"] + + +def _redacted(headers: Mapping[str, str]) -> Dict[str, str]: + """Headers with the credential masked, for logging.""" + return { + key: ("***" if key.lower() == AUTH_HEADER.lower() else value) + for key, value in headers.items() + } + + +class GraphQLTransport: + """Sends GraphQL documents and returns the parsed response body. Use it + directly for any operation the typed operation classes do not cover. + """ + + sdk_configuration: SDKConfiguration + + DEFAULT_ENDPOINT = "https://api.gen.adsrvr.org/graphql" + + def __init__( + self, + server_url: Optional[str] = None, + client: Optional[HttpClient] = None, + retry_config: OptionalNullable[RetryConfig] = UNSET, + timeout_ms: Optional[int] = None, + debug_logger: Optional[Logger] = None, + ) -> None: + client_supplied = client is not None + self.sdk_configuration = SDKConfiguration( + client=client or httpx.Client(follow_redirects=True), + client_supplied=client_supplied, + async_client=None, + async_client_supplied=False, + server_url=server_url or self.DEFAULT_ENDPOINT, + retry_config=retry_config, + timeout_ms=timeout_ms, + debug_logger=debug_logger or get_default_logger(), + ) + + def execute( + self, + query: str, + *, + ttd_auth: str, + variables: Optional[Dict[str, Any]] = None, + retries: OptionalNullable[RetryConfig] = UNSET, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> Dict[str, Any]: + """ + Execute an arbitrary GraphQL query or mutation. + + POSTs the request and returns the parsed JSON body. + + :param query: Full GraphQL query/mutation document. + :param ttd_auth: Platform API token. Sent as the `TTD-Auth` header, and + supplied per call so one client can serve several tokens and a + rotated token takes effect immediately. + :param variables: GraphQL variables referenced by the query. + :param retries: Override the client's retry configuration for this call. + :raises ValueError: `ttd_auth` is empty. + :raises GraphQLError: The response carried top-level `errors`. The + supergraph reports authorization and policy failures this way, with + HTTP 200, so this is the usual failure path rather than an edge case. + :raises APIError: Non-2xx response, matching the REST operations. + """ + if not ttd_auth: + raise ValueError("ttd_auth must be a non-empty platform API token") + + client = self.sdk_configuration.client + if client is None: + raise ValueError("client is required") + + headers = { + "Content-Type": "application/json", + "apollographql-client-name": "ttd-data-python", + } + if http_headers: + headers.update(http_headers) + # Last, so the typed credential always wins over a stray header entry. + headers[AUTH_HEADER] = ttd_auth + + effective_timeout_ms = timeout_ms or self.sdk_configuration.timeout_ms + timeout = ( + effective_timeout_ms / 1000 + if effective_timeout_ms is not None + else httpx.USE_CLIENT_DEFAULT + ) + + url = self.sdk_configuration.server_url or self.DEFAULT_ENDPOINT + request = client.build_request( + "POST", + url, + json={"query": query, "variables": variables or {}}, + headers=headers, + timeout=timeout, + ) + + if retries == UNSET and self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + logger = self.sdk_configuration.debug_logger + + def send() -> httpx.Response: + logger.debug( + "GraphQL request:\nURL: %s\nHeaders: %s\nBody: %s", + url, + _redacted(headers), + request.content, + ) + res = client.send(request) + logger.debug( + "GraphQL response:\nStatus Code: %s\nBody: %s", res.status_code, res.text + ) + return res + + if isinstance(retries, RetryConfig): + response = utils.retry( + send, utils.Retries(retries, RETRYABLE_STATUS_CODES) + ) + else: + response = send() + + if not response.is_success: + raise APIError("API error occurred", response, response.text) + + body = response.json() + if body.get("errors"): + raise GraphQLError(body["errors"], response, body.get("data")) + return body diff --git a/src/ttd_data/graphql/taxonomy.py b/src/ttd_data/graphql/taxonomy.py new file mode 100644 index 0000000..4f20fae --- /dev/null +++ b/src/ttd_data/graphql/taxonomy.py @@ -0,0 +1,294 @@ +"""Third-party data taxonomy operations: segment upsert, segment query, and +taxonomy approval status. +""" + +from typing import Any, Dict, Iterable, List, Mapping, Optional + +from typing_extensions import NotRequired, TypedDict + +from ttd_data.graphql._response import ( + Page, + UpsertResult, + build_page, + build_upsert_result, +) +from ttd_data.graphql._transport import GraphQLTransport +from ttd_data.types import OptionalNullable, UNSET +from ttd_data.utils import RetryConfig + +SEGMENTS_PATH = ("thirdPartyDataProvider", "thirdPartyTargetingDataSegments") +UPSERT_PATH = ("thirdPartyDataUpsert",) + +MAX_UPSERT_BATCH_SIZE = 1000 + + +class SegmentInput(TypedDict): + """A `ThirdPartyDataUpsertInput`. Keys are camelCase to match the schema, so + a segment goes to the server untouched. + + `displayName`, `parentElementId` and `buyable` are additionally required + when the segment is being created. `isDirectIPTargeting` is honoured on + create only; `parentElementId` and `subProviderId` are immutable once the + segment exists. + """ + + providerId: str + providerElementId: str + displayName: NotRequired[str] + parentElementId: NotRequired[str] + buyable: NotRequired[bool] + description: NotRequired[str] + isDirectIPTargeting: NotRequired[bool] + subProviderId: NotRequired[str] + +UPSERT_SEGMENTS = """ +mutation ThirdPartyDataUpsert($input: [ThirdPartyDataUpsertInput!]!) { + thirdPartyDataUpsert(input: $input) { + data { + mode + segment { + id + thirdPartyDataId + providerElementId + displayName + description + fullPath + buyable + taxonomyApprovalStatus + provider { + id + } + parent { + providerElementId + } + subProvider { + id + } + targetingDataInsights { + lastUpdatedAtUTC + activeCounts { + devices + households + persons + } + receivedCounts { + totalUserIdCount + } + } + } + } + errors { + __typename + ... on ThirdPartyDataUpsertOperationError { + mode + providerId + providerElementId + reason + message + field + } + ... on ThirdPartyDataUpsertBatchSizeError { + providedBatchSize + message + field + } + } + } +} +""" + +QUERY_SEGMENTS = """ +query QueryThirdPartyDataSegments( + $providerId: ID! + $first: Int + $after: String + $where: ThirdPartyTargetingDataFilterInput +) { + thirdPartyDataProvider(id: $providerId) { + thirdPartyTargetingDataSegments(first: $first, after: $after, where: $where) { + totalCount + nodes { + id + thirdPartyDataId + providerElementId + displayName + description + fullPath + buyable + taxonomyApprovalStatus + createdAt + subProvider { + id + } + parent { + providerElementId + } + targetingDataInsights { + lastUpdatedAtUTC + activeCounts { + devices + households + persons + } + receivedCounts { + totalUserIdCount + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +""" + +QUERY_TAXONOMY_STATUS = """ +query QueryThirdPartyDataTaxonomyStatus( + $providerId: ID! + $where: ThirdPartyTargetingDataFilterInput +) { + thirdPartyDataProvider(id: $providerId) { + thirdPartyTargetingDataSegments(first: 1, where: $where) { + nodes { + providerElementId + taxonomyApprovalStatus + } + } + } +} +""" + +QUERY_DOCUMENTS: Dict[str, str] = { + "upsert_segments": UPSERT_SEGMENTS, + "query_segments": QUERY_SEGMENTS, + "query_segment_taxonomy_status": QUERY_TAXONOMY_STATUS, +} + + +class TaxonomyOperations: + """Each method sends a fixed document with a fixed field selection; + arguments become GraphQL variables. + """ + + def __init__(self, transport: GraphQLTransport) -> None: + self._transport = transport + + def upsert_segments( + self, + *, + ttd_auth: str, + segments: List[SegmentInput], + retries: OptionalNullable[RetryConfig] = UNSET, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> UpsertResult: + """ + Create or update third-party data segments. The server decides per + segment whether the operation is a CREATE or an UPDATE, reported as + `mode` on each accepted entry. + + Partially succeeds, so a returned result is not proof the whole batch + landed — check `failed`. + + :param ttd_auth: Platform API token. + :param segments: Segments to create or update; omit a key to leave that + field unchanged. Pass a one-element list to upsert a single segment. + """ + if not 1 <= len(segments) <= MAX_UPSERT_BATCH_SIZE: + raise ValueError( + f"segments must contain between 1 and {MAX_UPSERT_BATCH_SIZE} " + f"entries, got {len(segments)}" + ) + return build_upsert_result( + self._transport.execute( + UPSERT_SEGMENTS, + ttd_auth=ttd_auth, + variables={"input": segments}, + retries=retries, + timeout_ms=timeout_ms, + http_headers=http_headers, + ), + *UPSERT_PATH, + ) + + def query_segments( + self, + *, + ttd_auth: str, + provider_id: str, + provider_element_ids: Optional[Iterable[str]] = None, + first: int = 1000, + after: Optional[str] = None, + retries: OptionalNullable[RetryConfig] = UNSET, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> Page: + """ + Query a provider's third-party data segments. + + :param ttd_auth: Platform API token. + :param provider_id: ThirdPartyDataProvider ID. + :param provider_element_ids: Restrict to these provider element IDs. + Omit to return every segment for the provider. + :param first: Page size, capped at 1000 by the schema. + :param after: Cursor to resume from (pass a previous `Page.end_cursor`). + """ + variables: Dict[str, Any] = { + "providerId": provider_id, + "first": first, + "after": after, + } + if provider_element_ids is not None: + variables["where"] = { + "providerElementId": {"in": list(provider_element_ids)} + } + return build_page( + self._transport.execute( + QUERY_SEGMENTS, + ttd_auth=ttd_auth, + variables=variables, + retries=retries, + timeout_ms=timeout_ms, + http_headers=http_headers, + ), + *SEGMENTS_PATH, + ) + + def query_segment_taxonomy_status( + self, + *, + ttd_auth: str, + provider_id: str, + provider_element_id: str, + retries: OptionalNullable[RetryConfig] = UNSET, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> Optional[str]: + """ + Query the taxonomy compliance approval status of one segment — + APPROVED, DENIED, PENDING or NOT_IN_QUEUE, matching the REST enum. + + Returns None when the provider has no such segment. Use + `query_segments` when you need the rest of the segment's fields. + + :param ttd_auth: Platform API token. + """ + page = build_page( + self._transport.execute( + QUERY_TAXONOMY_STATUS, + ttd_auth=ttd_auth, + variables={ + "providerId": provider_id, + "where": {"providerElementId": {"eq": provider_element_id}}, + }, + retries=retries, + timeout_ms=timeout_ms, + http_headers=http_headers, + ), + *SEGMENTS_PATH, + ) + if not page.nodes: + return None + return page.nodes[0].get("taxonomyApprovalStatus") diff --git a/src/ttd_data/utils/retries.py b/src/ttd_data/utils/retries.py index 5cef7c4..f20abf2 100644 --- a/src/ttd_data/utils/retries.py +++ b/src/ttd_data/utils/retries.py @@ -110,6 +110,18 @@ def __init__(self, inner: Exception): self.inner = inner +_TRANSPORT_ERROR_NAMES = frozenset({"NetworkError", "TimeoutException"}) +_TRANSPORT_ERROR_BASES = frozenset({"TransportError", "RequestError", "HTTPError"}) + + +def _is_transport_error(exception: BaseException) -> bool: + """Report whether an exception is a connection or timeout failure.""" + if isinstance(exception, (httpx.NetworkError, httpx.TimeoutException)): + return True + names = {base.__name__ for base in type(exception).__mro__} + return bool(names & _TRANSPORT_ERROR_NAMES) and _TRANSPORT_ERROR_BASES <= names + + def _parse_retry_after_header(response: httpx.Response) -> Optional[int]: """Parse Retry-After header from response. @@ -208,14 +220,15 @@ def do_request() -> httpx.Response: if res.status_code == parsed_code: raise TemporaryError(res) - except (httpx.NetworkError, httpx.TimeoutException) as exception: - if retries.config.retry_connection_errors: - raise - - raise PermanentError(exception) from exception except TemporaryError: raise except Exception as exception: + if ( + _is_transport_error(exception) + and retries.config.retry_connection_errors + ): + raise + raise PermanentError(exception) from exception return res @@ -253,14 +266,15 @@ async def do_request() -> httpx.Response: if res.status_code == parsed_code: raise TemporaryError(res) - except (httpx.NetworkError, httpx.TimeoutException) as exception: - if retries.config.retry_connection_errors: - raise - - raise PermanentError(exception) from exception except TemporaryError: raise except Exception as exception: + if ( + _is_transport_error(exception) + and retries.config.retry_connection_errors + ): + raise + raise PermanentError(exception) from exception return res diff --git a/src/ttd_data/utils/values.py b/src/ttd_data/utils/values.py index 53c8696..a75c6e1 100644 --- a/src/ttd_data/utils/values.py +++ b/src/ttd_data/utils/values.py @@ -53,9 +53,11 @@ def match_status_codes(status_codes: List[str], status_code: int) -> bool: T = TypeVar("T") + def cast_partial(typ): return partial(cast, typ) + def get_global_from_env( value: Optional[T], env_key: str, type_cast: Callable[[str], T] ) -> Optional[T]: diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index e69de29..1b932fd 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -0,0 +1,50 @@ +"""Shared fixtures for the GraphQL unit tests. No network: requests are served +by an httpx MockTransport that records what was sent.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import httpx +import pytest + + +class RecordingTransport: + """Captures each request body and replays a canned response.""" + + def __init__(self, response: Dict[str, Any]) -> None: + self.requests: List[Dict[str, Any]] = [] + self._response = response + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append( + { + "body": json.loads(request.content), + "headers": dict(request.headers), + "url": str(request.url), + } + ) + return httpx.Response(200, json=self._response) + + @property + def last_query(self) -> str: + return self.requests[-1]["body"]["query"] + + @property + def last_variables(self) -> Dict[str, Any]: + return self.requests[-1]["body"]["variables"] + + +@pytest.fixture +def graphql_ops(): + """Returns a factory: `make(ops_cls, response)` -> (ops_cls instance + wired to a recording transport, RecordingTransport).""" + from ttd_data.graphql import GraphQLTransport + + def make(ops_cls, response: Dict[str, Any] | None = None): + recorder = RecordingTransport(response or {"data": {}}) + http = httpx.Client(transport=httpx.MockTransport(recorder.handler)) + return ops_cls(GraphQLTransport(client=http)), recorder + + return make diff --git a/tests/unit/test_graphql_auth.py b/tests/unit/test_graphql_auth.py new file mode 100644 index 0000000..7beb7c9 --- /dev/null +++ b/tests/unit/test_graphql_auth.py @@ -0,0 +1,42 @@ +"""Unit tests for GraphQL authentication: the empty-token guard, and the +typed credential taking precedence over a caller-supplied header.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def graphql_client(graphql_ops): + from ttd_data.graphql import TaxonomyOperations + + def make(response=None): + return graphql_ops(TaxonomyOperations, response) + + return make + + +def test_empty_token_is_rejected(graphql_client): + """`ttd_auth: str` cannot catch an empty string, so the transport does — + before spending a request on a guaranteed 401.""" + client, recorder = graphql_client() + + with pytest.raises(ValueError, match="non-empty"): + client.query_segments(ttd_auth="", provider_id="eltoro") + + assert recorder.requests == [] + + +def test_token_argument_wins_over_a_stray_header(graphql_client): + """The credential is applied after the caller's headers are merged, so a + caller who also puts TTD-Auth in http_headers cannot silently override it. + Also pins the header name the token is sent under.""" + client, recorder = graphql_client() + + client.query_segments( + ttd_auth="the-real-token", + provider_id="eltoro", + http_headers={"TTD-Auth": "stale-token"}, + ) + + assert recorder.requests[-1]["headers"]["ttd-auth"] == "the-real-token" diff --git a/tests/unit/test_graphql_response.py b/tests/unit/test_graphql_response.py new file mode 100644 index 0000000..5d51916 --- /dev/null +++ b/tests/unit/test_graphql_response.py @@ -0,0 +1,145 @@ +"""Unit tests for the GraphQL response envelope: error surfacing, connection +unwrapping, and the per-segment outcome of an upsert.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def graphql_client(graphql_ops): + from ttd_data.graphql import TaxonomyOperations + + def make(response=None): + return graphql_ops(TaxonomyOperations, response) + + return make + + +def test_graphql_errors_raise_even_on_http_200(graphql_client): + """The supergraph reports authorization failures as HTTP 200 + errors, so + returning them as data would let a caller mistake failure for success.""" + from ttd_data.graphql import GraphQLError + + response = { + "data": None, + "errors": [{"message": "The current user is not authorized."}], + } + client, _ = graphql_client(response) + + with pytest.raises(GraphQLError, match="not authorized"): + client.query_segments(ttd_auth="tok", provider_id="eltoro") + + +def test_graphql_failures_are_catchable_as_sdk_errors(graphql_client): + """One `except DataError` must cover both suites. Before this, GraphQL + raised bare Exceptions and leaked httpx.HTTPStatusError, so a caller had to + know the transport library to handle a failure.""" + from ttd_data.errors import DataError + + client, _ = graphql_client({"errors": [{"message": "nope"}]}) + + with pytest.raises(DataError) as excinfo: + client.query_segments(ttd_auth="tok", provider_id="eltoro") + + assert excinfo.value.status_code == 200, "policy failures arrive as HTTP 200" + assert excinfo.value.raw_response is not None + + +def test_graphql_error_keeps_partially_resolved_data(graphql_client): + """GraphQL can return both; the caller must still be able to reach `data`.""" + from ttd_data.graphql import GraphQLError + + response = { + "data": {"thirdPartyDataProvider": None}, + "errors": [{"message": "policy denied"}], + } + client, _ = graphql_client(response) + + with pytest.raises(GraphQLError) as excinfo: + client.query_segments(ttd_auth="tok", provider_id="eltoro") + + assert excinfo.value.data == {"thirdPartyDataProvider": None} + assert excinfo.value.errors[0]["message"] == "policy denied" + + +def test_page_flattens_the_connection(graphql_client): + response = { + "data": { + "thirdPartyDataProvider": { + "thirdPartyTargetingDataSegments": { + "totalCount": 42, + "nodes": [{"providerElementId": "seg-1"}], + "pageInfo": {"hasNextPage": True, "endCursor": "cursor-abc"}, + } + } + } + } + client, _ = graphql_client(response) + + page = client.query_segments(ttd_auth="tok", provider_id="eltoro") + + assert page.nodes == [{"providerElementId": "seg-1"}] + assert page.total_count == 42 + assert page.has_next_page is True + assert page.end_cursor == "cursor-abc" + assert page.raw == response, "the untouched body stays reachable" + + +def test_page_is_empty_when_the_body_does_not_match_the_document(graphql_client): + """A connection path that does not resolve — a stale path constant, a + truncated payload — must degrade to an empty page rather than raise from + deep in a `.get` chain.""" + client, _ = graphql_client({"data": {"thirdPartyDataProvider": None}}) + + page = client.query_segments(ttd_auth="tok", provider_id="eltoro") + + assert page.nodes == [] + assert page.total_count is None + assert page.has_next_page is False + + +def test_upsert_result_separates_accepted_from_rejected(graphql_client): + """A partially-applied batch is the dangerous case: the request succeeds + while some segments are rejected.""" + response = { + "data": { + "thirdPartyDataUpsert": { + "data": [{"mode": "CREATE", "segment": {"providerElementId": "seg-1"}}], + "errors": [ + { + "__typename": "ThirdPartyDataUpsertOperationError", + "providerElementId": "seg-2", + "reason": "PARENT_NOT_FOUND", + } + ], + } + } + } + client, _ = graphql_client(response) + + result = client.upsert_segments( + ttd_auth="tok", + segments=[ + {"providerId": "eltoro", "providerElementId": f"seg-{i}"} for i in (1, 2) + ], + ) + + assert len(result.succeeded) == 1 + assert result.succeeded[0]["mode"] == "CREATE" + assert len(result.failed) == 1 + assert result.failed[0]["reason"] == "PARENT_NOT_FOUND" + + +def test_upsert_result_tolerates_omitted_lists(graphql_client): + """The server may omit `data`/`errors` rather than send them empty; both + must still read as empty lists, not None.""" + client, _ = graphql_client({"data": {"thirdPartyDataUpsert": {}}}) + + result = client.upsert_segments( + ttd_auth="tok", + segments=[{"providerId": "eltoro", "providerElementId": "seg-1"}], + ) + + assert result.succeeded == [] + assert result.failed == [] diff --git a/tests/unit/test_graphql_retries.py b/tests/unit/test_graphql_retries.py new file mode 100644 index 0000000..3e95ef4 --- /dev/null +++ b/tests/unit/test_graphql_retries.py @@ -0,0 +1,121 @@ +"""Unit tests for GraphQL retries and debug logging: the transport honours the +client's retry configuration, a call can override it, and the credential never +reaches the log.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Sequence + +import httpx +import pytest + +from ttd_data.errors import APIError +from ttd_data.graphql import GraphQLTransport +from ttd_data.utils import BackoffStrategy, RetryConfig + +QUERY = "query { __typename }" + +# Tight enough that a retrying test costs milliseconds, not seconds. +FAST_BACKOFF = BackoffStrategy( + initial_interval=1, max_interval=1, exponent=1.0, max_elapsed_time=1000, jitter_ms=0 +) + + +def backoff_config() -> RetryConfig: + return RetryConfig("backoff", FAST_BACKOFF, retry_connection_errors=False) + + +class StatusSequence: + """Replays one status code per attempt, counting attempts.""" + + def __init__(self, statuses: Sequence[int]) -> None: + self._statuses = list(statuses) + self.attempts = 0 + + def handler(self, request: httpx.Request) -> httpx.Response: + status = self._statuses[min(self.attempts, len(self._statuses) - 1)] + self.attempts += 1 + return httpx.Response(status, json={"data": {"__typename": "Query"}}) + + +class CollectingLogger: + def __init__(self) -> None: + self.lines: List[str] = [] + + def debug(self, msg: str, *args: Any, **kwargs: Any) -> None: + self.lines.append(msg % args if args else msg) + + +def transport(statuses: Sequence[int], **kwargs: Any): + sequence = StatusSequence(statuses) + client = httpx.Client(transport=httpx.MockTransport(sequence.handler)) + return GraphQLTransport(client=client, **kwargs), sequence + + +def test_a_retryable_status_is_retried(): + """One representative status is enough: RETRYABLE_STATUS_CODES is a flat + membership check, with no per-status branch to exercise separately.""" + gql, sequence = transport([429, 200], retry_config=backoff_config()) + + body: Dict[str, Any] = gql.execute(QUERY, ttd_auth="token") + + assert sequence.attempts == 2 + assert body["data"]["__typename"] == "Query" + + +def test_non_retryable_status_fails_on_the_first_attempt(): + """A 400 is the caller's fault; retrying it just delays the error.""" + gql, sequence = transport([400], retry_config=backoff_config()) + + with pytest.raises(APIError): + gql.execute(QUERY, ttd_auth="token") + + assert sequence.attempts == 1 + + +def test_without_a_retry_config_a_429_is_raised_immediately(): + """Retries stay opt-in, so behaviour is unchanged for callers who set none.""" + gql, sequence = transport([429, 200]) + + with pytest.raises(APIError): + gql.execute(QUERY, ttd_auth="token") + + assert sequence.attempts == 1 + + +def test_per_call_retries_override_the_client_configuration(): + """A client configured not to retry can still retry one call.""" + gql, sequence = transport( + [429, 200], + retry_config=RetryConfig( + "none", FAST_BACKOFF, retry_connection_errors=False + ), + ) + + gql.execute(QUERY, ttd_auth="token", retries=backoff_config()) + + assert sequence.attempts == 2 + + +def test_data_client_retry_config_reaches_graphql(): + """The bug this closes: retry_config set on DataClient used to apply to the + REST operations only.""" + from ttd_data import DataClient + + config = backoff_config() + client = DataClient(retry_config=config) + + assert client.graphql.sdk_configuration.retry_config is config + + +def test_the_credential_is_masked_in_debug_output(): + """Debug logging dumps the request headers; the token must not be among + them, which is only enforceable because auth is a typed parameter.""" + logger = CollectingLogger() + gql, _ = transport([200], debug_logger=logger) + + gql.execute(QUERY, ttd_auth="super-secret-token") + + logged = "\n".join(logger.lines) + assert "super-secret-token" not in logged + assert "'TTD-Auth': '***'" in logged diff --git a/tests/unit/test_graphql_schema.py b/tests/unit/test_graphql_schema.py new file mode 100644 index 0000000..e564277 --- /dev/null +++ b/tests/unit/test_graphql_schema.py @@ -0,0 +1,66 @@ +"""Validates every GraphQL document the SDK sends against the Impala +subgraph SDL. Offline: no network calls. This is the check that catches a +field name that does not exist before a caller ever sends one over the wire. + +Opt-in only: the SDL is never committed to this (public) repo — it carries +internal feature-flag/permission names, tenant-specific policy, and staff +email addresses from deprecation directives. Point TTD_GRAPHQL_SCHEMA_PATH at +a local copy (see `scripts/validate_graphql.py --write-sdl`) to run this; +otherwise it skips, and `scripts/validate_graphql.py` run against the live +endpoint is the equivalent on-demand check. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +graphql = pytest.importorskip("graphql") + +_DEFAULT_SDL_PATH = Path(__file__).resolve().parents[2] / "impala.graphql" +SDL_PATH = Path(os.environ.get("TTD_GRAPHQL_SCHEMA_PATH", _DEFAULT_SDL_PATH)) + + +@pytest.fixture(scope="module") +def schema(): + if not SDL_PATH.exists(): + pytest.skip(f"{SDL_PATH} not present (set TTD_GRAPHQL_SCHEMA_PATH to enable)") + # assume_valid: the SDL is a federation subgraph whose @key/@override/ + # @policy directives are supplied by @link, not declared in the file. + return graphql.build_schema( + SDL_PATH.read_text(encoding="utf-8"), + assume_valid=True, + assume_valid_sdl=True, + ) + + +def _document_names(): + from ttd_data.graphql import QUERY_DOCUMENTS + + return sorted(QUERY_DOCUMENTS) + + +@pytest.mark.parametrize("name", _document_names()) +def test_document_is_valid_against_schema(schema, name): + from ttd_data.graphql import QUERY_DOCUMENTS + + errors = graphql.validate(schema, graphql.parse(QUERY_DOCUMENTS[name])) + assert not errors, "\n".join(error.message for error in errors) + + +def test_every_typed_method_has_a_registered_document(): + """Guards the validator's coverage: a new typed method that forgets to + register its document would otherwise never be schema-checked. Add each + new operation class here alongside its QUERY_DOCUMENTS entries.""" + from ttd_data.graphql import QUERY_DOCUMENTS, TaxonomyOperations + + operation_classes = [TaxonomyOperations] + typed_methods = { + name + for cls in operation_classes + for name in dir(cls) + if name.startswith(("query_", "upsert_")) and callable(getattr(cls, name)) + } + assert typed_methods == set(QUERY_DOCUMENTS) diff --git a/tests/unit/test_graphql_taxonomy.py b/tests/unit/test_graphql_taxonomy.py new file mode 100644 index 0000000..c5552bc --- /dev/null +++ b/tests/unit/test_graphql_taxonomy.py @@ -0,0 +1,108 @@ +"""Unit tests for the taxonomy GraphQL operations: what document and variables +go on the wire, and the client-side batch-size guard.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def graphql_client(graphql_ops): + from ttd_data.graphql import TaxonomyOperations + + def make(response=None): + return graphql_ops(TaxonomyOperations, response) + + return make + + +def test_upsert_segments_sends_the_batch_verbatim(graphql_client): + """Segments reach the server as given — omitted keys stay omitted, so an + update touches only the fields the caller passed.""" + client, recorder = graphql_client() + batch = [ + { + "providerId": "eltoro", + "providerElementId": "seg-1", + "displayName": "Interest > Tech", + "parentElementId": "ROOT", + "buyable": False, + }, + {"providerId": "eltoro", "providerElementId": "seg-2"}, + ] + + client.upsert_segments(ttd_auth="tok", segments=batch) + + assert recorder.last_variables["input"] == batch + assert "mutation ThirdPartyDataUpsert" in recorder.last_query + + +@pytest.mark.parametrize("size", [0, 1001]) +def test_upsert_segments_rejects_out_of_range_batches(graphql_client, size): + client, recorder = graphql_client() + + with pytest.raises(ValueError, match="between 1 and 1000"): + client.upsert_segments(ttd_auth="tok", segments=[{"providerId": "p"}] * size) + + assert recorder.requests == [], "no request should be sent" + + +def test_query_segments_omits_where_when_unfiltered(graphql_client): + client, recorder = graphql_client() + + client.query_segments(ttd_auth="tok", provider_id="eltoro") + + variables = recorder.last_variables + assert variables == {"providerId": "eltoro", "first": 1000, "after": None} + assert "where" not in variables + + +def test_query_segments_filters_by_provider_element_ids(graphql_client): + client, recorder = graphql_client() + + client.query_segments( + ttd_auth="tok", + provider_id="eltoro", + provider_element_ids=("seg-1", "seg-2"), + first=50, + after="cursor-abc", + ) + + variables = recorder.last_variables + assert variables["where"] == {"providerElementId": {"in": ["seg-1", "seg-2"]}} + assert variables["first"] == 50 + assert variables["after"] == "cursor-abc" + + +def test_query_segment_taxonomy_status_filters_to_one_segment(graphql_client): + response = { + "data": { + "thirdPartyDataProvider": { + "thirdPartyTargetingDataSegments": { + "nodes": [ + {"providerElementId": "seg-1", "taxonomyApprovalStatus": "APPROVED"} + ] + } + } + } + } + client, recorder = graphql_client(response) + + status = client.query_segment_taxonomy_status( + ttd_auth="tok", provider_id="eltoro", provider_element_id="seg-1" + ) + + assert recorder.last_variables["where"] == {"providerElementId": {"eq": "seg-1"}} + assert status == "APPROVED" + + +def test_query_segment_taxonomy_status_returns_none_for_unknown_segment(graphql_client): + response = {"data": {"thirdPartyDataProvider": {"thirdPartyTargetingDataSegments": {"nodes": []}}}} + client, _ = graphql_client(response) + + assert ( + client.query_segment_taxonomy_status( + ttd_auth="tok", provider_id="eltoro", provider_element_id="nope" + ) + is None + ) diff --git a/tests/unit/test_uid2.py b/tests/unit/test_uid2.py index 78ae466..355fd48 100644 --- a/tests/unit/test_uid2.py +++ b/tests/unit/test_uid2.py @@ -105,7 +105,12 @@ def test_client_config_tracks_base_data_client_constructor_params(): "self", *not_reconstructable, } - config_fields = {f.name for f in dataclasses.fields(ClientConfig)} - {"uid2_config"} + # uid2_config and graphql_server_url are DataClient-level additions, + # not BaseDataClient constructor params. + config_fields = {f.name for f in dataclasses.fields(ClientConfig)} - { + "uid2_config", + "graphql_server_url", + } assert base_params == config_fields diff --git a/uv.lock b/uv.lock index f45caf3..f269f4d 100644 --- a/uv.lock +++ b/uv.lock @@ -176,6 +176,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "graphql-core" +version = "3.2.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/90/f2aff026ab4aebd80eb71905106a0885f4cfde85dcf965543f45bed0d9ee/graphql_core-3.2.11.tar.gz", hash = "sha256:e7e156d10beb127cab5c89ff0da71416fc73d27c484a4757d3b2d35633774802", size = 528407, upload-time = "2026-06-05T13:45:22.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl", hash = "sha256:0b3e35ff41e9adba53021ab0cef475eb18f57c7f53f0f2ca55567fbf3c537ea0", size = 214879, upload-time = "2026-06-05T13:45:21.245Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -502,11 +511,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] @@ -638,7 +647,7 @@ wheels = [ [[package]] name = "ttd-data" -version = "0.2.7" +version = "0.2.9" source = { editable = "." } dependencies = [ { name = "httpcore" }, @@ -649,6 +658,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "graphql-core" }, { name = "mypy" }, { name = "pylint" }, { name = "pyright" }, @@ -666,6 +676,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "graphql-core", specifier = ">=3.2.0,<4.0.0" }, { name = "mypy", specifier = "==1.15.0" }, { name = "pylint", specifier = "==3.2.3" }, { name = "pyright", specifier = "==1.1.398" },