diff --git a/.speakeasy/gen.lock b/.speakeasy/gen.lock index e2af417..b28f161 100644 --- a/.speakeasy/gen.lock +++ b/.speakeasy/gen.lock @@ -5,8 +5,8 @@ management: docVersion: v0.1 speakeasyVersion: 1.794.0 generationVersion: 2.930.0 - releaseVersion: 0.3.1 - configChecksum: 412022fe57a148beb9a811f85df27ac1 + releaseVersion: 0.3.2 + configChecksum: a2f185276005c068df58d66545924402 repoURL: https://github.com/thetradedesk/ttd-data-python.git installationURL: https://github.com/thetradedesk/ttd-data-python.git published: true diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml index 2db74c9..f54fd24 100644 --- a/.speakeasy/gen.yaml +++ b/.speakeasy/gen.yaml @@ -35,7 +35,7 @@ generation: generateNewTests: false skipResponseBodyAssertions: false python: - version: 0.3.1 + version: 0.3.2 additionalDependencies: dev: {} main: diff --git a/README.md b/README.md index e92351f..de8a349 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,56 @@ Each segment is a `SegmentInput`. `providerId` and `providerElementId` are alway 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. +### 10. Third-Party Data Rates (GraphQL) + +Rate operations run as GraphQL requests against the Platform API supergraph. They live under `client.third_party_data_rate`. + +| SDK function | REST equivalent | What it does | +| --- | --- | --- | +| `query_brands(...)` | [`GET /v3/datarate/brands/{providerId}`](https://open.thetradedesk.com/rest/openttd/provider/content/docs/GuidesProvider/audience/ref/get-datarate-brands-providerid) | Lists a provider's third-party data brands. | +| `query_segment_data_rates(...)` | [`POST /v3/datarate/query`](https://open.thetradedesk.com/rest/openttd/provider/content/docs/GuidesProvider/audience/ref/post-datarate-query) | Lists data rates for a provider's segments, optionally filtered by segment or brand. | +| `query_data_rate_batches(...)` | [`GET /v3/datarate/batch/{batchId}`](https://open.thetradedesk.com/rest/openttd/provider/content/docs/GuidesProvider/audience/ref/get-datarate-batch-batchid) | Lists data rate batches for a provider, optionally filtered to one batch. | +| `create_data_rate_batch(...)` | [`POST /v3/datarate/batch`](https://open.thetradedesk.com/rest/openttd/provider/content/docs/GuidesProvider/audience/ref/post-datarate-batch) | Submits a batch of data rate creates for a provider. | +| `client.graphql.execute(...)` | — | Sends any GraphQL document and returns the parsed response body. | + +```python +from ttd_data import DataClient + +client = DataClient(ttd_auth=TTD_AUTH_TOKEN) +rates = client.third_party_data_rate + +# List brands. Paginate with page.end_cursor while page.has_next_page. +page = rates.query_brands(provider_id=PROVIDER_ID, first=10) +for node in page.nodes: + print(node["id"], node["name"]) + +# List data rates for a provider's segments, optionally filtered by brand. +page = rates.query_segment_data_rates(provider_id=PROVIDER_ID, brand_id="brand-1") + +# List/filter data rate batches. +page = rates.query_data_rate_batches(provider_id=PROVIDER_ID, batch_id="0006A7D") + +# Submit a batch of data rate creates. Queued, not applied immediately. +result = rates.create_data_rate_batch( + provider_id=PROVIDER_ID, + data_rates=[ + { + "providerElementId": "auto/in-market/ev", + "thirdPartyDataBrandId": "brand-1", + "cost": {"cpm": {"cpmCost": {"amount": 2.5, "currencyCode": "USD"}}}, + } + ], +) +if result.errors: + print(result.errors) # the batch was rejected outright +else: + print(result.data["id"], result.data["processingStatus"]) +``` + +Each rate is a `DataRateInput`. `subject` sets exactly one of `partner`/`advertiser` (omit for a system rate); `cost` sets exactly one of `cpm`/`revShare`/`hybrid`. + +`errors` being non-empty means the batch was rejected outright; poll `query_data_rate_batches` with the returned batch ID to follow processing and approval. + diff --git a/examples/graphql_rates_example.py b/examples/graphql_rates_example.py new file mode 100644 index 0000000..ae4df75 --- /dev/null +++ b/examples/graphql_rates_example.py @@ -0,0 +1,112 @@ +"""Example: third-party data rate 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_BRAND_ID=... optional. Brand to filter rates by. + GRAPHQL_EXAMPLE_ELEMENT_ID=... optional. Segment to price. + +Reads run unconditionally. The batch submission at the end is a write, and is +skipped unless GRAPHQL_EXAMPLE_ELEMENT_ID and GRAPHQL_EXAMPLE_BRAND_ID are +both set. The submitter is taken from the token's email. + + TTD_AUTH_TOKEN=... GRAPHQL_EXAMPLE_PROVIDER_ID=... \ + python examples/graphql_rates_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") + +BRAND_ID = os.getenv("GRAPHQL_EXAMPLE_BRAND_ID", "").strip() +ELEMENT_ID = os.getenv("GRAPHQL_EXAMPLE_ELEMENT_ID", "").strip() + +client = DataClient(ttd_auth=token) +rates = client.third_party_data_rate + + +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)) + + +# --------------------------------------------------------------------------- +# Reads +# --------------------------------------------------------------------------- + +show( + "Brands for provider", + rates.query_brands(provider_id=PROVIDER_ID, first=10), +) + +show( + "Data rates for provider segments", + rates.query_segment_data_rates(provider_id=PROVIDER_ID, first=5), +) + +if not BRAND_ID: + print("\nSkipping brand filter (set GRAPHQL_EXAMPLE_BRAND_ID to run it).") +else: + show( + f"Data rates filtered to brand {BRAND_ID}", + rates.query_segment_data_rates( + provider_id=PROVIDER_ID, brand_id=BRAND_ID, first=5 + ), + ) + +show( + "Data rate batches", + rates.query_data_rate_batches(provider_id=PROVIDER_ID, first=10), +) + +# --------------------------------------------------------------------------- +# Mutation — this submits a rate batch against the provider +# --------------------------------------------------------------------------- + +if not (ELEMENT_ID and BRAND_ID): + print( + "\nSkipping batch submission (needs GRAPHQL_EXAMPLE_ELEMENT_ID " + "and GRAPHQL_EXAMPLE_BRAND_ID)." + ) +else: + # `subject` sets exactly one of `partner`/`advertiser` (omit for a system + # rate); `cost` sets exactly one of `cpm`/`revShare`/`hybrid`. + result = rates.create_data_rate_batch( + provider_id=PROVIDER_ID, + data_rates=[ + { + "providerElementId": ELEMENT_ID, + "thirdPartyDataBrandId": BRAND_ID, + "cost": {"cpm": {"cpmCost": {"amount": 2.5, "currencyCode": "USD"}}}, + # To scope this rate instead of making it a system rate, set + # "subject" to one of: + # {"advertiser": {"advertiserId": "..."}} + # {"partner": {"partnerId": "..."}} + } + ], + ) + print(f"\n{'=' * 60}\n Submit data rate batch\n{'=' * 60}") + if result.data is None: + print(f"rejected: {json.dumps(result.errors, indent=2)}") + else: + print(json.dumps(result.data, indent=2)) + # Queued, not applied: read the batch back to follow its processing. + show( + "Submitted batch", + rates.query_data_rate_batches( + provider_id=PROVIDER_ID, batch_id=result.data["id"] + ), + ) diff --git a/examples/graphql_example.py b/examples/graphql_taxonomy_example.py similarity index 98% rename from examples/graphql_example.py rename to examples/graphql_taxonomy_example.py index 963e6f0..0011cd3 100644 --- a/examples/graphql_example.py +++ b/examples/graphql_taxonomy_example.py @@ -11,7 +11,7 @@ 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 + python examples/graphql_taxonomy_example.py """ import json diff --git a/pyproject.toml b/pyproject.toml index 51c2a1f..9b6ba59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ttd-data" -version = "0.3.1" +version = "0.3.2" 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 eeb3117..48caf9d 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.3.1" +__version__: str = "0.3.2" __openapi_doc_version__: str = "v0.1" __gen_version__: str = "2.930.0" -__user_agent__: str = "speakeasy-sdk/python 0.3.1 2.930.0 v0.1 ttd-data" +__user_agent__: str = "speakeasy-sdk/python 0.3.2 2.930.0 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 8d7d10a..d9f7781 100644 --- a/src/ttd_data/client.py +++ b/src/ttd_data/client.py @@ -12,7 +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.graphql import DataRateOperations, GraphQLTransport, TaxonomyOperations from ttd_data.sdk import BaseDataClient from ttd_data.types import BaseModel, OptionalNullable from ttd_data.utils import RetryConfig @@ -347,6 +347,12 @@ def third_party_taxonomy(self) -> TaxonomyOperations: never on user identifiers.""" return TaxonomyOperations(self._graphql_transport) + @cached_property + def third_party_data_rate(self) -> DataRateOperations: + """Third-party data rate operations over GraphQL: brand and rate + queries, plus data rate batch submission and lookup.""" + return DataRateOperations(self._graphql_transport) + @cached_property def graphql(self) -> GraphQLTransport: """Escape hatch for GraphQL operations the typed namespaces above do diff --git a/src/ttd_data/graphql/__init__.py b/src/ttd_data/graphql/__init__.py index 2e61800..d4bc44a 100644 --- a/src/ttd_data/graphql/__init__.py +++ b/src/ttd_data/graphql/__init__.py @@ -1,15 +1,25 @@ -from ttd_data.graphql._response import GraphQLError, Page, UpsertResult +from ttd_data.graphql._response import ( + GraphQLError, + MutationResult, + Page, + UpsertResult, +) from ttd_data.graphql._transport import GraphQLTransport +from ttd_data.graphql.rates import QUERY_DOCUMENTS as _RATES_DOCUMENTS +from ttd_data.graphql.rates import DataRateInput, DataRateOperations 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) +QUERY_DOCUMENTS = {**_TAXONOMY_DOCUMENTS, **_RATES_DOCUMENTS} __all__ = [ + "DataRateInput", + "DataRateOperations", "GraphQLError", "GraphQLTransport", + "MutationResult", "Page", "SegmentInput", "TaxonomyOperations", diff --git a/src/ttd_data/graphql/_response.py b/src/ttd_data/graphql/_response.py index 9947242..714aa2d 100644 --- a/src/ttd_data/graphql/_response.py +++ b/src/ttd_data/graphql/_response.py @@ -62,6 +62,20 @@ class UpsertResult: raw: Dict[str, Any] = field(default_factory=dict) +@dataclass(frozen=True) +class MutationResult: + """Outcome of a mutation that returns one entity alongside field errors. + + `data` is None when the mutation was rejected outright; `errors` is + non-empty in that case. Both can be set when the server accepted the + operation but flagged something about it. + """ + + data: Optional[Dict[str, Any]] = None + errors: List[Dict[str, Any]] = field(default_factory=list) + 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 {} @@ -98,3 +112,13 @@ def build_upsert_result(raw: Dict[str, Any], *path: str) -> UpsertResult: failed=payload.get("errors") or [], raw=raw, ) + + +def build_mutation_result(raw: Dict[str, Any], *path: str) -> MutationResult: + """Unwrap a single-entity mutation payload at `path` under `data`.""" + payload = _resolve(raw, *path) + return MutationResult( + data=payload.get("data") or None, + errors=payload.get("errors") or [], + raw=raw, + ) diff --git a/src/ttd_data/graphql/rates.py b/src/ttd_data/graphql/rates.py new file mode 100644 index 0000000..9492e99 --- /dev/null +++ b/src/ttd_data/graphql/rates.py @@ -0,0 +1,430 @@ +"""Third-party data rate operations: brands, per-segment data rates, and data +rate batch submission and lookup. +""" + +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence + +from typing_extensions import NotRequired, TypedDict + +from ttd_data.graphql._response import ( + MutationResult, + Page, + build_mutation_result, + build_page, +) +from ttd_data.graphql._transport import GraphQLTransport +from ttd_data.types import OptionalNullable, UNSET +from ttd_data.utils import RetryConfig + +BRANDS_PATH = ("thirdPartyDataProvider", "thirdPartyDataBrands") +SEGMENTS_PATH = ("thirdPartyDataProvider", "thirdPartyTargetingDataSegments") +BATCHES_PATH = ("thirdPartyDataProvider", "dataRateBatches") +BATCH_CREATE_PATH = ("dataRateBatchCreate",) + +MAX_BATCH_SIZE = 5000 + + +class DataRateInput(TypedDict): + """A `DataRateCreateInput`. Creates the rate, or overwrites the existing + rate for the same segment, brand and subject. + + `subject` (a `DataRateSubjectCreateInput`) sets exactly one of `partner` + (`{"partnerId": ...}`) or `advertiser` (`{"advertiserId": ...}`) — omit it + entirely for a system (syndicated) rate. `cost` (a + `DataRateCostCreateInput`) sets exactly one of: + - `cpm`: `{"cpmCost": {"amount": ..., "currencyCode": ...}}` + - `revShare`: `{"percentOfMediaCost": ...}` — a fraction, not a + percentage: 0.12 means 12%. + - `hybrid`: `{"cpmCostCap": {"amount": ..., "currencyCode": ...}, + "percentOfMediaCost": ...}` + + The schema's `DataRateInput.delete` variant (removing an existing rate) + isn't supported yet. + """ + + providerElementId: str + thirdPartyDataBrandId: str + cost: Dict[str, Any] + subject: NotRequired[Dict[str, Any]] + + +def _require_exactly_one( + value: Mapping[str, Any], keys: Sequence[str], label: str +) -> str: + """Enforce a `@oneOf` input, which the server rejects outright otherwise.""" + unknown = sorted(set(value) - set(keys)) + if unknown: + raise ValueError(f"{label} has unknown key(s) {unknown}; allowed: {list(keys)}") + present = [key for key in keys if value.get(key) is not None] + if len(present) != 1: + raise ValueError( + f"{label} must set exactly one of {list(keys)}, got {present or 'none'}" + ) + return present[0] + + +def _validate_rate(rate: Mapping[str, Any], index: int) -> None: + label = f"data_rates[{index}]" + + subject = rate.get("subject") + if subject is not None: + _require_exactly_one(subject, ("partner", "advertiser"), f"{label}.subject") + + cost = rate.get("cost") + if cost is None: + raise ValueError(f"{label}.cost is required") + _require_exactly_one(cost, ("cpm", "revShare", "hybrid"), f"{label}.cost") + +QUERY_BRANDS = """ +query QueryThirdPartyDataBrands($providerId: ID!, $first: Int, $after: String) { + thirdPartyDataProvider(id: $providerId) { + thirdPartyDataBrands(first: $first, after: $after) { + totalCount + nodes { + id + name + brandDomainName + logoUrl + hasRestrictions + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +""" + +QUERY_SEGMENT_DATA_RATES = """ +query QuerySegmentDataRates( + $providerId: ID! + $first: Int + $after: String + $where: ThirdPartyTargetingDataFilterInput + $rateWhere: ProviderDataRateFilterInput +) { + thirdPartyDataProvider(id: $providerId) { + thirdPartyTargetingDataSegments(first: $first, after: $after, where: $where) { + totalCount + nodes { + id + providerElementId + providerDataRates(where: $rateWhere) { + totalCount + nodes { + brand { + id + name + } + dataRateCost { + __typename + rateType + ... on CpmDataRateCost { + cpmCost { + amount + currencyCode + } + } + ... on RevShareDataRateCost { + percentOfMediaCost + } + ... on HybridDataRateCost { + percentOfMediaCost + cpmCostCap { + amount + currencyCode + } + } + } + dataRateSubject { + __typename + level + ... on PartnerDataRateSubject { + partner { + id + } + } + ... on AdvertiserDataRateSubject { + advertiser { + id + } + } + } + dataRateBatch { + id + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +""" + +QUERY_DATA_RATE_BATCHES = """ +query QueryDataRateBatches( + $providerId: ID! + $first: Int + $after: String + $where: DataRateBatchFilterInput +) { + thirdPartyDataProvider(id: $providerId) { + dataRateBatches(first: $first, after: $after, where: $where) { + totalCount + nodes { + id + processingStatus + approvalStatus + createdAt + createdBy + reviewedAt + earliestEligibleForProcessingTime + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +""" + +CREATE_DATA_RATE_BATCH = """ +mutation DataRateBatchCreate($input: DataRateBatchCreateInput!) { + dataRateBatchCreate(input: $input) { + data { + id + processingStatus + approvalStatus + createdAt + createdBy + reviewedAt + earliestEligibleForProcessingTime + } + errors { + __typename + ... on UserError { + message + field + } + ... on FieldIsEmptyError { + message + field + } + ... on StringTooLongError { + message + field + maxLength + } + } + } +} +""" + +QUERY_DOCUMENTS: Dict[str, str] = { + "create_data_rate_batch": CREATE_DATA_RATE_BATCH, + "query_brands": QUERY_BRANDS, + "query_segment_data_rates": QUERY_SEGMENT_DATA_RATES, + "query_data_rate_batches": QUERY_DATA_RATE_BATCHES, +} + + +class DataRateOperations: + """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 create_data_rate_batch( + self, + *, + provider_id: str, + data_rates: List[DataRateInput], + submission_source: str = "API", + retries: OptionalNullable[RetryConfig] = UNSET, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> MutationResult: + """ + Submit a batch of data rate creates for one provider. + + The batch is queued rather than applied: the returned + `processingStatus` starts at NOT_STARTED, and a batch that trips the + pre-approval thresholds sits at AWAITING_MANUAL_REVIEW until the Data + Partnerships team approves it. Poll `query_data_rate_batches` with the + returned batch ID to follow it. + + `errors` being non-empty means the batch was rejected — check it rather + than assuming a returned result landed. + + The submitter recorded on the batch comes from the email on the + authenticating token, not from an argument. + + Requires the `ImpalaDataRateBatch` feature flag and the + `PublicAPI_ThirdPartyData_Edit` permission on the token. + + :param provider_id: ThirdPartyDataProvider ID owning every segment in + the batch. + :param data_rates: Rates to create. Where several batches touch one + segment, the latest submission wins. + :param submission_source: How the batch was created. Leave as API + unless you are attributing the batch to a specific tool. + :raises ValueError: A rate entry does not match the schema's `@oneOf` + inputs, or the batch is empty or over the limit. + """ + if not 1 <= len(data_rates) <= MAX_BATCH_SIZE: + raise ValueError( + f"data_rates must contain between 1 and {MAX_BATCH_SIZE} " + f"entries, got {len(data_rates)}" + ) + for index, rate in enumerate(data_rates): + _validate_rate(rate, index) + + return build_mutation_result( + self._transport.execute( + CREATE_DATA_RATE_BATCH, + variables={ + "input": { + "dataProviderId": provider_id, + "dataRates": [{"create": rate} for rate in data_rates], + "submissionSource": submission_source, + } + }, + retries=retries, + timeout_ms=timeout_ms, + http_headers=http_headers, + ), + *BATCH_CREATE_PATH, + ) + + def query_brands( + self, + *, + provider_id: str, + 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 the third-party data brands belonging to a provider. + + :param provider_id: ThirdPartyDataProvider ID. + :param first: Page size, capped at 1000 by the schema. + :param after: Cursor to resume from (pass a previous `Page.end_cursor`). + """ + return build_page( + self._transport.execute( + QUERY_BRANDS, + variables={"providerId": provider_id, "first": first, "after": after}, + retries=retries, + timeout_ms=timeout_ms, + http_headers=http_headers, + ), + *BRANDS_PATH, + ) + + def query_segment_data_rates( + self, + *, + provider_id: str, + provider_element_ids: Optional[Iterable[str]] = None, + brand_id: Optional[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 the data rates attached to a provider's segments. Each rate + carries its brand, cost (CPM, rev-share or hybrid), and the subject the + rate applies to — `dataRateSubject.level` is SYSTEM, PARTNER or + ADVERTISER, with the partner or advertiser ID alongside it. + + The schema offers no server-side filter by level, partner or + advertiser; filter `dataRateSubject` client-side if you need that. + + :param provider_id: ThirdPartyDataProvider ID. + :param provider_element_ids: Restrict to these provider element IDs. + Omit to cover every segment for the provider. + :param brand_id: Restrict the rates to a single brand. + :param first: Segment page size, capped at 1000 by the schema. + :param after: Cursor to resume from (pass a previous `Page.end_cursor`). + + Each node is a segment; its rates stay nested under + `providerDataRates`, which is its own connection. + """ + 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)} + } + if brand_id is not None: + variables["rateWhere"] = {"thirdPartyDataBrandId": {"eq": brand_id}} + return build_page( + self._transport.execute( + QUERY_SEGMENT_DATA_RATES, + variables=variables, + retries=retries, + timeout_ms=timeout_ms, + http_headers=http_headers, + ), + *SEGMENTS_PATH, + ) + + def query_data_rate_batches( + self, + *, + provider_id: str, + batch_id: Optional[str] = None, + first: int = 10, + after: Optional[str] = None, + retries: OptionalNullable[RetryConfig] = UNSET, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> Page: + """ + Query data rate batches for a provider, optionally filtered to one + batch. Returns batch state only — the schema exposes no edge from a + batch to the rates it contains. + + :param provider_id: ThirdPartyDataProvider ID. + :param batch_id: Filter to the batch with this ID. Omit to list all + batches 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 batch_id is not None: + variables["where"] = {"id": {"eq": batch_id}} + return build_page( + self._transport.execute( + QUERY_DATA_RATE_BATCHES, + variables=variables, + retries=retries, + timeout_ms=timeout_ms, + http_headers=http_headers, + ), + *BATCHES_PATH, + ) diff --git a/tests/unit/test_graphql_rates.py b/tests/unit/test_graphql_rates.py new file mode 100644 index 0000000..cac8dc4 --- /dev/null +++ b/tests/unit/test_graphql_rates.py @@ -0,0 +1,251 @@ +"""Unit tests for the data rate GraphQL operations: what document and +variables go on the wire, and how the two independent filters compose.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def graphql_client(graphql_ops): + from ttd_data.graphql.rates import DataRateOperations + + def make(response=None): + return graphql_ops(DataRateOperations, response, ttd_auth="tok") + + return make + + +def test_query_brands_sends_paging_variables(graphql_client): + client, recorder = graphql_client() + + client.query_brands(provider_id="eltoro", first=10, after="cursor-1") + + assert recorder.last_variables == { + "providerId": "eltoro", + "first": 10, + "after": "cursor-1", + } + assert "QueryThirdPartyDataBrands" in recorder.last_query + + +def test_query_segment_data_rates_omits_both_filters_when_unset(graphql_client): + client, recorder = graphql_client() + + client.query_segment_data_rates(provider_id="eltoro") + + variables = recorder.last_variables + assert variables == {"providerId": "eltoro", "first": 1000, "after": None} + assert "where" not in variables + assert "rateWhere" not in variables + + +def test_query_segment_data_rates_applies_segment_and_brand_filters(graphql_client): + client, recorder = graphql_client() + + client.query_segment_data_rates( + provider_id="eltoro", + provider_element_ids=["seg-1"], + brand_id="eltororetail", + ) + + variables = recorder.last_variables + assert variables["where"] == {"providerElementId": {"in": ["seg-1"]}} + assert variables["rateWhere"] == {"thirdPartyDataBrandId": {"eq": "eltororetail"}} + + +def test_query_segment_data_rates_brand_filter_is_independent(graphql_client): + """Filtering by brand alone must not imply a segment filter.""" + client, recorder = graphql_client() + + client.query_segment_data_rates(provider_id="eltoro", brand_id="eltororetail") + + variables = recorder.last_variables + assert "where" not in variables + assert variables["rateWhere"] == {"thirdPartyDataBrandId": {"eq": "eltororetail"}} + + +def test_query_data_rate_batches_lists_all_batches_by_default(graphql_client): + client, recorder = graphql_client() + + client.query_data_rate_batches(provider_id="eltoro") + + variables = recorder.last_variables + assert variables == {"providerId": "eltoro", "first": 10, "after": None} + assert "where" not in variables + + +def test_query_data_rate_batches_filters_to_one_batch(graphql_client): + response = { + "data": { + "thirdPartyDataProvider": { + "dataRateBatches": { + "totalCount": 1, + "nodes": [ + { + "id": "0006A7D", + "processingStatus": "SUCCESSFUL", + "approvalStatus": "APPROVED", + } + ], + } + } + } + } + client, recorder = graphql_client(response) + + page = client.query_data_rate_batches(provider_id="eltoro", batch_id="0006A7D") + + assert recorder.last_variables["where"] == {"id": {"eq": "0006A7D"}} + assert page.total_count == 1 + assert page.nodes[0]["processingStatus"] == "SUCCESSFUL" + + +def cpm_rate(element_id="seg-1", brand_id="brand-1", **overrides): + rate = { + "providerElementId": element_id, + "thirdPartyDataBrandId": brand_id, + "cost": {"cpm": {"cpmCost": {"amount": 2.5, "currencyCode": "USD"}}}, + } + rate.update(overrides) + return rate + + +def test_create_data_rate_batch_wraps_input_and_defaults_to_api_source(graphql_client): + client, recorder = graphql_client() + + client.create_data_rate_batch( + provider_id="eltoro", + data_rates=[cpm_rate()], + ) + + assert recorder.last_variables == { + "input": { + "dataProviderId": "eltoro", + "dataRates": [{"create": cpm_rate()}], + "submissionSource": "API", + } + } + assert "DataRateBatchCreate" in recorder.last_query + + +def test_create_data_rate_batch_wraps_each_rate_under_create(graphql_client): + client, recorder = graphql_client() + + client.create_data_rate_batch( + provider_id="eltoro", + data_rates=[cpm_rate(element_id="seg-1"), cpm_rate(element_id="seg-2")], + submission_source="PARTNER_PORTAL_UI", + ) + + variables = recorder.last_variables["input"] + assert variables["dataRates"] == [ + {"create": cpm_rate(element_id="seg-1")}, + {"create": cpm_rate(element_id="seg-2")}, + ] + assert variables["submissionSource"] == "PARTNER_PORTAL_UI" + + +def test_create_data_rate_batch_splits_payload_into_data_and_errors(graphql_client): + response = { + "data": { + "dataRateBatchCreate": { + "data": { + "id": "0006A7D", + "processingStatus": "NOT_STARTED", + "approvalStatus": "AWAITING_MANUAL_REVIEW", + }, + "errors": None, + } + } + } + client, _ = graphql_client(response) + + result = client.create_data_rate_batch( + provider_id="eltoro", + data_rates=[cpm_rate()], + ) + + assert result.data["id"] == "0006A7D" + assert result.data["approvalStatus"] == "AWAITING_MANUAL_REVIEW" + assert result.errors == [] + + +def test_create_data_rate_batch_surfaces_rejection_errors(graphql_client): + response = { + "data": { + "dataRateBatchCreate": { + "data": None, + "errors": [ + { + "__typename": "UserError", + "message": "At least one data rate object must be provided.", + "field": ["dataRates"], + } + ], + } + } + } + client, _ = graphql_client(response) + + result = client.create_data_rate_batch( + provider_id="eltoro", + data_rates=[cpm_rate()], + ) + + assert result.data is None + assert result.errors[0]["field"] == ["dataRates"] + + +@pytest.mark.parametrize("size", [0, 5001]) +def test_create_data_rate_batch_rejects_batch_outside_limits(graphql_client, size): + client, recorder = graphql_client() + + with pytest.raises(ValueError, match="between 1 and 5000"): + client.create_data_rate_batch( + provider_id="eltoro", + data_rates=[cpm_rate()] * size, + ) + + assert recorder.requests == [] + + +@pytest.mark.parametrize( + "rate, expected", + [ + ( + {"providerElementId": "seg-1", "thirdPartyDataBrandId": "brand-1"}, + r"data_rates\[0\]\.cost is required", + ), + ( + cpm_rate( + cost={ + "cpm": {"cpmCost": {"amount": 2.5, "currencyCode": "USD"}}, + "revShare": {"percentOfMediaCost": 0.12}, + } + ), + r"data_rates\[0\]\.cost must set exactly one", + ), + ( + cpm_rate( + subject={ + "partner": {"partnerId": "p-1"}, + "advertiser": {"advertiserId": "a-1"}, + } + ), + r"data_rates\[0\]\.subject must set exactly one", + ), + ], +) +def test_create_data_rate_batch_enforces_one_of_inputs(graphql_client, rate, expected): + """@oneOf violations are caught before a request goes out, since the server + rejects the whole batch for one malformed entry.""" + client, recorder = graphql_client() + + with pytest.raises(ValueError, match=expected): + client.create_data_rate_batch( + provider_id="eltoro", + data_rates=[rate], + ) + + assert recorder.requests == [] diff --git a/tests/unit/test_graphql_schema.py b/tests/unit/test_graphql_schema.py index e564277..920df90 100644 --- a/tests/unit/test_graphql_schema.py +++ b/tests/unit/test_graphql_schema.py @@ -54,13 +54,18 @@ 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 + from ttd_data.graphql import ( + QUERY_DOCUMENTS, + DataRateOperations, + TaxonomyOperations, + ) - operation_classes = [TaxonomyOperations] + operation_classes = [TaxonomyOperations, DataRateOperations] typed_methods = { name for cls in operation_classes for name in dir(cls) - if name.startswith(("query_", "upsert_")) and callable(getattr(cls, name)) + if name.startswith(("query_", "upsert_", "create_")) + and callable(getattr(cls, name)) } assert typed_methods == set(QUERY_DOCUMENTS)