Metadata-Version: 2.4
Name: dcr-client
Version: 0.4.4
Summary: Python SDK for DCR Data Clean Room Platform
Author: DCR Platform Team
License-Expression: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.33.0
Requires-Dist: urllib3>=2.8.0
Requires-Dist: pydantic>=2.4.0
Requires-Dist: cryptography>=50.0.0
Requires-Dist: certifi>=2026.10.19
Requires-Dist: idna>=3.15
Provides-Extra: dev
Requires-Dist: pytest>=9.0.3; extra == "dev"
Requires-Dist: responses>=0.23; extra == "dev"
Dynamic: license-file

# DCR Platform Python SDK

Python client library for the DCR (Data Clean Room) Platform API. Provides type-safe access to all platform services including data ingestion, privacy-preserving matching, federated query execution, governance, reporting, alerting, and AI-powered analytics.

## Requirements

- Python 3.10+
- DCR Platform instance (self-hosted or managed)

### Compatibility (0.4.4)

0.4.2 raised the lowest versions the SDK accepts, and 0.4.4 keeps them. For
each package below but Python, the new lowest version is past every security
advisory published for it by 2026-10-27.

| Package | 0.3.0 | 0.4.4 |
|---|---|---|
| Python | 3.9 | 3.10 |
| urllib3 | 1.26 | 2.8.0 |
| pydantic | 2.0.0 | 2.4.0 |
| cryptography | 41.0.0 | 50.0.0 |
| certifi | none | 2026.10.19 |
| idna | none | 3.15 |
| setuptools (only to build from source) | 68.0 | 84.1.0 |

- Python 3.9 could not install 0.3.0 either: its requests>=2.33.0 needs Python 3.10.
- If one pip command installs the SDK with a pin on an older urllib3,
  pydantic, cryptography, certifi or idna, or with a package that caps urllib3
  below 2.8.0 (such as `urllib3<2`), pip stops with a resolution error. In
  separate commands, pip gives the later one what it needs, even a urllib3
  below 2.8.0, prints the conflict and exits 0: run `pip check` after each
  install. Raise that pin, or give the SDK its own virtual environment.
- A `--no-build-isolation` build fails on an installed setuptools below 77.
- cryptography 50 publishes no wheel for Intel (x86_64) macOS, 32-bit
  Windows, Windows on ARM64, PyPy 3.10 or Linux with musl 1.1. There, pip
  builds it from source (a C compiler, Rust and the OpenSSL headers).
- Python 3.10 reached its upstream end of life on 2026-10-01; a later SDK
  release may require 3.11.

### Certificate authorities

The client checks TLS certificates against the authorities in the certifi
package. The SDK needs certifi 2026.10.19 or later, so installing it into an
existing environment can upgrade certifi there, for every program in it; a
virtual environment of the SDK's own leaves other programs' certifi as it was.
Compared with 2026.7.22, certifi 2026.10.19 no longer trusts "emSign ECC Root
CA - G3", whose intermediate authorities had keys compromised
(GHSA-6q48-6c7w-9gw2), and 2026.10.23 also drops "emSign ECC Root CA - C3" and
"emSign Root CA - C1". Where a Linux distribution's certifi reads the system's
certificate store, the upgrade replaces it with certifi's own bundle, so an
authority added to that store is no longer trusted. Either way, a call that
worked can then fail with `CERTIFICATE_VERIFY_FAILED`. A platform certificate
that chains to "emSign ECC Root CA - G3" has to be replaced; do not add that
root to a file.

If the platform uses a certificate from a private or corporate authority, or a
proxy on your network inspects TLS, set `REQUESTS_CA_BUNDLE` to a PEM file that
holds that authority's root certificate. requests then uses that file instead
of certifi's bundle in every program that sees the variable, so the certifi
floor above no longer protects those calls. If such a program also calls other
hosts, add the public roots from certifi 2026.10.19 or later
(`python -m certifi` prints its file) or from a system store that no longer
trusts "emSign ECC Root CA - G3", and build the file again after each certifi
upgrade.

### Strict certificate checks

On Python 3.13 and later, urllib3 2 checks strictly, whatever file
`REQUESTS_CA_BUNDLE` names, and installing the SDK brings urllib3 2 to every
program in its environment. It refuses a certificate the authority issued
without an Authority Key Identifier, and an authority certificate, root or
intermediate, without a Key Usage extension (a root made by
`openssl req -x509` with its defaults has none), without a Subject Key
Identifier or, with some OpenSSL builds, whose Basic Constraints are not
marked critical. The error names the reason (such as
`Missing Authority Key Identifier` or
`CA cert does not include key usage extension`). The certificate at fault has
to be issued again: for the last three reasons, that is the authority's own
root or intermediate, not the server's.

## Installation

Install the SDK from PyPI:

```bash
pip install dcr-client
```

pip fetches the SDK's dependencies from PyPI as usual. The distribution is
named `dcr-client` and imports as `dcr_client`. The client is synchronous:
there is no async variant and no `async` extra. The package declares one
extra, `dev`, which holds the test tools for working on the SDK's own source
(see Development); using the SDK does not need it. The installed package
reports its version in `dcr_client.SDK_VERSION` (also `dcr_client.__version__`).

Placino support may also send you a version newer than PyPI's as a package
file (a wheel). Install it with `<version>` replaced by the version in its
name:

```bash
pip install ./dcr_client-<version>-py3-none-any.whl
```

Before you install it, compare the file's SHA-256 with the one Placino support
gives you for it on a different channel from the file's: a hash that came with
the file shows only that the file was not damaged on the way. Compute it with
`shasum -a 256 <file>` on macOS, `sha256sum <file>` on Linux or
`Get-FileHash <file> -Algorithm SHA256` in PowerShell on Windows. Name the
file and that SHA-256 in your requirements file, in the form `pip freeze`
writes:
`dcr-client @ file:///path/to/dcr_client-<version>-py3-none-any.whl#sha256=<the SHA-256 you checked>`.
pip then refuses a file at that path whose SHA-256 differs. Not every
installer checks that part (uv 0.10 does not): with another installer, check
the file's SHA-256 before every install.

## Client-Side Hashing (Zero PII Exposure)

Hash your data locally before uploading — raw PII never leaves your machine. For the full step-by-step guide (including the standalone `dcr-prepare` CLI binary that requires no Python), see the "CLI (`dcr-prepare`)" section of the Integration Guide.

Install the SDK first (see Installation).

```python
from dcr_client.prepare import prepare_csv

# Step 1: Hash your CSV (runs on YOUR machine)
result = prepare_csv(
    input_path="customers.csv",
    output_path="customers_hashed.csv",
    secret_key="hmac-key-from-platform-admin",
    columns={"email": "email", "phone": "phone", "first_name": "name"},
    activation_columns=["email", "phone"],
    activation_platforms=["google", "meta", "tiktok"],
)

# Step 2: Create metadata file (customers_hashed.meta.json)
import json
with open("customers_hashed.meta.json", "w") as f:
    json.dump({
        "activation_key_columns": ["email", "phone"],
        "hash_mode": "pre_hashed",
        "pii_acknowledged_columns": ["first_name"]
    }, f, indent=2)

# Step 3: Upload both files via SFTP to your inbox — the SFTP session's root folder
```

## Quick Start (API Client)

```python
from dcr_client import DCRClient

client = DCRClient(
    base_url="http://localhost:8000",
    username="analyst@acme.com",
    password="secret",
    tenant_id="your-tenant-uuid",
)

# Upload data
upload = client.ingestion.upload("customers.csv")

# Create a clean room and match
room = client.matching.create_room("Campaign Room", participants=["tenant-a", "tenant-b"])
result = client.matching.trigger_match(room.id, "tenant-a", "tenant-b")

# Execute a query
job = client.query.execute_sql(room.id, "SELECT segment, count(*) FROM matched_data GROUP BY segment")
result = client.query.wait_for_result(job.id)
print(result.rows)
```

## Authentication

The SDK supports two authentication methods:

### Username/Password (JWT)

```python
client = DCRClient(
    base_url="http://localhost:8000",
    username="user@example.com",
    password="secret",
    tenant_id="tenant-uuid",
    auto_login=True,  # default: logs in automatically on first API call
)
```

### API Key

```python
client = DCRClient(
    base_url="http://localhost:8000",
    api_key="dcrk_live_abc123...",
    tenant_id="tenant-uuid",
)
```

### Token Refresh

Token refresh is handled automatically. The SDK refreshes the JWT before expiry using the stored refresh token. You can also manage tokens manually:

```python
# Manual login
token = client.login()
print(token.access_token, token.expires_in)

# Check auth status
print(client.is_authenticated)
```

## Multi-Tenant Usage

```python
client = DCRClient(
    base_url="http://localhost:8000",
    username="admin@example.com",
    password="secret",
    tenant_id="tenant-1-uuid",
)

# Work with tenant 1
client.ingestion.list_uploads()

# Switch to tenant 2 (same credentials, different tenant context)
client.switch_tenant("tenant-2-uuid")
client.ingestion.list_uploads()  # returns tenant 2's uploads
```

## API Versioning

```python
# Pin to a specific API version
client = DCRClient(
    base_url="http://localhost:8000",
    username="user@example.com",
    password="secret",
    tenant_id="tenant-uuid",
    api_version="2026-03-01",  # date-based API version
)

# Check server version after login
print(client.server_api_version)
```

---

## API Reference

### DCRClient

The main entry point. All module APIs are accessed as properties.

| Property | Module | Description |
|----------|--------|-------------|
| `client.auth` | `AuthAPI` | Authentication, token management, password reset |
| `client.ingestion` | `IngestionAPI` | File upload, schema inspection |
| `client.matching` | `MatchingAPI` | Clean room management, match execution |
| `client.query` | `QueryAPI` | Query execution, templates, history, export |
| `client.governance` | `GovernanceAPI` | DUAs, audit trail, DSR, webhooks, retention |
| `client.reports` | `ReportsAPI` | Report templates, runs, schedules |
| `client.alerts` | `AlertsAPI` | Alert rules, history, acknowledgement |
| `client.api_keys` | `APIKeysAPI` | API key CRUD, rotation, scopes |
| `client.ai` | `AIAPI` | AI query, dictionary, recommendations |

---

### AuthAPI — `client.auth`

| Method | Description |
|--------|-------------|
| `login(username, password)` | Authenticate and receive JWT tokens |
| `me()` | Get current user info (profile, role, tenants) |
| `refresh(refresh_token)` | Refresh an expired access token |
| `revoke(token)` | Revoke a token (logout) |
| `request_password_reset(username)` | Send password reset email |
| `confirm_password_reset(token, new_password)` | Complete password reset |

---

### IngestionAPI — `client.ingestion`

| Method | Description |
|--------|-------------|
| `upload(filepath)` | Upload a CSV/Parquet file |
| `list_uploads(page, page_size)` | List uploads with pagination |
| `get_upload(upload_id)` | Get upload details and status |
| `get_schema(upload_id)` | Get column schema for an upload |
| `preview(filepath)` | Preview file contents before uploading |
| `upload_encrypted(filepath, wrapped_dek, encrypt_columns, match_columns?)` | Upload a client-side pre-encrypted file |
| `upload_chunked(filepath, dataset_id, *, match_columns, ...)` | Chunked upload for large files |
| `get_encryption_public_key()` | Get the platform RSA public key for client-side encryption |

---

### MatchingAPI — `client.matching`

| Method | Description |
|--------|-------------|
| `create_room(name, participants)` | Create a new clean room |
| `list_rooms(page, page_size)` | List clean rooms |
| `get_room(room_id)` | Get room details |
| `trigger_match(room_id, requesting_tenant, providing_tenant, identifier_type)` | Execute a pairwise match |
| `get_results(room_id)` | Get match results for a room |
| `trigger_multi_match(room_id, participants, identifier_type)` | Execute multi-party match |
| `get_multi_results(room_id)` | Get multi-party match results |
| `close_room(room_id)` | Close a room (no further matches) |
| `add_participant(room_id, tenant_id)` | Add a participant to a room |
| `remove_participant(room_id, tenant_id)` | Remove a participant from a room |

---

### QueryAPI — `client.query`

| Method | Description |
|--------|-------------|
| `execute_sql(room_id, sql, params)` | Execute a raw SQL query |
| `execute_template(room_id, template_id, params)` | Execute a query template |
| `get_status(job_id)` | Check job status |
| `get_result(job_id)` | Get query result (must be completed) |
| `wait_for_result(job_id, timeout, poll_interval)` | Poll until result is ready |
| `estimate(room_id, template_id, custom_sql)` | Estimate query cost/time |
| `export(query_id, format, destination_id)` | Export results (CSV, Parquet, or to destination) |
| `list_templates()` | List available query templates |
| `get_template(template_id)` | Get template details |
| `create_template(name, sql, params)` | Create a reusable query template |
| `list_history(page, page_size)` | List query execution history |

---

### GovernanceAPI — `client.governance`

#### Data Usage Agreements (DUA)

| Method | Description |
|--------|-------------|
| `create_dua(requesting_org_id, providing_org_id, purpose, duration_days, **kwargs)` | Create a new DUA |
| `list_duas()` | List all DUAs |
| `get_dua(dua_id)` | Get DUA details |
| `approve_dua(dua_id, accepted_min_aggregation_size=None)` | Approve a DUA waiting for your approval (the party that did not draft the terms approves them). When the data owner's terms raise the minimum group size (`get_dua(...).k_raise`), name the new size in `accepted_min_aggregation_size`, or the approval is refused (409 `GOV_CONFLICT_048`) |
| `revoke_dua(dua_id)` | Revoke an active DUA |

#### Column Permissions

| Method | Description |
|--------|-------------|
| `set_permissions(dua_id, permissions)` | Set column-level permissions on a DUA |
| `get_permissions(dua_id)` | Get column permissions for a DUA |

#### Audit Trail

| Method | Description |
|--------|-------------|
| `get_audit_trail(limit, offset, event_type)` | Query audit trail events |
| `get_audit_stats()` | Get audit statistics |
| `verify_audit_chain()` | Verify blockchain-anchored audit chain integrity |

#### JIT Approvals

| Method | Description |
|--------|-------------|
| `request_jit_approval(dua_id, query_sql, justification)` | Request just-in-time approval for a query |
| `list_jit_approvals()` | List JIT approval requests |

#### Data Subject Requests (DSR)

| Method | Description |
|--------|-------------|
| `create_dsr_request(request_type, user_hash, description)` | Create a DSR (access/deletion/portability) |
| `list_dsr_requests(status, page, page_size)` | List DSR requests |
| `process_dsr_request(request_id)` | Process a pending DSR request |

#### Webhooks

| Method | Description |
|--------|-------------|
| `create_webhook(url, events, active)` | Subscribe to platform events |
| `list_webhooks()` | List webhook subscriptions |
| `update_webhook(webhook_id, **kwargs)` | Update a webhook |
| `delete_webhook(webhook_id)` | Delete a webhook |
| `test_webhook(webhook_id)` | Send a test event to a webhook |

#### Retention Policies

| Method | Description |
|--------|-------------|
| `list_retention_policies()` | List data retention policies |
| `update_retention_policy(resource_type, **kwargs)` | Update retention settings |
| `preview_retention()` | Preview what would be cleaned up |

---

### ReportsAPI — `client.reports`

| Method | Description |
|--------|-------------|
| `create_template(name, queries, description, **kwargs)` | Create a report template |
| `list_templates()` | List report templates |
| `get_template(template_id)` | Get template details |
| `update_template(template_id, **kwargs)` | Update a template |
| `delete_template(template_id)` | Delete a template |
| `run_report(template_id, params)` | Run a report |
| `list_runs()` | List report runs |
| `get_run(run_id)` | Get run details and output |
| `create_schedule(template_id, cron, **kwargs)` | Schedule recurring reports |
| `list_schedules()` | List schedules |
| `update_schedule(schedule_id, **kwargs)` | Update a schedule |
| `delete_schedule(schedule_id)` | Delete a schedule |

---

### AlertsAPI — `client.alerts`

| Method | Description |
|--------|-------------|
| `create_rule(name, metric_type, threshold_value, comparison, **kwargs)` | Create an alert rule |
| `list_rules()` | List alert rules |
| `update_rule(rule_id, **kwargs)` | Update a rule |
| `delete_rule(rule_id)` | Delete a rule |
| `list_history(status, page, page_size)` | List alert history |
| `acknowledge(alert_id)` | Acknowledge a fired alert |

---

### APIKeysAPI — `client.api_keys`

| Method | Description |
|--------|-------------|
| `list_keys()` | List API keys |
| `create_key(name, scopes, description, expires_in_days, rate_limit_rpm, rate_limit_rpd)` | Create an API key (a client signed in as an administrator; a client built with `api_key=` gets `ForbiddenError`, 403) |
| `get_key(key_id)` | Get key details |
| `revoke_key(key_id)` | Revoke a key |
| `rotate_key(key_id, grace_period_hours)` | Rotate a key with grace period (a client signed in as an administrator, as for `create_key`) |
| `list_scopes()` | List available scopes |

---

### AIAPI — `client.ai`

#### Natural Language Queries

| Method | Description |
|--------|-------------|
| `ask(room_id, question, language, auto_fix, max_fix_attempts)` | High-level: ask a question, get results (auto-fix on error) |
| `query_ai(room_id, message, language)` | Convert natural language to IR |
| `fix_ir(room_id, original_ir, sql, error, ...)` | Fix a failed IR using the self-healing loop |
| `compile_and_execute_ir(room_id, ir, ...)` | Compile IR to SQL and execute |

#### Text-to-SQL & Analysis

| Method | Description |
|--------|-------------|
| `text_to_sql(query, room_id, schema_context)` | Convert text to SQL directly |
| `analyze(prompt, type, room_id)` | Run an AI analysis |
| `get_recommendations(room_id, language, refresh)` | Get AI-powered query recommendations |

#### Data Dictionary

| Method | Description |
|--------|-------------|
| `generate_dictionary(upload_id, dataset_name, ...)` | Generate AI data dictionary |
| `get_dictionary(upload_id)` | Get dictionary content |
| `get_dictionary_status(upload_id)` | Check generation status |
| `update_dictionary_column(upload_id, column_name, updates)` | Update a column's dictionary entry |
| `publish_dictionary(upload_id)` | Publish dictionary |
| `unpublish_dictionary(upload_id)` | Unpublish dictionary |
| `archive_dictionary(upload_id)` | Archive dictionary |
| `wait_for_dictionary(upload_id, timeout, poll_interval)` | Poll until dictionary generation completes |

#### Status

| Method | Description |
|--------|-------------|
| `get_status()` | Check AI service health and model info |
| `get_query_usage(room_id)` | Get AI query usage stats for a room |

---

## Utilities

### Client-Side Hashing (HMAC-SHA256)

Hash identifiers locally before uploading to the platform. This ensures raw PII never leaves your infrastructure.

```python
from dcr_client.hashing import hash_identifier, verify_hash_format

# HMAC-SHA256 hash with your org's secret key
hashed = hash_identifier("user@example.com", secret_key="your-hmac-secret")
print(hashed)  # "a1b2c3d4..."

# Verify hash format
assert verify_hash_format(hashed)

# Legacy SHA-256 + salt (for backwards compatibility)
from dcr_client.hashing import hash_identifier_legacy
legacy = hash_identifier_legacy("user@example.com", salt="your-salt")
```

### Normalization

Normalize identifiers before hashing to ensure consistent matching across organizations.

```python
from dcr_client.normalize import normalize_email, normalize_phone, normalize_name

# Email: lowercase, strip dots from local part (Gmail), strip whitespace
email = normalize_email("  John.Doe@Gmail.com  ")
assert email == "johndoe@gmail.com"

# Phone: strip formatting, ensure E.164 prefix
phone = normalize_phone("+1 (555) 123-4567")
assert phone == "+15551234567"

# Name: lowercase, strip whitespace, normalize unicode
name = normalize_name("  José García  ")
assert name == "josé garcía"

# Generic: auto-detect type
from dcr_client.normalize import normalize_identifier
normalized = normalize_identifier("User@Example.COM", "email")
```

### CSV Preparation

Normalize and hash an entire CSV file in one step — ready for upload. This runs entirely on your machine; raw PII never leaves your infrastructure.

```python
from dcr_client.prepare import prepare_csv

result = prepare_csv(
    input_path="raw_customers.csv",
    output_path="hashed_customers.csv",
    secret_key="your-hmac-secret",
    columns={
        "email": "email",       # column_name: identifier_type
        "phone": "phone",
        "full_name": "name",
    },
    keep_original=False,  # remove raw PII columns from output
)

print(f"Processed {result.rows_processed} rows, hashed {result.columns_hashed} columns")
print(f"Output: {result.output_path}")

# Now upload the hashed file
client.ingestion.upload(result.output_path)
```

### CSV Preparation with Activation Keys

Generate per-platform activation keys alongside identity hashes. Different ad platforms normalize phone numbers differently (Meta strips `+`, Google/TikTok keep it), so each gets its own hash column.

```python
from dcr_client.prepare import prepare_csv

result = prepare_csv(
    input_path="raw_customers.csv",
    output_path="hashed_customers.csv",
    secret_key="your-hmac-secret",
    columns={
        "email": "email",
        "phone": "phone",
        "first_name": "name",
    },
    activation_columns=["email", "phone"],
    activation_platforms=["google", "meta", "tiktok"],
)

print(f"Activation columns: {result.activation_columns_added}")
# ['__ak__email', '__ak__phone_google', '__ak__phone_meta', '__ak__phone_tiktok']
```

Output CSV columns:
| Column | Type | Description |
|--------|------|-------------|
| `email` | HMAC-SHA256 | Identity hash (for identity resolution) |
| `phone` | HMAC-SHA256 | Identity hash |
| `first_name` | HMAC-SHA256 | Identity hash |
| `city` | plain | Non-PII, untouched |
| `__ak__email` | SHA-256 | Activation key (one key for every platform, Gmail-normalised) |
| `__ak__phone_google` | SHA-256 | Google Ads: E.164 with `+` prefix |
| `__ak__phone_meta` | SHA-256 | Meta Ads: digits only, no `+` |
| `__ak__phone_tiktok` | SHA-256 | TikTok Ads: E.164 with `+` prefix |

**Phone normalization per platform:**
| Platform | Input | Normalized | SHA-256 |
|----------|-------|-----------|---------|
| Google Ads | `+90 530 123 4567` | `+905301234567` | `abc...` |
| Meta Ads | `+90 530 123 4567` | `905301234567` | `def...` (different!) |
| TikTok Ads | `+90 530 123 4567` | `+905301234567` | `abc...` (same as Google) |

Upload the hashed file via SFTP with a companion `.meta.json`:
```json
{
  "activation_key_columns": ["email", "phone"],
  "hash_mode": "pre_hashed",
  "pii_acknowledged_columns": ["first_name"]
}
```

---

## Error Handling

All API errors are mapped to typed exception classes with status code and error code:

```python
from dcr_client.exceptions import (
    DCRError,
    AuthenticationError,
    ForbiddenError,
    NotFoundError,
    ConflictError,
    ValidationError,
    RateLimitError,
    InsufficientCreditsError,
    ServerError,
)

try:
    room = client.matching.get_room("nonexistent-id")
except NotFoundError as e:
    print(f"Not found: {e.message}")
    print(f"Status: {e.status_code}")    # 404
    print(f"Error code: {e.error_code}")  # "ROOM_NOT_FOUND"
except RateLimitError as e:
    print(f"Rate limited. Retry after: {e.message}")
except AuthenticationError:
    print("Token expired or invalid")
except DCRError as e:
    print(f"API error {e.status_code}: {e.message}")
```

### API key scopes the methods need (2026-10-30 or later)

A client built with `api_key=` needs, besides the key's analyst scope: `manage:rooms` for `matching.trigger_match` and `matching.trigger_multi_match`; `read:data` for `reports.get_run`; `manage:governance` for `governance.list_dsr_requests` (`read:metadata` and `read:data` no longer pass); `execute:queries` for `ai.query_ai`, `ai.fix_ir`, `ai.text_to_sql`, `ai.analyze` and `ai.get_recommendations` (`ai.ask` already needed it for its compile step). Without it the call raises `ForbiddenError` with `error_code` `AUTH_FORBIDDEN_005` and the scope in its message. `governance.request_jit_approval` is not affected. `manage:quality` (a dataset's quality run and alert actions) is asked only by routes no method of this client calls.

### Exception Hierarchy

| Exception | HTTP Status | When |
|-----------|-------------|------|
| `AuthenticationError` | 401 | Invalid credentials or expired token |
| `ForbiddenError` | 403 | Insufficient permissions. For a client built with `api_key=`: `error_code` `AUTH_FORBIDDEN_005` when the key lacks a scope the route asks for (the message names it: `(insufficient scope: <scope> required)`; issue a key with the scope, scopes cannot be added to a key), and `AUTH_FORBIDDEN_006` on the organization's users, invitations, sign-in IP allowlist and single sign-on, where no API key may make a change or run a single sign-on connection test or discovery, and on creating, changing or rotating an SFTP login; their reads, and deleting an SFTP login, stay open (sign in as an administrator) |
| `NotFoundError` | 404 | Resource not found |
| `ConflictError` | 409 | Duplicate resource or state conflict |
| `ValidationError` | 422 | Invalid request body, or a query whose differential-privacy price alone is above the room's monthly budget (`error_code` `QUERY_DP_002`; no month lets it run) |
| `RateLimitError` | 429 | Rate limit exceeded, or a room's monthly differential-privacy budget is spent (`error_code` `QUERY_DP_001`; it resets at 00:00 UTC on the first of the next month). From 0.4.4, a 429 whose `Retry-After` is at most an hour (a request limit) is waited out and the request sent again, up to three times; one that names more, or no time, is raised at once, and `retry_after` holds the seconds the response named (for `QUERY_DP_001`, the time until the next UTC month). 0.4.3 waits out a 429 that names a `Retry-After`, however long, up to three times on the endpoints it retries (urllib3 2.8 cuts each wait at six hours), so a query sent to a room whose budget is spent can hold a 0.4.3 call for about 18 hours before it raises: upgrade to 0.4.4 |
| `InsufficientCreditsError` | 402 | Billing/credit issue |
| `ServerError` | 500+ | Internal server error |

---

## Examples

The `examples/` directory of the SDK's source contains runnable scripts. They
are not part of the installed package (the wheel holds only the `dcr_client`
package and its metadata).

| File | Description |
|------|-------------|
| `quickstart.py` | End-to-end workflow: login, upload, room, match, query |
| `multi_tenant.py` | Multi-tenant operations: switch tenants, cross-org DUA, match |
| `reports.py` | Report templates, runs, schedules |
| `streaming_events.py` | SSE event streaming listener |
| `prepare_for_upload.py` | Client-side hashing with per-platform activation keys |
| `bulk_upload.py` | Chunked, parallel upload of a large file (`ingestion.upload_chunked`) |

---

## Development

Working on the SDK itself needs access to its source repository; using the
SDK does not (see Installation).

```bash
# Install from the root of a checkout of the source repository
cd sdk/python
pip install -e ".[dev]"

# Run tests
pytest

# Run specific test module
pytest tests/test_matching.py -v
```

## License

MIT. The text ships as LICENSE, and the package metadata states it as
`License-Expression: MIT`, with no License classifier. Tools that read only
the older `License` field or the classifier do not see it: `pip show` before
pip 25 shows an empty License, and pip-licenses before 5.5 shows `UNKNOWN`.
pip-licenses 5.5 and later reads the expression and shows `MIT`, not
`MIT License`, so a license allow-list that names only `MIT License` needs
`MIT` added.
