Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,20 +198,21 @@ client = TtdDatabricksClient.from_params(

Provide your own [`DataClient`](https://github.com/thetradedesk/ttd-data-python/blob/main/src/ttd_data/sdk.py) instance to control the underlying HTTP transport directly.
Use this when you need to configure options not exposed by `from_params()`, or to inject a mock in tests.
The `DataClient` you pass in must carry your API token as `ttd_auth`; every request the SDK makes authenticates with it.

```python
from ttd_data import DataClient
from ttd_databricks_python.ttd_databricks import TtdDatabricksClient

# Configure DataClient with custom HTTP settings.
# Configure DataClient with your API token and custom HTTP settings.
data_client = DataClient(
ttd_auth="<ttd-auth-token>", # your TTD platform API token
server_url="https://custom-server.example.com", # override default server URL
timeout_ms=10000, # request timeout in milliseconds
)

client = TtdDatabricksClient(
data_api_client=data_client,
api_token="<ttd-auth-token>",
spark=spark, # optional; spark variable available from the Databricks notebook runtime
)
```
Expand Down Expand Up @@ -456,15 +457,13 @@ from ttd_data.utils.retries import BackoffStrategy, RetryConfig
from ttd_databricks_python.ttd_databricks import TtdDatabricksClient

data_client = DataClient(
ttd_auth="<ttd-auth-token>", # your TTD platform API token
server_url="https://custom-server.example.com", # override default server URL
timeout_ms=10000, # request timeout in milliseconds
retry_config=RetryConfig("backoff", BackoffStrategy(1000, 60000, 1.5, 3600000), True), # custom retry config
)

client = TtdDatabricksClient(
data_api_client=data_client,
api_token="<ttd-auth-token>",
)
client = TtdDatabricksClient(data_api_client=data_client)
```

In batch processing mode, a `DataClient` singleton is maintained per Spark worker process to enable HTTP connection reuse across batches, reducing overhead during distributed execution.
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ttd-databricks"
version = "0.5.0"
version = "0.6.0"
description = "Client implementation and helper functions for integrating with the TTD Databricks services."
readme = "README.md"
requires-python = ">=3.10"
Expand All @@ -15,7 +15,7 @@ authors = [
]

dependencies = [
"ttd-data>=0.2.6,<0.3.0",
"ttd-data>=0.3.0,<0.4.0",
"pandas>=1.0.5",
"pyarrow>=4.0.0",
"setuptools>=63.4.1",
Expand Down Expand Up @@ -54,6 +54,7 @@ select = [
]
ignore = [
"UP045", # prefer Optional[X] over X | None
"UP007", # prefer Union[X, Y] over X | Y
]

[tool.ruff.lint.isort]
Expand Down
1 change: 0 additions & 1 deletion tests/unit/test_batch_process_early_exit.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
def _make_client(spark: SparkSession) -> TtdDatabricksClient:
return TtdDatabricksClient(
data_api_client=MagicMock(spec=DataClient),
api_token="test-token",
spark=spark,
)

Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_call_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@


def _make_client() -> TtdDatabricksClient:
return TtdDatabricksClient(data_api_client=MagicMock(spec=DataClient), api_token="test-token")
return TtdDatabricksClient(data_api_client=MagicMock(spec=DataClient))


def _make_rows(*dicts: dict[str, Any]) -> list[MagicMock]:
Expand Down
1 change: 0 additions & 1 deletion tests/unit/test_client_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
def _make_client(**kwargs) -> TtdDatabricksClient: # type: ignore[no-untyped-def]
return TtdDatabricksClient(
data_api_client=MagicMock(spec=DataClient),
api_token="test-token",
**kwargs,
)

Expand Down
27 changes: 15 additions & 12 deletions tests/unit/test_process_partitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,36 @@
from collections.abc import Iterator
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Optional

import pytest
from pyspark.sql import SparkSession
from pyspark.sql.types import StringType, StructField, StructType, TimestampType
from ttd_data import ClientConfig
from ttd_data import DataClient

from ttd_databricks_python.ttd_databricks.batching import process_partitions
from ttd_databricks_python.ttd_databricks.contexts import AdvertiserContext
from ttd_databricks_python.ttd_databricks.schemas import get_output_schema

pytestmark = pytest.mark.spark

# Pinned so request counts stay exact: retry_config=None leaves the SDK's retry wrapper
# off, so each batch makes exactly one call even when the stub returns a retryable 5xx.
_TOKEN = "not-a-real-token"

# Snapshotted off a real DataClient rather than hand-built, so the test tracks whatever
# fields ClientConfig carries in the installed ttd-data.
# retry_config=None leaves the SDK's retry wrapper off, keeping request counts exact: each
# batch makes exactly one call even when the stub returns a retryable 5xx.
# Shared by both tests — workers cache one DataClient per process, so a differing config
# in a second test would be silently ignored.
_NO_RETRY_CLIENT_CONFIG = ClientConfig(
server_url=None,
retry_config=None,
timeout_ms=10_000,
uid2_config=None,
)
_NO_RETRY_CLIENT_CONFIG = DataClient(ttd_auth=_TOKEN, retry_config=None, timeout_ms=10_000).config


class _StubHandler(BaseHTTPRequestHandler):
"""Responds with the configured status to every request. Tracks request count to prove the server was hit."""

status_code = 500
request_count = 0
auth_headers: list[Optional[str]] = []
# ThreadingHTTPServer handles each request on its own thread; `+= 1` is a
# non-atomic read-modify-write, so guard it rather than relying on the spark
# fixture staying single-threaded.
Expand All @@ -56,10 +57,12 @@ def configure(cls, status_code: int) -> None:
with cls.counter_lock:
cls.status_code = status_code
cls.request_count = 0
cls.auth_headers = []

def do_POST(self) -> None: # noqa: N802 — required by stdlib BaseHTTPRequestHandler
with type(self).counter_lock:
type(self).request_count += 1
type(self).auth_headers.append(self.headers.get("TTD-Auth"))
body = b'{"Message":"forced error for test"}'
self.send_response(type(self).status_code)
self.send_header("Content-Type", "application/json")
Expand Down Expand Up @@ -112,7 +115,6 @@ def test_mapinpandas_wires_up_and_round_trips(spark: SparkSession, stub_server:
df=input_df,
batch_size=3,
output_schema=output_schema,
api_token="not-a-real-token",
context=context,
parallelism=2,
client_config=_NO_RETRY_CLIENT_CONFIG,
Expand All @@ -127,6 +129,9 @@ def test_mapinpandas_wires_up_and_round_trips(spark: SparkSession, stub_server:
assert result_df.schema.fieldNames() == output_schema.fieldNames()
# 4. Input column values survive Arrow → pandas → dict → pandas → Arrow round-trip.
assert {row["id_value"] for row in result_rows} == set(input_ids)
# 5. The worker's rebuilt DataClient authenticates: ttd_auth travels in the client_config
# snapshot, not as a separate per-call argument.
assert set(_StubHandler.auth_headers) == {_TOKEN}


@pytest.mark.parametrize(
Expand All @@ -149,7 +154,6 @@ def test_401_and_403_stop_partition_without_failing_job(spark: SparkSession, stu
df=input_df,
batch_size=3,
output_schema=output_schema,
api_token="not-a-real-token",
context=context,
parallelism=1,
client_config=_NO_RETRY_CLIENT_CONFIG,
Expand Down Expand Up @@ -182,7 +186,6 @@ def test_other_4xx_fails_only_its_own_batch(spark: SparkSession, stub_server: st
df=input_df,
batch_size=3,
output_schema=output_schema,
api_token="not-a-real-token",
context=context,
parallelism=1,
client_config=_NO_RETRY_CLIENT_CONFIG,
Expand Down
1 change: 0 additions & 1 deletion tests/unit/test_push_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
def _make_client(spark: SparkSession) -> TtdDatabricksClient:
return TtdDatabricksClient(
data_api_client=MagicMock(spec=DataClient),
api_token="test-token",
spark=spark,
)

Expand Down
3 changes: 2 additions & 1 deletion tests/unit/test_uid2_resolutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ def test_raises_with_alter_table_hint_when_column_missing(self) -> None:


def _make_client() -> TtdDatabricksClient:
return TtdDatabricksClient(data_api_client=MagicMock(spec=DataClient), api_token="test-token")
return TtdDatabricksClient(data_api_client=MagicMock(spec=DataClient))


def _make_rows(*dicts: dict) -> list[MagicMock]:
Expand Down Expand Up @@ -420,4 +420,5 @@ def test_batch_process_config_is_derived_from_data_api_client() -> None:

assert client._data_api_client.config.uid2_config is uid2_cfg
assert client._data_api_client.config.retry_config is retry_cfg
assert client._data_api_client.config.ttd_auth == "tok"

19 changes: 7 additions & 12 deletions ttd_databricks_python/ttd_databricks/batching.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,10 @@ def process_partitions(
df: DataFrame,
batch_size: int,
output_schema: StructType,
api_token: str,
context: TTDContext,
client_config: ClientConfig,
parallelism: Optional[int] = None,
data_load_trace_id: Optional[str] = None,
client_config: Optional[ClientConfig] = None,
) -> DataFrame:
"""Process all rows through the API using a single mapInPandas pass.

Expand All @@ -57,7 +56,7 @@ def process_partitions(
on server responses. Falls back to _DEFAULT_PARALLELISM on serverless / Spark
Connect where sparkContext is unavailable.

client_config is a snapshot of the driver DataClient's settings (server_url,
client_config is a snapshot of the driver DataClient's settings (ttd_auth, server_url,
retry_config, timeout_ms, uid2_config), used to rebuild an equivalent DataClient
per worker.

Expand All @@ -81,7 +80,7 @@ def partition_to_results(pandas_df_iter: Iterable[pd.DataFrame]) -> Iterator[pd.
import pandas as pd
from ttd_data import DataClient

from ttd_databricks_python.ttd_databricks.constants import ABORTED_ERROR_CODE, DEFAULT_RETRY_CONFIG
from ttd_databricks_python.ttd_databricks.constants import ABORTED_ERROR_CODE
from ttd_databricks_python.ttd_databricks.utils import (
attach_resolutions,
classify_failure,
Expand All @@ -92,11 +91,9 @@ def partition_to_results(pandas_df_iter: Iterable[pd.DataFrame]) -> Iterator[pd.
global _worker_client
if _worker_client is None:
# Workers rebuild the client from the picklable client_config snapshot;
# DataClient itself can't be cloudpickled.
if client_config is None:
_worker_client = DataClient(timeout_ms=10_000, retry_config=DEFAULT_RETRY_CONFIG)
else:
_worker_client = DataClient.from_config(client_config)
# DataClient itself can't be cloudpickled. The snapshot carries ttd_auth, so the
# rebuilt client authenticates exactly as the driver's client does.
_worker_client = DataClient.from_config(client_config)
client = _worker_client
handler = importlib.import_module(handler_module)

Expand Down Expand Up @@ -139,9 +136,7 @@ def abort(error_code: str, error_message: str) -> pd.DataFrame:
try:
items = handler.build_items(batch_rows)
raw_pii_ids_per_row = handler.collect_raw_pii_ids_per_row(batch_rows)
failed_lines, identity_resolutions = handler.call_api(
client, context, items, api_token, data_load_trace_id
)
failed_lines, identity_resolutions = handler.call_api(client, context, items, data_load_trace_id)
row_results = parse_failed_lines(failed_lines, len(batch_rows))
attach_resolutions(row_results, raw_pii_ids_per_row, identity_resolutions)
except Exception as exc:
Expand Down
2 changes: 0 additions & 2 deletions ttd_databricks_python/ttd_databricks/handlers/advertiser.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ def call_api(
client: DataClient,
context: AdvertiserContext,
items: list[AdvertiserDataItem],
api_token: str,
data_load_trace_id: Optional[str] = None,
) -> tuple[list[Any], dict[str, UID2Resolution]]:
"""Call ingest_advertiser_data. Returns (failed_lines, identity_resolutions).
Expand All @@ -72,7 +71,6 @@ def call_api(
try:
response = client.advertiser.ingest_advertiser_data(
advertiser_id=context.advertiser_id,
ttd_auth=api_token,
data_provider_id=context.data_provider_id if context.data_provider_id is not None else UNSET,
items=items,
data_load_trace_id=data_load_trace_id if data_load_trace_id is not None else UNSET,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ def call_api(
client: DataClient,
context: DeletionOptOutAdvertiserContext,
items: list[PartnerDsrDataItem],
api_token: str,
data_load_trace_id: Optional[str] = None,
) -> tuple[list[Any], dict[str, UID2Resolution]]:
"""Call data_subject_request_advertiser_data.
Expand All @@ -49,7 +48,6 @@ def call_api(

try:
response = client.deletion_opt_out.data_subject_request_advertiser_data(
ttd_auth=api_token,
advertiser_id=context.advertiser_id,
data_provider_id=context.data_provider_id if context.data_provider_id is not None else UNSET,
items=items,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ def call_api(
client: DataClient,
context: DeletionOptOutMerchantContext,
items: list[PartnerDsrDataItem],
api_token: str,
data_load_trace_id: Optional[str] = None,
) -> tuple[list[Any], dict[str, UID2Resolution]]:
"""Call data_subject_request_merchant_data.
Expand All @@ -49,7 +48,6 @@ def call_api(

try:
response = client.deletion_opt_out.data_subject_request_merchant_data(
ttd_auth=api_token,
merchant_id=context.merchant_id,
items=items,
data_load_trace_id=data_load_trace_id if data_load_trace_id is not None else UNSET,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ def call_api(
client: DataClient,
context: DeletionOptOutThirdPartyContext,
items: list[PartnerDsrDataItem],
api_token: str,
data_load_trace_id: Optional[str] = None,
) -> tuple[list[Any], dict[str, UID2Resolution]]:
"""Call data_subject_request_third_party_data.
Expand All @@ -49,7 +48,6 @@ def call_api(

try:
response = client.deletion_opt_out.data_subject_request_third_party_data(
ttd_auth=api_token,
data_provider_id=context.data_provider_id,
brand_id=context.brand_id if context.brand_id is not None else UNSET,
items=items,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ def call_api(
client: DataClient,
context: OfflineConversionContext,
items: list[OfflineConversionDataItem],
api_token: str,
data_load_trace_id: Optional[str] = None,
) -> tuple[list[Any], dict[str, UID2Resolution]]:
"""Call ingest_offline_conversion_data. Returns (failed_lines, identity_resolutions).
Expand All @@ -136,7 +135,6 @@ def call_api(

try:
response = client.offline_conversion.ingest_offline_conversion_data(
ttd_auth=api_token,
data_provider_id=context.data_provider_id,
user_id_array_metadata_format=["type", "id"] if has_user_id_array else UNSET,
items=items,
Expand Down
2 changes: 0 additions & 2 deletions ttd_databricks_python/ttd_databricks/handlers/third_party.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ def call_api(
client: DataClient,
context: ThirdPartyContext,
items: list[ThirdPartyDataItem],
api_token: str,
data_load_trace_id: Optional[str] = None,
) -> tuple[list[Any], dict[str, UID2Resolution]]:
"""Call ingest_third_party_data. Returns (failed_lines, identity_resolutions).
Expand All @@ -71,7 +70,6 @@ def call_api(

try:
response = client.third_party.ingest_third_party_data(
ttd_auth=api_token,
data_provider_id=context.data_provider_id,
items=items,
is_user_id_already_hashed=context.is_user_id_already_hashed,
Expand Down
Loading
Loading