diff --git a/.github/workflows/docs-dispatch.yml b/.github/workflows/docs-dispatch.yml new file mode 100644 index 0000000..d01f7e3 --- /dev/null +++ b/.github/workflows/docs-dispatch.yml @@ -0,0 +1,58 @@ +name: Docs dispatch + +# Fires on push to main when content that affects the published docs site +# changes (docs/, README, or CHANGELOG). Notifies makegov/docs via +# repository_dispatch so the docs site rebuilds without waiting for someone +# to push to the composer. +# +# tango-python is a `coloc-source` repo: its docs/ folder is the authoritative +# source for the Python SDK pages on docs.makegov.com (see makegov/docs#15). +# +# Required secrets: +# DOCS_DISPATCH_TOKEN — GitHub token with contents:write on makegov/docs. +# +# Required variables (optional): +# DOCS_TARGET_REPO — override the dispatch target (default: makegov/docs). + +on: + push: + branches: + - main + paths: + - "docs/**" + - "README.md" + - "CHANGELOG.md" + workflow_dispatch: + +jobs: + dispatch: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Detect changed paths + id: changes + run: | + set -euo pipefail + base=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD) + external=$(git diff --name-only "$base" HEAD -- 'docs' 'README.md' 'CHANGELOG.md' | paste -sd, -) + { + echo "external=$external" + echo "has_external=$([ -n "$external" ] && echo true || echo false)" + } >> "$GITHUB_OUTPUT" + + - name: Dispatch to docs composer (makegov/docs) + if: steps.changes.outputs.has_external == 'true' + env: + GH_TOKEN: ${{ secrets.DOCS_DISPATCH_TOKEN }} + TARGET: ${{ vars.DOCS_TARGET_REPO || 'makegov/docs' }} + run: | + gh api "repos/$TARGET/dispatches" \ + -f event_type=external_updated \ + -f "client_payload[source_repo]=${{ github.repository }}" \ + -f "client_payload[source_ref]=${{ github.sha }}" \ + -f "client_payload[changed_paths]=${{ steps.changes.outputs.external }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f7efe..d6f6e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,67 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [1.0.0] - 2026-05-13 + +> First stable release. `tango-python` is now at full API parity with the +> Tango HTTP surface, the legacy subject-based webhook subscription +> mechanism has been removed in favor of filter alerts, the shape parser +> agrees byte-for-byte with the server's expand-alias handling, and the +> SDK's docs are auto-published to `docs.makegov.com/sdks/python/` via the +> composer pipeline (makegov/docs#15 / makegov/docs#16). From `1.x` on, +> we'll only do breaking changes on a major bump. +> +> Originally tracked as: API parity (PR #25), subject-based webhook +> removal (PR #27 / issue #2275), shape-validator alias support (PR #28 / +> issue #2266), and the docs-only content port (makegov/docs#16). + +### Added +- `ordering` parameter on `list_forecasts`, `list_grants`, `list_subawards`, `list_gsa_elibrary_contracts`, and `list_opportunities`. Prefix with `-` for descending. Closes a parity gap with the API surface (these endpoints all accept `?ordering=` server-side). +- `create_webhook_endpoint` accepts `name=` (keyword-only) and now **requires** it. The Tango API enforces unique `(user, name)` on endpoints; omitting `name` returns a 400 server-side, so the SDK raises `TangoValidationError` client-side instead of round-tripping. (0.7.0 — never publicly released — emitted a `DeprecationWarning` instead.) +- `update_webhook_endpoint` accepts `name=` for renaming an endpoint. +- Webhook alerts (filter subscriptions): `list_webhook_alerts`, `get_webhook_alert`, `create_webhook_alert`, `update_webhook_alert`, `delete_webhook_alert` — the canonical write surface over `/api/webhooks/alerts/`. New `WebhookAlert` dataclass exported from the top-level package. +- `resolve(name, target_type, ...)` — POST `/api/resolve/` to rank entity / organization candidates from a free-text name. Returns `ResolveResult` with `ResolveCandidate` entries (both exported). +- `validate(identifier_type, value)` — POST `/api/validate/` to validate the format of a PIID, solicitation number, or UEI. Returns `ValidateResult` (exported). +- Reference data: `list_departments`, `get_department`, `list_psc`, `get_psc`, `get_psc_metrics`, `get_naics`, `get_naics_metrics`, `get_business_type`, `list_assistance_listings`, `get_assistance_listing`, `list_mas_sins`, `get_mas_sin`. +- Entity sub-resources: `list_entity_contracts`, `list_entity_idvs`, `list_entity_otas`, `list_entity_otidvs`, `list_entity_subawards`, `list_entity_lcats`, `get_entity_metrics`. All shape-aware where the underlying endpoint supports shaping. +- IDV sub-resources: `list_idv_lcats`. +- Agency sub-resources: `list_agency_awarding_contracts`, `list_agency_funding_contracts`. +- Misc: `search_opportunity_attachments(q, top_k, include_extracted_text)` for `/api/opportunities/attachment-search/`; `get_version()` for `/api/version/`; `list_api_keys()` for `/api/api-keys/`. + +### Changed +- `create_webhook_alert` accepts `endpoint=` (keyword-only). Required for accounts with multiple webhook endpoints; auto-resolves for single-endpoint accounts. Closes the multi-endpoint smoke-test gap (tango#2256). +- `test_webhook_delivery` now sends the canonical `endpoint` body key instead of the deprecated `endpoint_id` alias (tango#2252). The Python kwarg name stays `endpoint_id=` for backwards compatibility; the wire payload is what changed. +- **`generate_signature(body, secret)` now returns the full wire form `"sha256="`** instead of bare hex. Callers can assign the return value directly to the `X-Tango-Signature` header without wrapping in a format string. This is a breaking change for code that relied on the bare-hex return; pass it through `parse_signature_header()` to recover the previous form. `verify_signature` accepts both prefixed and bare-hex inputs (unchanged), so receivers continue to work either way. + +### Removed +- **Subject-based webhook subscription surface** (tango#2275). Migrate to `create_webhook_alert(...)` and the alerts API. + - Methods: `list_webhook_subscriptions`, `get_webhook_subscription`, `create_webhook_subscription`, `update_webhook_subscription`, `delete_webhook_subscription`. + - Dataclasses: `WebhookSubscription`, `WebhookSubjectTypeDefinition`. Both are no longer exported from the top-level `tango` package — importing them raises `ImportError`. + - Fields: `default_subject_type` removed from `WebhookEventType`; `subject_types` and `subject_type_definitions` removed from `WebhookEventTypesResponse`. The server's `/api/webhooks/event-types/` response no longer carries these. + - CLI: the entire `tango webhooks subscriptions` Click subgroup (`list` / `get` / `create` / `delete`). Use the SDK's `client.create_webhook_alert(...)` etc. directly — there is no CLI subgroup for alerts. +- `ordering` kwarg from `list_notices` and `list_protests`. The notices and protests viewsets reject every `?ordering=` value at runtime (tango#2254); the kwarg silently sent unsupported values. Other five list methods retain `ordering`. + +### Fixed +- `TangoClient._post()` and `_patch()` accept both `json_data=` (positional) and `json=` (keyword) for backward compatibility. Internal callers and docs examples that use `json=` no longer fail with `TypeError`. Passing **both** now raises `TangoValidationError` rather than silently preferring one — that ambiguity would hide caller bugs. +- `get_psc_metrics` / `get_naics_metrics` / `get_entity_metrics` docstrings — `period_grouping` values are `"month"` / `"quarter"` / `"year"` (the path-segment values the API accepts), not `"monthly"` / `"quarterly"`. +- `docs/API_REFERENCE.md#get_agency` — example uses `client.get_agency("GSA")` consistently and notes the parameter accepts CGAC / FPDS / short code / abbreviation / canonical name. +- `README.md` Quick Start — `get_agency()` returns an `Agency` dataclass, so the example uses attribute access (`agency.name`) instead of `agency['name']` which would `TypeError`. +- `scripts/smoke_api_parity.py` — `list_business_types(limit=1)` is now wrapped in the `run(...)` helper so a failure on that call records FAIL instead of aborting the smoke run. +- `tango webhooks endpoints create` CLI now accepts and requires `--name` (passed through to `create_webhook_endpoint(name=...)`). Previously the option was absent, meaning the CLI could never set a custom endpoint name and every call would 400 server-side (the server enforces `unique(user, name)`). +- `WebhookAlert.query_type` and `WebhookAlert.filters` tightened from `Optional` to non-optional (`str` and `dict[str, Any]` respectively). Legacy nullable rows were purged by the tango#2275 migration; the server model and serializer guarantee non-null values for all current data. `WebhookAlert.status` narrowed from `str` to `Literal["active", "paused"]` — the server serializer produces exactly those two values. +- **Shape validator agrees with server on `naics(...)` / `psc(...)` expansions.** The client-side `ShapeParser.validate()` previously rejected the canonical `shape=naics(code,description)` form (which the server has always accepted) and also rejected the alias `shape=naics_code(code,description)`. The parser now mirrors the server's `_EXPAND_ALIASES` (introduced in Tango PR makegov/tango#2259) and rewrites `naics_code(...)` / `psc_code(...)` to their canonical `naics(...)` / `psc(...)` form at parse time. Bare scalar leaves (`shape=naics_code` / `shape=psc_code`) are left untouched and still return the raw column value, matching the server. Schemas for `Contract`, `Forecast`, `Opportunity`, `Notice`, and `Vehicle` gained explicit `naics` / `psc` expand entries backed by the existing `CodeDescription` nested model. Fixes makegov/tango#2266. +- **`Subaward` schema matches the server's `SubawardSerializer`.** The previous `SUBAWARD_SCHEMA` declared two fields the server has never exposed (`id`, `amount`) and was missing every real field on the resource — including `piid`, `key`, `awarding_office` / `funding_office` / `place_of_performance` / `subaward_details` / `fsrs_details` / `highly_compensated_officers` / `usaspending_permalink`, and the denormalized `prime_awardee_*` / `recipient_*` lookup columns. Shape strings that referenced any real field (e.g. `shape="piid"`) would fail client-side validation with `unknown_field`, and conversely the SDK happily passed `shape="id"` / `shape="amount"` through to the server, where they were rejected. `SUBAWARD_SCHEMA` is now derived directly from `awards.serializers.subawards.SubawardSerializer` and the resource's runtime `available_fields`. The `Subaward` dataclass in `tango/models.py` was updated to match. New nested schemas `SubawardDetails`, `FsrsDetails`, `SubawardPlaceOfPerformance`, and `HighlyCompensatedOfficer` are registered so the corresponding shape expansions validate end-to-end. + +### Documentation +- New `docs/ERRORS.md` — full exception hierarchy, recovery patterns, and the shape-error classes (`ShapeValidationError`, `ShapeParseError`, `TypeGenerationError`, `ModelInstantiationError`). Ported from `docs.makegov.com/sdks/python/errors.md` ahead of the docs-site auto-pull cutover (makegov/docs#15 / makegov/docs#16). +- New `docs/PAGINATION.md` — page-based vs cursor-based strategies, iteration patterns, and the `PaginatedResponse` field reference. Ported from `docs.makegov.com/sdks/python/pagination.md`. +- New `docs/CLIENT.md` — `TangoClient` constructor reference, `rate_limit_info` / `last_response_headers` properties, and retry-semantics note (the SDK has no built-in retry). Ported from `docs.makegov.com/sdks/python/client.md`. + +### CI +- New `.github/workflows/docs-dispatch.yml` — fires on push to `main` when `docs/**`, `README.md`, or `CHANGELOG.md` changes and dispatches `external_updated` at `makegov/docs` so the public docs site rebuilds with the latest SDK content. Required for the makegov/docs#15 auto-pull pipeline. + ## [0.6.0] - 2026-05-07 ### Added diff --git a/README.md b/README.md index 4964e4e..fd766d7 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,14 @@ print(f"Found {agencies.count} agencies") # Get specific agency agency = client.get_agency("GSA") -print(f"Agency: {agency['name']}") +print(f"Agency: {agency.name}") # Search contracts contracts = client.list_contracts( limit=10 ) +``` + ## Authentication Most endpoints require an API key. You can obtain one from the [Tango API portal](https://tango.makegov.com). @@ -204,13 +206,13 @@ opportunities = client.list_opportunities(agency="DOD", active=True, limit=25) ### Notices ```python -notices = client.list_notices(agency="DOD", notice_type="award", limit=25) +notices = client.list_notices(agency="DOD", notice_type="Presolicitation", limit=25) ``` ### Grants ```python -grants = client.list_grants(agency="HHS", status="forecasted", limit=25) +grants = client.list_grants(agency="HHS", status="F", limit=25) # F = Forecasted ``` ### Protests @@ -230,12 +232,45 @@ contract = client.get_gsa_elibrary_contract("UUID") ### Reference Data ```python -# Offices, organizations, NAICS, subawards, business types +# Offices, organizations, NAICS, PSC, subawards, business types offices = client.list_offices(search="acquisitions") organizations = client.list_organizations(level=1) naics = client.list_naics(search="software") +get_naics = client.get_naics("541511") +psc = client.list_psc() subawards = client.list_subawards(prime_uei="UEI123") business_types = client.list_business_types() +mas_sins = client.list_mas_sins() +assistance = client.list_assistance_listings() +departments = client.list_departments() +``` + +### Resolve / Validate + +```python +# Resolve a name to entity/org candidates +result = client.resolve(name="Lockheed Martin", target_type="entity") +for c in result.candidates: + print(c.identifier, c.display_name) + +# Validate an identifier +result = client.validate(identifier_type="uei", value="ABCDEF123456") +``` + +### IT Dashboard + +```python +investments = client.list_itdashboard_investments(search="cloud", limit=25) +investment = client.get_itdashboard_investment("023-000001234") +``` + +### Entity Sub-resources + +```python +contracts = client.list_entity_contracts("ABCDEF123456", limit=25) +idvs = client.list_entity_idvs("ABCDEF123456") +otas = client.list_entity_otas("ABCDEF123456") +metrics = client.get_entity_metrics("ABCDEF123456", months=12, period_grouping="month") ``` ## Pagination @@ -253,9 +288,9 @@ print(f"Previous page URL: {response.previous}") for contract in response.results: print(contract['description']) -# Get next page +# Get next page (contracts use keyset/cursor pagination) if response.next: - next_response = client.list_contracts(page=2, limit=25) + next_response = client.list_contracts(cursor=response.cursor, limit=25) ``` ## Error Handling @@ -282,7 +317,7 @@ except TangoNotFoundError: print("Resource not found") except TangoValidationError as e: print(f"Invalid parameters: {e.message}") - print(f"Details: {e.details}") + print(f"Details: {e.response_data}") except TangoRateLimitError: print("Rate limit exceeded") except TangoAPIError as e: @@ -314,22 +349,18 @@ contracts = client.list_contracts( ### Flattened Responses -Enable flattening to get dot-notation field names: +The `flat=True` parameter is passed to the API, which returns dot-notation keys in the raw response. The SDK still wraps the result in a `ShapedModel` — access nested fields via attribute or dict syntax, not dot-notation string keys: ```python contracts = client.list_contracts( shape="key,piid,recipient(display_name,uei)", flat=True ) -# Returns: {"key": "...", "piid": "...", "recipient.display_name": "...", "recipient.uei": "..."} - -# Flatten arrays with indexed keys -contracts = client.list_contracts( - shape="key,transactions(*)", - flat=True, - flat_lists=True -) -# Returns: {"key": "...", "transactions.0.action_date": "...", "transactions.0.obligated": "..."} +for contract in contracts.results: + # Attribute access + print(contract.recipient.display_name) + # Dict access (nested, not flat string keys) + print(contract['recipient']['display_name']) ``` ### Webhook Tooling @@ -353,9 +384,8 @@ tango webhooks simulate --secret $SECRET --event-type entities.updated # sign + tango webhooks simulate --secret $SECRET --event-type entities.updated \ --to http://127.0.0.1:8011/tango/webhooks # also POST -# Manage real subscriptions and endpoints -tango webhooks endpoints create|list|get|delete -tango webhooks subscriptions create|list|get|delete +# Manage delivery endpoints +tango webhooks endpoints create|list|get|delete # Force a real test delivery from Tango tango webhooks trigger diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 8416e25..2c50b03 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -68,8 +68,9 @@ agencies = client.list_agencies(page=1, limit=25) **Parameters:** - `page` (int): Page number (default: 1) - `limit` (int): Results per page (default: 25, max: 100) +- `search` (str, optional): Search term to filter agencies by name -**Returns:** [PaginatedResponse](#paginatedresponse) with agency dictionaries +**Returns:** [PaginatedResponse](#paginatedresponse) with `Agency` dataclass objects **Example:** ```python @@ -77,7 +78,7 @@ agencies = client.list_agencies(limit=10) print(f"Found {agencies.count} total agencies") for agency in agencies.results: - print(f"{agency['code']}: {agency['name']}") + print(f"{agency.code}: {agency.name}") ``` ### get_agency() @@ -85,21 +86,21 @@ for agency in agencies.results: Get a specific agency by code. ```python -agency = client.get_agency(code="GSA") +agency = client.get_agency("GSA") ``` **Parameters:** -- `code` (str): Agency code (e.g., "GSA", "DOD", "HHS") +- `code` (str): Agency identifier. Accepts CGAC ("097"), FPDS code ("4712"), short code ("GSA"), abbreviation, or canonical name. See [Federal agency hierarchy](https://docs.makegov.com/api-reference/concepts/federal-agency-hierarchy/) for code semantics. -**Returns:** Dictionary with agency details +**Returns:** `Agency` dataclass with agency details **Example:** ```python gsa = client.get_agency("GSA") -print(f"Name: {gsa['name']}") -print(f"Abbreviation: {gsa.get('abbreviation', 'N/A')}") -if gsa.get('department'): - print(f"Department: {gsa['department']['name']}") +print(f"Name: {gsa.name}") +print(f"Abbreviation: {gsa.abbreviation or 'N/A'}") +if gsa.department: + print(f"Department: {gsa.department.name}") ``` **Agency Fields:** @@ -212,7 +213,7 @@ Search and filter contracts with extensive options. ```python contracts = client.list_contracts( - page=1, + cursor=None, # keyset pagination token (not page number) limit=25, shape=None, flat=False, @@ -253,7 +254,7 @@ contracts = client.list_contracts( ``` **Common Parameters:** -- `page` (int): Page number +- `cursor` (str, optional): Keyset pagination token from `response.next` (contracts use keyset pagination, not page numbers) - `limit` (int): Results per page (max: 100) - `shape` (str): Fields to return (see [Shaping Guide](SHAPES.md)) - `flat` (bool): Flatten nested objects to dot-notation keys @@ -724,10 +725,10 @@ entities = client.list_entities( entities = client.list_entities(search="Booz Allen", limit=20) for entity in entities.results: - print(f"{entity['display_name']}") + print(f"{entity['legal_business_name']}") print(f"UEI: {entity.get('uei', 'N/A')}") if entity.get('business_types'): - print(f"Types: {', '.join(entity['business_types'])}") + print(f"Types: {', '.join(bt['code'] for bt in entity['business_types'])}") ``` ### get_entity() @@ -978,7 +979,7 @@ notices = client.list_notices( **Example:** ```python -notices = client.list_notices(agency="GSA", notice_type="award", limit=20) +notices = client.list_notices(agency="GSA", notice_type="Presolicitation", limit=20) for notice in notices.results: print(f"{notice['title']}") @@ -1050,7 +1051,7 @@ grants = client.list_grants( **Example:** ```python -grants = client.list_grants(agency="HHS", status="forecasted", limit=20) +grants = client.list_grants(agency="HHS", status="F", limit=20) # F = Forecasted, P = Posted for grant in grants.results: print(f"{grant['title']}") @@ -1234,7 +1235,7 @@ business_types = client.list_business_types(page=1, limit=25) business_types = client.list_business_types(limit=50) for biz_type in business_types.results: - print(f"{biz_type['code']}: {biz_type['name']}") + print(f"{biz_type.code}: {biz_type.name}") ``` **Business Type Fields:** @@ -1281,72 +1282,360 @@ naics = client.list_naics( naics = client.list_naics(search="software", limit=10) for code in naics.results: - print(f"{code['code']}: {code['title']}") + print(f"{code['code']}: {code['description']}") +``` + +### get_naics() + +Get a single NAICS code by code string. + +```python +naics = client.get_naics("541511") +``` + +**Returns:** Dictionary with NAICS code details. + +### get_naics_metrics() + +Get computed metrics for a NAICS code. + +```python +metrics = client.get_naics_metrics(code="541511", months=12, period_grouping="month") ``` --- -## Webhooks +## PSC -Webhook APIs let **Large / Enterprise** users manage subscription filters for outbound Tango webhooks. +Product and Service Codes. -> **For testing, signing, and a CLI tool**, see [`docs/WEBHOOKS.md`](WEBHOOKS.md). This section covers SDK method signatures only. +### list_psc() -### list_webhook_event_types() +```python +psc = client.list_psc(page=1, limit=25) +``` -Discover supported `event_type` values and subject types. +### get_psc() ```python -info = client.list_webhook_event_types() -print(info.event_types[0].event_type) +psc = client.get_psc("D302") ``` -### list_webhook_subscriptions() +### get_psc_metrics() ```python -subs = client.list_webhook_subscriptions(page=1, page_size=25) +metrics = client.get_psc_metrics(code="D302", months=12, period_grouping="month") ``` -Notes: +--- + +## MAS SINs + +GSA Multiple Award Schedule Special Item Numbers. + +### list_mas_sins() -- This endpoint uses `page` + `page_size` (tier-capped) rather than `limit`. +```python +sins = client.list_mas_sins(page=1, limit=25) +``` -### get_webhook_subscription() +### get_mas_sin() ```python -sub = client.get_webhook_subscription("SUBSCRIPTION_UUID") +sin = client.get_mas_sin("54151S") ``` -### create_webhook_subscription() +--- + +## Assistance Listings (CFDA) + +Catalog of Federal Domestic Assistance listings. + +### list_assistance_listings() ```python -sub = client.create_webhook_subscription( - "Track specific vendors", - { - "records": [ - {"event_type": "awards.new_award", "subject_type": "entity", "subject_ids": ["UEI123ABC"]}, - {"event_type": "awards.new_transaction", "subject_type": "entity", "subject_ids": ["UEI123ABC"]}, - ] - }, +listings = client.list_assistance_listings(page=1, limit=25) +``` + +### get_assistance_listing() + +```python +listing = client.get_assistance_listing("10.310") +``` + +--- + +## Departments + +### list_departments() + +```python +depts = client.list_departments(page=1, limit=25) +``` + +### get_department() + +```python +dept = client.get_department("097") +``` + +--- + +## Business Types (by code) + +### get_business_type() + +Get a single business type by code. + +```python +bt = client.get_business_type("A6") +``` + +--- + +## IT Dashboard + +Federal IT investments from the OMB IT Dashboard. + +### list_itdashboard_investments() + +```python +investments = client.list_itdashboard_investments( + page=1, + limit=25, + search=None, + agency_code=None, + type_of_investment=None, + # Pro/Business+ tier-gated filters available ) ``` -Notes: +**Notes:** +- Filter tier-gating: `search` is free; `agency_code`, `type_of_investment` require Pro; `agency_name`, `cio_rating`, `performance_risk` require Business+. +- Shape defaults to `ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL`. + +### get_itdashboard_investment() + +```python +investment = client.get_itdashboard_investment("023-000001234") +``` + +--- -- Prefer v2 fields: `subject_type` + `subject_ids`. -- Legacy compatibility: `resource_ids` is accepted as an alias for `subject_ids` (don’t send both). -- Catch-all: `subject_ids: []` means “all subjects” for that record and is **Enterprise-only**. Large tier users must list specific IDs. +## Entity Sub-resources -### update_webhook_subscription() +### list_entity_contracts() ```python -sub = client.update_webhook_subscription("SUBSCRIPTION_UUID", subscription_name="Updated name") +contracts = client.list_entity_contracts("ABCDEF123456", limit=25) ``` -### delete_webhook_subscription() +### list_entity_idvs() ```python -client.delete_webhook_subscription("SUBSCRIPTION_UUID") +idvs = client.list_entity_idvs("ABCDEF123456", limit=25) +``` + +### list_entity_otas() / list_entity_otidvs() + +```python +otas = client.list_entity_otas("ABCDEF123456", limit=25) +otidvs = client.list_entity_otidvs("ABCDEF123456", limit=25) +``` + +### list_entity_subawards() + +```python +subawards = client.list_entity_subawards("ABCDEF123456", limit=25) +``` + +### list_entity_lcats() + +```python +lcats = client.list_entity_lcats("ABCDEF123456", limit=25) +``` + +### get_entity_metrics() + +```python +metrics = client.get_entity_metrics("ABCDEF123456", months=12, period_grouping="month") +``` + +--- + +## IDV LCATs + +### list_idv_lcats() + +```python +lcats = client.list_idv_lcats("GS-00F-XXXX", limit=25) +``` + +--- + +## Agency Sub-resources + +### list_agency_awarding_contracts() + +List contracts where the agency is the awarding agency. + +```python +contracts = client.list_agency_awarding_contracts("4700", limit=25) +``` + +### list_agency_funding_contracts() + +List contracts where the agency is the funding agency. + +```python +contracts = client.list_agency_funding_contracts("4700", limit=25) +``` + +--- + +## Resolve / Validate + +### resolve() + +Resolve a free-text name to ranked entity or organization candidates. + +```python +result = client.resolve( + name="Lockheed Martin", + target_type="entity", # or "organization" + state="MD", # optional + city="Bethesda", # optional + context="defense contractor", # optional +) + +for candidate in result.candidates: + print(candidate.identifier, candidate.display_name) +``` + +**Notes:** +- Free-tier: up to 3 candidates with `identifier` and `display_name`. +- Pro+: up to 5 candidates with additional `match_tier` field. + +### validate() + +Validate the format of a PIID, solicitation number, or UEI. + +```python +result = client.validate(identifier_type="uei", value="ABCDEF123456") +# identifier_type is one of: "piid", "solicitation", "uei" +``` + +**Note:** The parameter is named `identifier_type` (not `type`) to avoid shadowing the Python builtin. + +--- + +## Opportunities (attachments) + +### search_opportunity_attachments() + +Semantic search over opportunity attachments. `q` is required. + +```python +results = client.search_opportunity_attachments( + q="cybersecurity", + top_k=10, + include_extracted_text=False, +) +``` + +**Parameters:** +- `q` (str): Search query (required) +- `top_k` (int, optional): Number of top results to return +- `include_extracted_text` (bool, optional): Whether to include extracted text from attachments in results + +**Returns:** dict with search results + +--- + +## Webhook Alerts + +The Alerts API is the canonical (and only) write surface for webhook subscriptions. Every alert maps to one of the five `alerts.*.match` event types and delivers when its saved-search filters match new or modified records. + +### list_webhook_alerts() + +```python +alerts = client.list_webhook_alerts(page=1, page_size=25) +``` + +### get_webhook_alert() + +```python +alert = client.get_webhook_alert("ALERT_UUID") +``` + +### create_webhook_alert() + +```python +alert = client.create_webhook_alert( + name="New cloud IT contracts", + query_type="contract", + filters={"naics": "541511"}, +) +``` + +For multi-endpoint accounts, pin the delivery target with `endpoint=`: + +```python +alert = client.create_webhook_alert( + name="New cloud IT contracts", + query_type="contract", + filters={"naics": "541511"}, + endpoint="ENDPOINT_UUID", +) +``` + +**Notes:** +- `name` and `query_type` are required. `query_type` is **singular** (e.g. `"contract"`, not `"contracts"`). +- `endpoint=` is optional and only required when the account has multiple webhook endpoints; for single-endpoint accounts the server auto-resolves. + +### update_webhook_alert() + +```python +alert = client.update_webhook_alert("ALERT_UUID", name="Updated name") +``` + +### delete_webhook_alert() + +```python +client.delete_webhook_alert("ALERT_UUID") +``` + +--- + +## Utility + +### get_version() + +```python +version = client.get_version() +``` + +### list_api_keys() + +```python +keys = client.list_api_keys() +``` + +--- + +## Webhooks + +Webhook APIs let **Large / Enterprise** users manage delivery endpoints and discover the supported event-type catalog. Filter subscriptions (alerts) live in the [Webhook Alerts](#webhook-alerts) section above. + +> **For testing, signing, and a CLI tool**, see [`docs/WEBHOOKS.md`](WEBHOOKS.md). This section covers SDK method signatures only. + +### list_webhook_event_types() + +Discover supported `event_type` values. + +```python +info = client.list_webhook_event_types() +print(info.event_types[0].event_type) ``` ### list_webhook_endpoints() @@ -1384,10 +1673,10 @@ print(result.success, result.status_code) ### get_webhook_sample_payload() -Fetch Tango-shaped sample deliveries (and sample subscription request bodies). +Fetch Tango-shaped sample deliveries. ```python -sample = client.get_webhook_sample_payload(event_type="awards.new_award") +sample = client.get_webhook_sample_payload(event_type="alerts.contract.match") print(sample["event_type"]) ``` @@ -1396,7 +1685,7 @@ print(sample["event_type"]) The API does not currently expose a public `/api/webhooks/deliveries/` or redelivery endpoint. Use: - `test_webhook_delivery()` for connectivity checks -- `get_webhook_sample_payload()` for building handlers + subscription payloads +- `get_webhook_sample_payload()` for building handlers ### Receiving webhooks (signature verification) @@ -1428,7 +1717,7 @@ The `tango.webhooks` subpackage adds testing and developer-tooling primitives on ```python from tango.webhooks import ( verify_signature, # (body: bytes, secret: str, header: str | None) -> bool - generate_signature, # (body: bytes, secret: str) -> str (lowercase hex) + generate_signature, # (body: bytes, secret: str) -> str ("sha256=" wire form) parse_signature_header, # (header: str | None) -> str | None (strips "sha256=") SIGNATURE_HEADER, # "X-Tango-Signature" SIGNATURE_PREFIX, # "sha256=" @@ -1440,7 +1729,8 @@ from tango.webhooks import ( A stdlib-based local HTTP receiver, useful in tests and during local development. ```python -from tango.webhooks import WebhookReceiver, Delivery +from tango import WebhookReceiver, Delivery # exported from top-level tango package +# or: from tango.webhooks.receiver import WebhookReceiver, Delivery with WebhookReceiver(secret="dev").run() as rx: # ... cause something to POST to rx.url ... @@ -1518,26 +1808,28 @@ print(f"Results on this page: {len(contracts.results)}") for contract in contracts.results: print(contract['piid']) -# Check for more pages +# Check for more pages (contracts use keyset pagination via cursor) if contracts.next: - next_page = client.list_contracts(page=2, limit=25) + next_page = client.list_contracts(cursor=contracts.cursor, limit=25) ``` -**Pagination Example:** +**Pagination Example (contracts use keyset pagination, not page numbers):** ```python -page = 1 +cursor = None all_results = [] +page_num = 1 while True: - response = client.list_contracts(page=page, limit=100) + response = client.list_contracts(cursor=cursor, limit=100) all_results.extend(response.results) - print(f"Page {page}: {len(response.results)} results") + print(f"Batch {page_num}: {len(response.results)} results") if not response.next: break - page += 1 + cursor = response.cursor # use cursor for next page + page_num += 1 print(f"Total collected: {len(all_results)} results") ``` @@ -1565,7 +1857,7 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE) | Constant | Used by | Description | |----------|---------|-------------| -| `CONTRACTS_MINIMAL` | `list_contracts`, `search_contracts` | key, piid, award_date, recipient(display_name), description, total_contract_value | +| `CONTRACTS_MINIMAL` | `list_contracts` | key, piid, award_date, recipient(display_name), description, total_contract_value | | `ENTITIES_MINIMAL` | `list_entities` | uei, legal_business_name, cage_code, business_types | | `ENTITIES_COMPREHENSIVE` | `get_entity` | Full entity profile (addresses, naics, psc, obligations, etc.) | | `FORECASTS_MINIMAL` | `list_forecasts` | id, title, anticipated_award_date, fiscal_year, naics_code, status | @@ -1574,15 +1866,18 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE) | `GRANTS_MINIMAL` | `list_grants` | grant_id, opportunity_number, title, status(*), agency_code | | `IDVS_MINIMAL` | `list_idvs`, `list_vehicle_awardees` | key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated, idv_type | | `IDVS_COMPREHENSIVE` | `get_idv` | Full IDV with offices, place_of_performance, competition, transactions, etc. | -| `VEHICLES_MINIMAL` | `list_vehicles` | uuid, solicitation_identifier, organization_id, awardee_count, order_count, vehicle_obligations, vehicle_contracts_value, solicitation_title, solicitation_date | +| `VEHICLES_MINIMAL` | `list_vehicles` | uuid, solicitation_identifier, is_synthetic_solicitation, program_acronym, organization_id, organization, vehicle_type, description, idv_count, awardee_count, order_count, total_obligated, vehicle_obligations, vehicle_contracts_value, latest_award_date, solicitation_title, solicitation_date | | `VEHICLES_COMPREHENSIVE` | `get_vehicle` | Full vehicle with competition_details, fiscal_year, set_aside, etc. | | `VEHICLE_AWARDEES_MINIMAL` | `list_vehicle_awardees` | uuid, key, piid, award_date, title, order_count, idv_obligations, idv_contracts_value, recipient(display_name,uei) | -| `ORGANIZATIONS_MINIMAL` | `list_organizations`, `list_organization_offices` | key, fh_key, name, level, type, short_name | +| `ORGANIZATIONS_MINIMAL` | `list_organizations` | key, fh_key, name, level, type, short_name | | `OTAS_MINIMAL` | `list_otas` | key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated | | `OTIDVS_MINIMAL` | `list_otidvs` | key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated, idv_type | | `SUBAWARDS_MINIMAL` | `list_subawards` | award_key, prime_recipient(uei,display_name), subaward_recipient(uei,display_name) | | `GSA_ELIBRARY_CONTRACTS_MINIMAL` | `list_gsa_elibrary_contracts` | uuid, contract_number, schedule, recipient(display_name,uei), idv(key,award_date) | | `PROTESTS_MINIMAL` | `list_protests` | case_id, case_number, title, source_system, outcome, filed_date | +| `VEHICLE_ORDERS_MINIMAL` | `list_vehicle_orders` | key, piid, award_date, recipient(display_name,uei), total_contract_value, obligated | +| `ITDASHBOARD_INVESTMENTS_MINIMAL` | `list_itdashboard_investments` | Minimal IT Dashboard investment fields | +| `ITDASHBOARD_INVESTMENTS_COMPREHENSIVE` | `get_itdashboard_investment` | Full investment fields: uii, agency_code, agency_name, bureau_code, bureau_name, investment_title, type_of_investment, part_of_it_portfolio, updated_time, url | All predefined shapes are validated at SDK release time (see [Developer Guide](DEVELOPERS.md#sdk-conformance-maintainers)). For custom shapes, see the [Shaping Guide](SHAPES.md). @@ -1674,8 +1969,8 @@ try: ) except TangoValidationError as e: print(f"Validation error: {e.message}") - if e.details: - print(f"Details: {e.details}") + if e.response_data: + print(f"Details: {e.response_data}") # Handle rate limiting try: @@ -1719,15 +2014,17 @@ See [Shaping Guide](SHAPES.md) for details. Don't fetch all results at once - paginate responsibly: ```python -# ✅ Good - process page by page -page = 1 -while page <= 10: # Limit to 10 pages - contracts = client.list_contracts(page=page, limit=100) +# ✅ Good - process batch by batch (contracts use keyset/cursor pagination) +cursor = None +batches = 0 +while batches < 10: # Limit to 10 batches + contracts = client.list_contracts(cursor=cursor, limit=100) process_contracts(contracts.results) if not contracts.next: break - page += 1 + cursor = contracts.cursor + batches += 1 ``` ### 3. Use Filters to Narrow Results diff --git a/docs/CLIENT.md b/docs/CLIENT.md new file mode 100644 index 0000000..0598d7a --- /dev/null +++ b/docs/CLIENT.md @@ -0,0 +1,73 @@ +# Client Configuration + +`TangoClient` is the entry point for every API call. This guide covers the constructor, the rate-limit and response-inspection properties, and how the client handles authentication and transport. + +For per-method signatures, see [`API_REFERENCE.md`](API_REFERENCE.md). For error handling, see [`ERRORS.md`](ERRORS.md). For shaping responses, see [`SHAPES.md`](SHAPES.md). + +## Constructor + +```python +from tango import TangoClient + +client = TangoClient( + api_key="your-api-key", # or set TANGO_API_KEY env var + base_url="https://tango.makegov.com", # default + user_agent="my-app/1.0", # optional custom User-Agent + extra_headers={"X-Custom": "val"}, # optional additional headers +) +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `api_key` | `str \| None` | `None` | API key. Falls back to `TANGO_API_KEY` environment variable. | +| `base_url` | `str` | `"https://tango.makegov.com"` | Base URL for the Tango API. | +| `user_agent` | `str \| None` | `None` | Custom User-Agent string appended to the default. | +| `extra_headers` | `dict[str, str] \| None` | `None` | Additional HTTP headers sent with every request. | + +The client uses [httpx](https://www.python-httpx.org/) under the hood with a 30-second timeout. The API key is sent as an `X-API-KEY` header on every request. + +## Properties + +### `rate_limit_info` + +Returns rate limit information from the most recent API response. + +```python +resp = client.list_contracts(limit=5) +info = client.rate_limit_info + +if info: + print(f"Remaining: {info.remaining}/{info.limit}") + print(f"Resets in: {info.reset}s") + print(f"Daily remaining: {info.daily_remaining}/{info.daily_limit}") +``` + +The `RateLimitInfo` object exposes: + +| Field | Type | Description | +|---|---|---| +| `limit` | `int \| None` | Request limit for the current window | +| `remaining` | `int \| None` | Requests remaining in the current window | +| `reset` | `int \| None` | Seconds until the window resets | +| `daily_limit` | `int \| None` | Daily request limit | +| `daily_remaining` | `int \| None` | Daily requests remaining | +| `daily_reset` | `int \| None` | Seconds until the daily limit resets | +| `burst_limit` | `int \| None` | Burst request limit | +| `burst_remaining` | `int \| None` | Burst requests remaining | +| `burst_reset` | `int \| None` | Seconds until the burst limit resets | + +### `last_response_headers` + +Returns the full HTTP headers from the most recent API response, as an `httpx.Headers` object. + +```python +resp = client.list_contracts(limit=5) +headers = client.last_response_headers +print(headers["content-type"]) +``` + +## Retry Semantics + +The SDK does **not** include built-in retry or backoff. Each method call maps to exactly one HTTP request. If you need retry-on-429 or retry-on-transient-error behavior, wrap your calls (or catch [`TangoRateLimitError`](ERRORS.md#tangoratelimiterror-429) and use `wait_in_seconds`). + +See the [Rate Limits guide](https://docs.makegov.com/guides/patterns/rate-limits/) for recommended strategies. diff --git a/docs/DEVELOPERS.md b/docs/DEVELOPERS.md index f64b72e..81719c3 100644 --- a/docs/DEVELOPERS.md +++ b/docs/DEVELOPERS.md @@ -52,7 +52,6 @@ Get accurate autocomplete suggestions for only the fields in your shape: ```python contracts = client.list_contracts( shape=ShapeConfig.CONTRACTS_MINIMAL, - use_dynamic=True ) contract = contracts.results[0] # Typing "contract[" shows only: key, piid, award_date, award_type, @@ -75,7 +74,6 @@ Catch shape mismatches early with clear error messages: # If you request a field that doesn't exist contracts = client.list_contracts( shape="key,invalid_field", - use_dynamic=True ) # ShapeValidationError: Field 'invalid_field' does not exist in Contract ``` @@ -134,70 +132,41 @@ from tango import TangoClient, ShapeConfig client = TangoClient(api_key="your-key") -# Ultra-minimal for dropdowns -contracts = client.list_contracts( - shape=ShapeConfig.CONTRACTS_SUMMARY, - limit=100 -) -# Fields: key, piid, recipient(display_name), total_contract_value - -# Balanced for lists (recommended default) +# Default minimal shape for lists contracts = client.list_contracts( shape=ShapeConfig.CONTRACTS_MINIMAL, limit=100 ) -# Fields: key, piid, award_date, award_type, recipient(display_name), -# description, total_contract_value +# Fields: key, piid, award_date, recipient(display_name), description, total_contract_value -# Detailed with context +# Or use a custom shape for specific needs contracts = client.list_contracts( - shape=ShapeConfig.CONTRACTS_COMPREHENSIVE, + shape="key,piid,recipient(display_name),total_contract_value", limit=100 ) -# Fields: 16 fields including agencies, location, classification - -# Optimized for data analysis -contracts = client.list_contracts( - shape=ShapeConfig.CONTRACTS_FOR_ANALYSIS, - limit=1000 -) -# Fields: 13 analytical fields for research and statistics ``` ### Entities ```python -# Fast lookups +# Fast lookups (default for list_entities) entities = client.list_entities( shape=ShapeConfig.ENTITIES_MINIMAL, limit=50 ) -# Fields: uei, display_name, cage_code, business_types +# Fields: uei, legal_business_name, cage_code, business_types # Note: Entities do NOT have a 'key' field - use 'uei' as identifier -# Balanced profile info -entities = client.list_entities( - shape=ShapeConfig.ENTITIES_STANDARD, - limit=50 -) -# Fields: uei, display_name, legal_business_name, cage_code, -# business_types, physical_address(city,country_code) - -# Full vendor details +# Full vendor details (default for get_entity) entities = client.list_entities( shape=ShapeConfig.ENTITIES_COMPREHENSIVE, limit=50 ) -# Fields: All entity fields including: -# - Core: uei, display_name, legal_business_name, dba_name, cage_code -# - Registration: registered, registration_status, purpose_of_registration_code -# - Classification: primary_naics, naics_codes, psc_codes, business_types, sba_business_types -# - Contact: email_address, entity_url -# - Metadata: description, capabilities, keywords -# - Addresses: physical_address(*), mailing_address(*) -# - Dates: sam_activation_date, sam_registration_date, sam_expiration_date -# - Financial: federal_obligations, congressional_district -# - Relationships: relationships(relation,type,uei,display_name) +# Fields: uei, legal_business_name, dba_name, cage_code, +# business_types, primary_naics, naics_codes, psc_codes, +# email_address, entity_url, description, capabilities, keywords, +# physical_address, mailing_address, +# federal_obligations(*), congressional_district ``` ### Forecasts, Opportunities, Notices @@ -248,12 +217,15 @@ for contract in contracts.results: ### Multiple Nested Objects ```python -# Select from multiple nested relations with enhanced fields +# Select from multiple nested relations +# Note: awarding_office(*) returns organization_id, office_code, office_name, +# agency_code, agency_name, department_code, department_name +# Note: place_of_performance uses city_name (not city); no congressional_district custom_shape = ( "key,piid,award_date," "recipient(display_name,uei)," "awarding_office(office_code,office_name,agency_code,agency_name,department_code,department_name)," - "place_of_performance(city,city_name,state_code,state_name,country_code,country_name)" + "place_of_performance(city_name,state_code,state_name,country_code,country_name)" ) contracts = client.list_contracts(shape=custom_shape) @@ -264,7 +236,7 @@ for contract in contracts.results: print(f"Agency: {office.get('agency_name')} ({office.get('agency_code')})") print(f"Department: {office.get('department_name')}") location = contract.get('place_of_performance', {}) - print(f"Location: {location.get('city_name') or location.get('city')}, " + print(f"Location: {location.get('city_name')}, " f"{location.get('state_name') or location.get('state_code')}, " f"{location.get('country_name') or location.get('country_code')}") ``` @@ -411,8 +383,8 @@ For performance-critical applications, pre-generate types: # Pre-warm cache with common shapes common_shapes = [ ShapeConfig.CONTRACTS_MINIMAL, - ShapeConfig.CONTRACTS_COMPREHENSIVE, ShapeConfig.ENTITIES_MINIMAL, + ShapeConfig.IDVS_MINIMAL, ] for shape in common_shapes: @@ -446,94 +418,6 @@ contracts = client.list_contracts( **Solution:** Check the field name spelling and refer to the API documentation. -```python -# ✗ Wrong -contracts = client.list_contracts( - shape="key,piid,invalid_field", - use_dynamic=True -) -# ShapeValidationError: Field 'invalid_field' does not exist in Contract - -# ✓ Correct -contracts = client.list_contracts( - shape="key,piid,award_date", - use_dynamic=True -) -``` - -#### Issue: KeyError when accessing fields - -**Cause:** Trying to access a field that wasn't included in the shape. - -**Solution:** Add the field to your shape or check if the field exists before accessing. - -```python -# ✗ Wrong -contracts = client.list_contracts( - shape="key,piid", - use_dynamic=True -) -contract = contracts.results[0] -print(contract["award_date"]) # KeyError: 'award_date' - -# ✓ Correct - include field in shape -contracts = client.list_contracts( - shape="key,piid,award_date", - use_dynamic=True -) -contract = contracts.results[0] -print(contract["award_date"]) # Works - -# ✓ Correct - check before accessing -contract = contracts.results[0] -if "award_date" in contract: - print(contract["award_date"]) -``` - -#### Issue: Type checker doesn't recognize fields - -**Cause:** Using custom shapes without type annotations. - -**Solution:** Add type annotations for custom shapes or use predefined shapes. - -```python -from typing import TypedDict - -# Define your shape type -class MyCustomShape(TypedDict): - key: str - piid: str | None - award_date: str | None - -# Use type annotation -contracts = client.list_contracts( - shape="key,piid,award_date", - use_dynamic=True -) -contract: MyCustomShape = contracts.results[0] -# Now type checker understands the structure -``` - -#### Issue: Performance slower than expected - -**Cause:** Shapes not being reused or cache thrashing. - -**Solution:** Reuse shapes consistently. - -```python -# Reuse shapes -COMMON_SHAPE = "key,piid,recipient(display_name)" -contracts1 = client.list_contracts(shape=COMMON_SHAPE) -contracts2 = client.list_contracts(shape=COMMON_SHAPE) -# Second request uses cached type -``` - -#### Issue: "Field 'X' does not exist in Model" - -**Cause:** You requested a field that doesn't exist in the model schema. - -**Solution:** Check the field name spelling and refer to the API documentation. - ```python # ✗ Wrong contracts = client.list_contracts(shape="key,piid,invalid_field") diff --git a/docs/DYNAMIC_MODELS.md b/docs/DYNAMIC_MODELS.md new file mode 100644 index 0000000..77f966a --- /dev/null +++ b/docs/DYNAMIC_MODELS.md @@ -0,0 +1,205 @@ +# Tango Python SDK – Dynamic Models Guide + +This document explains how the **Python dynamic shaping system** works. +It mirrors the Node.js `DYNAMIC_MODELS.md` guide for the Python SDK. + +--- + +## Overview + +Tango's dynamic modeling allows you to: + +- Request _exactly the fields you want_ +- Validate the shape string against Tango's schemas +- Generate a typed model descriptor at runtime +- Materialize shaped objects using correct: + - date parsing + - datetime parsing + - decimal handling + - list vs scalar logic + - nested structure + +--- + +## Components + +### ShapeParser + +Parses shape strings into a `ShapeSpec`. + +```python +from tango.shapes import ShapeParser + +parser = ShapeParser() +spec = parser.parse("key,piid,recipient(display_name)") +``` + +### SchemaRegistry + +Holds the field schemas for all models. + +```python +from tango.shapes import SchemaRegistry +from tango.models import Contract + +registry = SchemaRegistry() +schema = registry.get_schema(Contract) +award_date_field = schema["award_date"] +# FieldSchema(name='award_date', type=date | None) +``` + +### TypeGenerator + +Builds a dynamic `TypedDict`-backed type from `(shape_spec, base_model)`. + +```python +from tango.shapes import ShapeParser, TypeGenerator +from tango.models import Contract + +parser = ShapeParser() +spec = parser.parse("key,piid,recipient(display_name)") + +gen = TypeGenerator() +dynamic_type = gen.generate_type( + shape_spec=spec, + base_model=Contract, + type_name="ContractShaped", +) +``` + +### ModelFactory + +Takes a dynamic type + raw API JSON and produces typed `ShapedModel` instances. +The `TangoClient` uses this pipeline automatically after fetching data. + +```python +from tango import TangoClient + +client = TangoClient(api_key="your-api-key") +contracts = client.list_contracts( + shape="key,award_date,recipient(display_name)", +) + +# contracts.results are ShapedModel instances materialized by ModelFactory: +# - date/datetime strings parsed to date/datetime objects +# - decimals normalized via Decimal +# - nested structures are themselves ShapedModel instances +``` + +--- + +## Example: Full Shaping Pipeline (manual) + +```python +from tango.shapes import ShapeParser, TypeGenerator, ModelFactory, create_default_parser_registry +from tango.models import Contract + +parser = ShapeParser() +spec = parser.parse("key,award_date,recipient(display_name)") + +gen = TypeGenerator() +dynamic_type = gen.generate_type( + shape_spec=spec, + base_model=Contract, + type_name="ContractShaped", +) + +parsers = create_default_parser_registry() +factory = ModelFactory(gen, parsers) + +shaped = factory.create_instance( + data={ + "key": "C-1", + "award_date": "2024-01-15", + "recipient": {"display_name": "Acme"}, + }, + shape_spec=spec, + base_model=Contract, + dynamic_type=dynamic_type, +) +``` + +`shaped` becomes: + +```python +ContractShaped(key='C-1', award_date=datetime.date(2024, 1, 15), recipient=ContractShaped_Recipient(display_name='Acme')) +``` + +--- + +## Attribute Access + +`ShapedModel` is a `dict` subclass with `__getattr__` so fields are accessible +both as dictionary keys and as attributes: + +```python +# Both styles work +shaped["key"] # "C-1" +shaped.key # "C-1" + +# Nested models are also ShapedModel instances +shaped.recipient["display_name"] # "Acme" +shaped.recipient.display_name # "Acme" +``` + +Accessing a field that was not included in your shape raises a descriptive +`AttributeError` with suggestions: + +```python +shaped.award_amount +# AttributeError: Field 'award_amount' not found in ContractShaped. +# Available fields: 'key', 'award_date', 'recipient' +# This field may not be included in your shape specification. +# To include this field, add it to your shape parameter. +``` + +--- + +## Type Safety + +The Python SDK enforces shape correctness at parse time via `ShapeParser.validate()`. +Nested structures are recursively materialized as `ShapedModel` instances, guaranteeing +the same access patterns at every depth. No static class generation happens at build time; +shapes are resolved at runtime. + +--- + +## Caching + +`TypeGenerator` caches descriptors using a thread-safe LRU cache (default: 100 entries). + +`ShapeParser` also caches parse results keyed on the raw shape string. + +--- + +## Nested Models + +If a field is nested in the schema (e.g. `"recipient"` → `RecipientProfile`), +the generator recursively builds the nested descriptor, naming it +`{ParentType}_{FieldName}` (e.g. `ContractShaped_Recipient`). Each nested object +is also a `ShapedModel`, so attribute access and `repr` work uniformly at every level. + +--- + +## Predefined Shape Constants + +`ShapeConfig` provides opinionated defaults for each resource's list and detail methods. +Each `TangoClient` method applies its corresponding default automatically; pass `shape=` +to override. + +```python +from tango import TangoClient, ShapeConfig + +client = TangoClient(api_key="your-api-key") + +# These are equivalent — list_contracts defaults to CONTRACTS_MINIMAL +contracts = client.list_contracts(limit=10) +contracts = client.list_contracts(shape=ShapeConfig.CONTRACTS_MINIMAL, limit=10) + +# Other resources +entities = client.list_entities(shape=ShapeConfig.ENTITIES_MINIMAL) +idvs = client.list_idvs(shape=ShapeConfig.IDVS_MINIMAL) +``` + +See [API Reference – ShapeConfig](API_REFERENCE.md#shapeconfig-predefined-shapes) for the +full table of constants. diff --git a/docs/ERRORS.md b/docs/ERRORS.md new file mode 100644 index 0000000..1b38d64 --- /dev/null +++ b/docs/ERRORS.md @@ -0,0 +1,158 @@ +# Error Handling + +The SDK raises typed exceptions for HTTP errors and for shape-related failures. All exceptions are importable from `tango.exceptions` (and re-exported from the top-level `tango` package for the API errors). + +For a compact reference of each class, see [`API_REFERENCE.md` § Error Handling](API_REFERENCE.md#error-handling). This guide covers the hierarchy, recovery patterns, and the shape-error classes that don't have a dedicated section there. + +## Exception Hierarchy + +``` +TangoAPIError +├── TangoAuthError (401 Unauthorized) +├── TangoNotFoundError (404 Not Found) +├── TangoValidationError (400 Bad Request) +├── TangoRateLimitError (429 Too Many Requests) +└── ShapeError + ├── ShapeValidationError (invalid field names) + ├── ShapeParseError (invalid shape syntax) + ├── TypeGenerationError (dynamic type generation failure) + └── ModelInstantiationError (model creation failure) +``` + +## API Errors + +### TangoAPIError (base) + +All API errors inherit from this class. + +| Attribute | Type | Description | +|---|---|---| +| `status_code` | `int \| None` | HTTP status code | +| `response_data` | `dict` | Parsed response body (credentials redacted) | +| `message` | `str` | Human-readable error message | + +```python +from tango import TangoClient +from tango.exceptions import TangoAPIError + +client = TangoClient() + +try: + resp = client.list_contracts(limit=10) +except TangoAPIError as e: + print(f"API error {e.status_code}: {e.message}") +``` + +### TangoAuthError (401) + +Raised when the API key is missing, invalid, or expired. + +```python +from tango.exceptions import TangoAuthError + +try: + client = TangoClient(api_key="invalid-key") + client.list_contracts(limit=1) +except TangoAuthError: + print("Check your API key") +``` + +### TangoNotFoundError (404) + +Raised when a resource doesn't exist. + +```python +from tango.exceptions import TangoNotFoundError + +try: + entity = client.get_entity("INVALID_UEI") +except TangoNotFoundError: + print("Entity not found") +``` + +### TangoValidationError (400) + +Raised for invalid request parameters (bad date format, unknown filter, etc.). + +### TangoRateLimitError (429) + +Raised when you exceed rate limits. Includes retry information. + +| Attribute | Type | Description | +|---|---|---| +| `wait_in_seconds` | `int \| None` | Seconds to wait before retrying | +| `detail` | `str \| None` | Human-readable rate limit message | +| `limit_type` | `str \| None` | `"burst"` or `"daily"` | + +```python +import time +from tango.exceptions import TangoRateLimitError + +try: + resp = client.list_contracts(limit=10) +except TangoRateLimitError as e: + if e.wait_in_seconds: + print(f"Rate limited ({e.limit_type}). Retrying in {e.wait_in_seconds}s...") + time.sleep(e.wait_in_seconds) + resp = client.list_contracts(limit=10) +``` + +> **Note:** The SDK does not include built-in retry or backoff. You are responsible for handling rate limit errors. See the [Rate Limits guide](https://docs.makegov.com/guides/patterns/rate-limits/) for strategies. + +## Shape Errors + +These are raised when there's a problem with the response shaping configuration, not the API itself. See [`SHAPES.md`](SHAPES.md) for shape syntax. + +### ShapeValidationError + +Raised when a shape string references field names that don't exist on the model. + +```python +from tango.exceptions import ShapeValidationError + +try: + resp = client.list_contracts(shape="key,piid,nonexistent_field", limit=1) +except ShapeValidationError as e: + print(f"Invalid shape: {e}") + print(f"Shape string: {e.shape}") +``` + +### ShapeParseError + +Raised when the shape string has invalid syntax (unbalanced parentheses, etc.). + +| Attribute | Type | Description | +|---|---|---| +| `shape` | `str` | The invalid shape string | +| `position` | `int \| None` | Character position where parsing failed | + +### TypeGenerationError + +Raised when the SDK fails to generate a dynamic TypedDict for a shaped response. + +### ModelInstantiationError + +Raised when the SDK fails to create a model instance from API data. + +| Attribute | Type | Description | +|---|---|---| +| `field_name` | `str \| None` | Field that caused the failure | +| `expected_type` | `type \| None` | Expected Python type | +| `actual_value` | `Any` | Value that couldn't be coerced | + +## Catching Everything + +To handle any SDK-raised error in one place, catch `TangoAPIError` and `ShapeError` (or just `Exception` at the outermost boundary): + +```python +from tango.exceptions import TangoAPIError, ShapeError + +try: + resp = client.list_contracts(shape="key,piid", limit=10) +except TangoAPIError as e: + # HTTP-layer problems (auth, rate limit, validation, etc.) + print(f"API error {e.status_code}: {e.message}") +except ShapeError as e: + # Shape-string or model-construction problems + print(f"Shape error: {e}") +``` diff --git a/docs/PAGINATION.md b/docs/PAGINATION.md new file mode 100644 index 0000000..8f7144f --- /dev/null +++ b/docs/PAGINATION.md @@ -0,0 +1,112 @@ +# Pagination + +The SDK uses two pagination strategies depending on the endpoint. + +For per-method pagination parameters, see [`API_REFERENCE.md`](API_REFERENCE.md). This guide is the conceptual overview and iteration patterns. + +## Page-Based Pagination + +Most endpoints use traditional page-based pagination with `page` and `limit` parameters. + +```python +from tango import TangoClient + +client = TangoClient() + +# First page +resp = client.list_entities(search="Booz Allen", limit=25) +print(f"Total: {resp.count}") +print(f"This page: {len(resp.results)}") +print(f"Next: {resp.next}") + +# Next page +resp2 = client.list_entities(search="Booz Allen", limit=25, page=2) +``` + +### Iterating All Pages + +```python +page = 1 +all_results = [] + +while True: + resp = client.list_entities(search="Booz Allen", limit=100, page=page) + all_results.extend(resp.results) + if not resp.next: + break + page += 1 + +print(f"Fetched {len(all_results)} of {resp.count} entities") +``` + +**Endpoints using page-based pagination:** entities, forecasts, opportunities, notices, grants, protests, subawards, vehicles, vehicle awardees, organizations, GSA eLibrary contracts, IT Dashboard investments, agencies, offices, business types, NAICS, webhook subscriptions, webhook endpoints. + +## Cursor-Based Pagination + +High-volume award endpoints use cursor-based (keyset) pagination for better performance on large datasets. Instead of a page number, you pass a `cursor` token from the previous response. + +```python +from urllib.parse import parse_qs, urlparse + +from tango import TangoClient + +client = TangoClient() + +# First page +resp = client.list_contracts(limit=25, sort="award_date", order="desc") +print(f"Total: {resp.count}") + +# Get cursor from the next URL +if resp.next: + qs = parse_qs(urlparse(resp.next).query) + cursor = qs.get("cursor", [None])[0] + + # Fetch next page + resp2 = client.list_contracts( + limit=25, + cursor=cursor, + sort="award_date", + order="desc", + ) +``` + +### Iterating All Pages + +```python +from urllib.parse import parse_qs, urlparse + +all_results = [] +cursor = None + +while True: + resp = client.list_contracts( + keyword="cloud", + limit=100, + cursor=cursor, + sort="award_date", + order="desc", + ) + all_results.extend(resp.results) + + if not resp.next: + break + qs = parse_qs(urlparse(resp.next).query) + cursor = qs.get("cursor", [None])[0] + +print(f"Fetched {len(all_results)} of {resp.count} contracts") +``` + +**Endpoints using cursor-based pagination:** contracts, IDVs, IDV awards, IDV child IDVs, IDV transactions, OTAs, OTIDVs. + +## PaginatedResponse + +All list methods return a `PaginatedResponse` object: + +| Field | Type | Description | +|---|---|---| +| `count` | `int` | Total number of results available | +| `next` | `str \| None` | Full URL for the next page, or `None` | +| `previous` | `str \| None` | Full URL for the previous page, or `None` | +| `results` | `list[T]` | List of results for this page | +| `cursor` | `str \| None` | Cursor token (cursor-based endpoints only) | +| `page_metadata` | `dict \| None` | Optional additional page metadata | diff --git a/docs/SHAPES.md b/docs/SHAPES.md index 2334170..70bc40c 100644 --- a/docs/SHAPES.md +++ b/docs/SHAPES.md @@ -99,18 +99,18 @@ for contract in contracts.results: ### Multiple Levels -You can nest as deeply as needed: +You can nest as deeply as needed. Contract location information is on `place_of_performance` (not nested inside `recipient`): ```python -# Get location details from recipient +# Get place of performance details contracts = client.list_contracts( - shape="key,recipient(display_name,location(city,state_code,zip_code))", + shape="key,recipient(display_name),place_of_performance(city_name,state_code,zip_code)", limit=10 ) for contract in contracts.results: - location = contract['recipient']['location'] - print(f"{location['city']}, {location['state_code']} {location['zip_code']}") + location = contract['place_of_performance'] + print(f"{location['city_name']}, {location['state_code']} {location['zip_code']}") ``` ## Common Use Cases @@ -138,13 +138,13 @@ When analyzing contracts, focus on the metrics: ```python # Get financial and timing data contracts = client.list_contracts( - shape="key,piid,award_date,fiscal_year,total_contract_value,total_obligated", + shape="key,piid,award_date,fiscal_year,total_contract_value,obligated", awarding_agency="GSA", limit=1000 ) # Analyze -total_value = sum(c.get('total_contract_value', 0) for c in contracts.results) +total_value = sum(c.get('total_contract_value', 0) or 0 for c in contracts.results) print(f"Total contract value: ${total_value:,.2f}") ``` @@ -154,14 +154,15 @@ When you need location data: ```python # Get place of performance details +# Note: use city_name (not city); congressional_district is not a shape field contracts = client.list_contracts( - shape="key,piid,place_of_performance(city,state_code,congressional_district)", + shape="key,piid,place_of_performance(city_name,state_code)", limit=100 ) # Group by state from collections import Counter -states = Counter(c['place_of_performance']['state_code'] for c in contracts.results) +states = Counter(c['place_of_performance']['state_code'] for c in contracts.results if c.get('place_of_performance') and c['place_of_performance'].get('state_code')) print(f"Top states: {states.most_common(5)}") ``` @@ -171,17 +172,19 @@ When researching vendors and recipients: ```python # Get detailed vendor information +# Note: entity physical_address uses 'city' and 'state_or_province_code' +# business_types is a list of dicts with 'code' and 'description' entities = client.list_entities( - shape="uei,legal_business_name,dba_name,business_types,physical_address(city,state_code)", + shape="uei,legal_business_name,dba_name,business_types,physical_address(city,state_or_province_code)", limit=50 ) for entity in entities.results: print(f"{entity['legal_business_name']}") - print(f"Business Types: {', '.join(entity.get('business_types', []))}") + print(f"Business Types: {', '.join(bt['code'] for bt in entity.get('business_types', []))}") if entity.get('physical_address'): addr = entity['physical_address'] - print(f"Location: {addr.get('city')}, {addr.get('state_code')}") + print(f"Location: {addr.get('city')}, {addr.get('state_or_province_code')}") ``` ### 5. Agency Research @@ -190,8 +193,9 @@ When analyzing agency activity: ```python # Get agency and classification details +# Note: use awarding_office (not awarding_agency) for agency name/code sub-fields contracts = client.list_contracts( - shape="key,awarding_agency(name,code),naics(code,description),psc(code,description),total_contract_value", + shape="key,awarding_office(agency_name,agency_code),naics(code,description),psc(code,description),total_contract_value", fiscal_year=2024, limit=500 ) @@ -200,9 +204,9 @@ contracts = client.list_contracts( from collections import defaultdict by_agency = defaultdict(float) for contract in contracts.results: - if contract.get('awarding_agency'): - agency = contract['awarding_agency']['name'] - value = contract.get('total_contract_value', 0) + if contract.get('awarding_office'): + agency = contract['awarding_office']['agency_name'] + value = float(contract.get('total_contract_value', 0) or 0) by_agency[agency] += value # Top agencies by value @@ -295,9 +299,9 @@ Define shapes as constants for reuse: # Define your common shapes SHAPES = { 'list': "key,piid,recipient(display_name),total_contract_value", - 'detail': "key,piid,description,recipient(*),awarding_agency(*),total_contract_value,award_date", - 'analysis': "key,fiscal_year,total_contract_value,total_obligated,award_date", - 'geographic': "key,piid,place_of_performance(city,state_code,congressional_district)" + 'detail': "key,piid,description,recipient(*),awarding_office(*),total_contract_value,award_date", + 'analysis': "key,fiscal_year,total_contract_value,obligated,award_date", + 'geographic': "key,piid,place_of_performance(city_name,state_code)" } # Use them @@ -336,7 +340,7 @@ contracts = client.list_contracts(shape=DASHBOARD_SHAPE, limit=50) **Financial:** - `total_contract_value` - Total contract value -- `total_obligated` - Total obligated amount +- `obligated` - Total obligated amount (note: field is `obligated`, not `total_obligated`) - `award_amount` - Initial award amount **Parties:** @@ -433,14 +437,14 @@ display_name = contract.get('recipient', {}).get('display_name', 'Unknown') # Minimal for lists "key,piid,recipient(display_name),total_contract_value" -# For analysis -"key,fiscal_year,award_date,total_contract_value,total_obligated,naics(code)" +# For analysis (use 'obligated', not 'total_obligated') +"key,fiscal_year,award_date,total_contract_value,obligated,naics(code)" -# For geographic analysis -"key,piid,place_of_performance(city,state_code,congressional_district)" +# For geographic analysis (use city_name; congressional_district not available) +"key,piid,place_of_performance(city_name,state_code)" -# Full detail -"key,piid,description,recipient(*),awarding_agency(*),total_contract_value,award_date,naics(*),psc(*)" +# Full detail (use awarding_office for agency breakdown) +"key,piid,description,recipient(*),awarding_office(*),total_contract_value,award_date,naics(*),psc(*)" ``` ### Entities @@ -449,8 +453,8 @@ display_name = contract.get('recipient', {}).get('display_name', 'Unknown') # Minimal for lookups "uei,legal_business_name,cage_code,business_types" -# For vendor research -"uei,legal_business_name,dba_name,business_types,physical_address(city,state_code),primary_naics" +# For vendor research (entity physical_address uses state_or_province_code, not state_code) +"uei,legal_business_name,dba_name,business_types,physical_address(city,state_or_province_code),primary_naics" # Full profile "uei,legal_business_name,dba_name,cage_code,business_types,physical_address(*),email_address,entity_url" diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index 817b22c..bb9659e 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -1,6 +1,6 @@ # Webhooks Guide -This guide covers everything `tango-python` provides for **building, testing, and operating webhook integrations against the Tango API**: signing helpers, a local receiver, a command-line tool, and management commands for the underlying endpoints and subscriptions. +This guide covers everything `tango-python` provides for **building, testing, and operating webhook integrations against the Tango API**: signing helpers, a local receiver, a command-line tool, and management commands for the underlying endpoints and alerts. If you only need the SDK method signatures, see [`API_REFERENCE.md` § Webhooks](API_REFERENCE.md#webhooks). For the API-level contract (signing scheme, event taxonomy, retry behavior), see the [Tango Webhooks Partner Guide](https://docs.makegov.com/webhooks-user-guide/). @@ -18,7 +18,6 @@ If you only need the SDK method signatures, see [`API_REFERENCE.md` § Webhooks] - [`tango webhooks fetch-sample`](#tango-webhooks-fetch-sample) - [`tango webhooks list-event-types`](#tango-webhooks-list-event-types) - [`tango webhooks endpoints`](#tango-webhooks-endpoints) - - [`tango webhooks subscriptions`](#tango-webhooks-subscriptions) - [Programmatic use](#programmatic-use) - [Signature verification in your handler](#signature-verification-in-your-handler) - [`WebhookReceiver` in pytest fixtures](#webhookreceiver-in-pytest-fixtures) @@ -54,18 +53,18 @@ tango webhooks --help ## Concepts in 60 seconds -Tango webhooks have three pieces of state: +Tango webhooks have two pieces of state: | Concept | What it is | Tango term | |---|---|---| | **Endpoint** | The URL Tango POSTs to, plus a generated signing secret | `WebhookEndpoint` | -| **Subscription** | A filter saying *which events* you want delivered to that endpoint | `WebhookSubscription` | +| **Alert** | A saved-search filter saying *which matches* to deliver | `WebhookAlert` (filter subscription) | | **Delivery** | A single signed POST Tango makes when a matching event fires | (the request itself) | A typical setup: 1. **Create an endpoint** (`POST /api/webhooks/endpoints/`) with the public URL of your handler. Tango returns a `secret` — save it; it's used to sign every delivery. -2. **Create one or more subscriptions** (`POST /api/webhooks/subscriptions/`) describing the events your handler cares about (e.g. `entities.updated` for specific UEIs). +2. **Create one or more alerts** (`POST /api/webhooks/alerts/`) describing the saved-search matches you want delivered (e.g. opportunities matching `naics=541511`). Each alert maps to one of five `alerts.*.match` event types. 3. **Tango POSTs** to your endpoint when matching events fire. The body is JSON; the header `X-Tango-Signature: sha256=` is the HMAC-SHA256 of the raw body bytes keyed by your endpoint's secret. 4. **Your handler verifies the signature**, parses the body, and acts on it. @@ -80,15 +79,17 @@ Assumes you have a `TANGO_API_KEY` and want to receive entity-update webhooks fo ```bash export TANGO_API_KEY=... tango webhooks list-event-types -# entities.updated An entity record was updated -# awards.created A new award was published -# ... +# alerts.opportunity.match New/updated opportunity matched a saved alert +# alerts.contract.match New/updated contract matched a saved alert +# alerts.entity.match Entity matched a saved alert +# alerts.grant.match Grant matched a saved alert +# alerts.forecast.match Forecast matched a saved alert ``` ### 2. See what a payload looks like ```bash -tango webhooks fetch-sample --event-type entities.updated +tango webhooks fetch-sample --event-type alerts.entity.match ``` Prints the canonical JSON shape Tango will deliver. No POST, no signature — just the body. @@ -107,26 +108,32 @@ In another shell, drive it with the canonical sample, signed locally: ```bash tango webhooks simulate \ --secret $TANGO_WEBHOOK_SECRET \ - --event-type entities.updated \ + --event-type alerts.entity.match \ --to http://127.0.0.1:8011/tango/webhooks ``` -The listener should print a `verified` delivery with the entities-updated body. You now have a feedback loop: edit your handler, re-run `simulate`, see the result. +The listener should print a `verified` delivery with the alerts.entity.match body. You now have a feedback loop: edit your handler, re-run `simulate`, see the result. ### 4. Wire up the real Tango → your handler path -When you're ready for end-to-end testing against Tango itself, expose your local listener via a tunnel (`ngrok http 8011`, `cloudflared tunnel`, etc.) and register that public URL with Tango: +When you're ready for end-to-end testing against Tango itself, expose your local listener via a tunnel (`ngrok http 8011`, `cloudflared tunnel`, etc.) and register that public URL with Tango, then create an alert via the SDK: ```bash # Use the public URL the tunnel gave you. -tango webhooks endpoints create --url https://.ngrok.io/tango/webhooks +tango webhooks endpoints create --name dev --url https://.ngrok.io/tango/webhooks # Save the `secret` from the response — that's what your handler uses to verify. +``` -tango webhooks subscriptions create \ - --name "watch UEI ABC123" \ - --event-type entities.updated \ - --subject-type entity \ - --subject-id ABC123 +```python +# Create an alert (filter subscription) via the SDK +from tango import TangoClient + +client = TangoClient() +client.create_webhook_alert( + name="watch UEI ABC123", + query_type="entity", + filters={"uei": "ABC123"}, +) ``` To force a real test delivery from Tango (without waiting for an actual event): @@ -174,7 +181,7 @@ Sign a payload locally with the same scheme Tango uses, then either print the si **Without `--to`** — just print the headers + body a real Tango delivery would have: ```bash -tango webhooks simulate --secret dev_secret --event-type entities.updated +tango webhooks simulate --secret dev_secret --event-type alerts.entity.match ``` Output includes `delivered: false`, the headers (`Content-Type`, `X-Tango-Signature`), and the JSON payload. @@ -184,7 +191,7 @@ Output includes `delivered: false`, the headers (`Content-Type`, `X-Tango-Signat ```bash tango webhooks simulate \ --secret dev_secret \ - --event-type entities.updated \ + --event-type alerts.entity.match \ --to http://127.0.0.1:8011/tango/webhooks ``` @@ -194,7 +201,7 @@ Three sources for the payload (mutually exclusive): | Flag | Source | When to use | |---|---|---| -| `--event-type X` | Fetches the canonical sample for `X` from Tango | You want a realistic body without setting up a subscription | +| `--event-type X` | Fetches the canonical sample for `X` from Tango | You want a realistic body without setting up an alert | | `--payload-file PATH` | Reads a JSON file | You're testing a specific shape (regression, edge case) | | *(neither)* | A built-in placeholder envelope | Smoke-testing the wiring | @@ -214,7 +221,7 @@ Output is JSON: `success`, `status_code` (the HTTP code Tango got from your endp Print the canonical sample payload for one event type, or the full mapping if `--event-type` is omitted. Wraps `GET /api/webhooks/endpoints/sample-payload/`. Read-only. ```bash -tango webhooks fetch-sample --event-type entities.updated +tango webhooks fetch-sample --event-type alerts.entity.match tango webhooks fetch-sample # all event types ``` @@ -233,28 +240,33 @@ Manage **where Tango delivers**. ```bash tango webhooks endpoints list [--page N] [--limit N] tango webhooks endpoints get ENDPOINT_ID -tango webhooks endpoints create --url URL [--inactive] +tango webhooks endpoints create --name NAME --url URL [--inactive] tango webhooks endpoints delete ENDPOINT_ID [--yes] ``` -`create` returns the generated `secret` once — save it. `delete` prompts for confirmation; `--yes` skips. `--inactive` registers the endpoint disabled (no deliveries until you re-enable it). +`create` returns the generated `secret` once — save it. `--name` is required and must be unique per user (uniqueness is enforced on `(user, name)`, so you can have multiple endpoints with distinct names). `delete` prompts for confirmation; `--yes` skips. `--inactive` registers the endpoint disabled (no deliveries until you re-enable it). -### `tango webhooks subscriptions` +### Managing alerts -Manage **what Tango delivers**. +Alerts (filter subscriptions) are the canonical way to control what Tango delivers. There is no CLI subgroup for them — use the SDK directly: -```bash -tango webhooks subscriptions list [--page N] [--page-size N] -tango webhooks subscriptions get SUBSCRIPTION_ID -tango webhooks subscriptions create \ - --name "watch UEI ABC123" \ - --event-type entities.updated \ - --subject-type entity \ - --subject-id ABC123 -tango webhooks subscriptions delete SUBSCRIPTION_ID [--yes] +```python +from tango import TangoClient + +client = TangoClient() + +client.list_webhook_alerts() +client.get_webhook_alert("ALERT_UUID") +client.create_webhook_alert( + name="watch UEI ABC123", + query_type="entity", + filters={"uei": "ABC123"}, +) +client.update_webhook_alert("ALERT_UUID", name="Renamed") +client.delete_webhook_alert("ALERT_UUID") ``` -`create` builds a single-record subscription (one event type, one subject type, one or more subject IDs). For multi-record subscriptions, call `client.create_webhook_subscription(...)` directly with a hand-crafted `payload` dict. +For multi-endpoint accounts, pass `endpoint=` to `create_webhook_alert` to pin which endpoint the alert delivers to. --- @@ -287,17 +299,19 @@ def handle_webhook(request): The CLI's `listen` command is a thin wrapper around `tango.webhooks.WebhookReceiver`, which is a context-manager-friendly local HTTP server. Use it directly in tests to verify your code emits webhook calls correctly, or to drive your handler with realistic deliveries. ```python -from tango.webhooks import WebhookReceiver, verify_signature +from tango import WebhookReceiver # WebhookReceiver is exported from the top-level tango package +from tango.webhooks import generate_signature, verify_signature import httpx def test_my_handler_processes_entity_update(): with WebhookReceiver(secret="test_secret").run() as rx: # Trigger whatever in your code-under-test should send a webhook # (e.g. a publisher, or in this case a manual POST). - body = b'{"events":[{"event_type":"entities.updated","uei":"ABC"}]}' - from tango.webhooks import generate_signature + body = b'{"events":[{"event_type":"alerts.entity.match","alert_id":"ABC"}]}' sig = generate_signature(body, "test_secret") - httpx.post(rx.url, content=body, headers={"X-Tango-Signature": f"sha256={sig}"}) + # generate_signature returns the wire form ("sha256=") — assign + # directly to the header without wrapping. + httpx.post(rx.url, content=body, headers={"X-Tango-Signature": sig}) assert len(rx.deliveries) == 1 assert rx.deliveries[0].verified @@ -323,7 +337,7 @@ Each `Delivery` has: `received_at`, `path`, `signature_header`, `body_bytes`, `b ```python from tango.webhooks import sign -signed = sign({"events": [{"event_type": "entities.updated"}]}, secret="s") +signed = sign({"events": [{"event_type": "alerts.entity.match"}]}, secret="s") assert signed.headers["X-Tango-Signature"].startswith("sha256=") # Use `signed.body` as the raw bytes and `signed.headers` directly: @@ -335,7 +349,8 @@ response = my_app.test_client().post( `simulate.deliver` does the same but POSTs the result to a URL — `WebhookReceiver` works as a target: ```python -from tango.webhooks import simulate, WebhookReceiver +from tango.webhooks import simulate +from tango import WebhookReceiver with WebhookReceiver(secret="s").run() as rx: result = simulate.deliver(target_url=rx.url, payload={...}, secret="s") @@ -354,16 +369,19 @@ export TANGO_API_KEY=... tango webhooks list-event-types # 2. Stand up a tunnel so Tango can reach you ngrok http 8011 & -# 3. Register your endpoint and subscription -tango webhooks endpoints create --url https://.ngrok.io/tango/webhooks +# 3. Register your endpoint +tango webhooks endpoints create --name dev --url https://.ngrok.io/tango/webhooks # (save the `secret` from the response into TANGO_WEBHOOK_SECRET) -tango webhooks subscriptions create \ - --name "entities" --event-type entities.updated \ - --subject-type entity --subject-id -# 4. Run the listener pointed at your downstream handler +# 4. Create an alert via the SDK +python -c ' +from tango import TangoClient +TangoClient().create_webhook_alert( + name="entities", query_type="entity", filters={"uei": ""} +)' +# 5. Run the listener pointed at your downstream handler tango webhooks listen --port 8011 --secret $TANGO_WEBHOOK_SECRET \ --forward-to http://localhost:4242/wh -# 5. Force a test delivery +# 6. Force a test delivery tango webhooks trigger ``` @@ -376,7 +394,7 @@ You don't need a Tango account or any tunnel: tango webhooks listen --port 8011 --secret dev --forward-to http://127.0.0.1:4242/wh # In another shell, drive it. Use Tango-shaped bodies if you have an API key: -tango webhooks simulate --secret dev --event-type entities.updated \ +tango webhooks simulate --secret dev --event-type alerts.entity.match \ --to http://127.0.0.1:8011/tango/webhooks # Or use a custom shape from a file (no API key required): @@ -389,13 +407,14 @@ tango webhooks simulate --secret dev --payload-file ./fixtures/edge.json \ In pytest, use `WebhookReceiver` and `simulate.deliver` together — both are pure-Python and don't talk to Tango: ```python -from tango.webhooks import simulate, WebhookReceiver +from tango.webhooks import simulate +from tango import WebhookReceiver def test_handler_round_trip(): with WebhookReceiver(secret="s").run() as rx: result = simulate.deliver( target_url=rx.url, - payload={"events": [{"event_type": "entities.updated", "uei": "X"}]}, + payload={"events": [{"event_type": "alerts.entity.match", "alert_id": "X"}]}, secret="s", ) assert result.status_code == 200 @@ -405,7 +424,7 @@ def test_handler_round_trip(): ### "I need to inspect what bytes Tango actually sends" ```bash -tango webhooks simulate --secret $TANGO_WEBHOOK_SECRET --event-type entities.updated +tango webhooks simulate --secret $TANGO_WEBHOOK_SECRET --event-type alerts.entity.match # Prints { "delivered": false, "headers": {...}, "sent_payload": {...} } ``` @@ -423,7 +442,7 @@ This is the shape your handler will receive — including the exact `X-Tango-Sig **`fetch-sample` returns 401.** Set `TANGO_API_KEY` (or pass `--api-key`). `fetch-sample` reads from Tango's API. -**`endpoints create` returns 403 or "endpoint already exists".** Tango limits one endpoint per user. Use `endpoints list` to find the existing one, then either reuse it or delete it first. +**`endpoints create` returns 400 or "endpoint already exists".** Endpoint names are unique per user — if you've already created one with that `--name`, either pick a different name or use `endpoints list` to find the existing one and reuse it. **`simulate --event-type X` fails with HTTP 4xx.** Tango doesn't recognize the event type. Run `list-event-types` to see the current list. diff --git a/pyproject.toml b/pyproject.toml index 7007849..ece8faf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tango-python" -version = "0.6.0" +version = "1.0.0" description = "Python SDK for the Tango API" readme = "README.md" requires-python = ">=3.12" @@ -122,6 +122,34 @@ exclude_lines = [ [tool.hatch.build.targets.wheel] packages = ["tango"] +# Sdist whitelist — `uv build` / `hatch build sdist` only includes these. +# +# Hatchling's default behavior packs everything in the project root that +# isn't matched by .gitignore, which is fine in CI (clean checkout) but +# leaks per-developer state (`.mg-tools/`, `.claude/`, `CLAUDE.md`, work +# diaries, scratch reports, etc.) when anyone runs `uv publish` locally. +# The explicit whitelist is belt-and-suspenders for that foot-gun: even +# from a developer laptop, only these paths can ever end up on PyPI. +[tool.hatch.build.targets.sdist] +include = [ + "/tango", + "/tests", + "/docs", + "/scripts", + "/.github/workflows", + "/README.md", + "/LICENSE", + "/CHANGELOG.md", + "/pyproject.toml", +] +exclude = [ + # Internal-only docs / artifacts that exist inside the included dirs. + "/docs/DEVELOPERS.md", + "/docs/quick_start.ipynb", + "**/__pycache__", + "**/*.pyc", +] + [[tool.mypy.overrides]] module = "tango.webhooks.cli" disallow_untyped_decorators = false diff --git a/scripts/smoke_api_parity.py b/scripts/smoke_api_parity.py new file mode 100644 index 0000000..261b534 --- /dev/null +++ b/scripts/smoke_api_parity.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""Smoke test for API-parity work (feat/api-parity branch). + +Hits every method added or changed on the branch against a running local +Tango. For mutations (webhook endpoints / subscriptions / alerts) the +script creates a resource, verifies it, and tears it down. Prints +PASS/FAIL per method and exits non-zero on any failure. + +Usage: + TANGO_BASE_URL=http://localhost:8000 \\ + TANGO_API_KEY=... \\ + python scripts/smoke_api_parity.py +""" + +from __future__ import annotations + +import os +import sys +import time +import traceback +from collections.abc import Callable +from typing import Any + +from tango import TangoClient, WebhookAlert, WebhookEndpoint + +BASE_URL = os.getenv("TANGO_BASE_URL", "http://localhost:8000") +API_KEY = os.getenv("TANGO_API_KEY") +CALLBACK_URL = "http://example.test/smoke-python" + +if not API_KEY: + print("TANGO_API_KEY not set", file=sys.stderr) + sys.exit(2) + + +client = TangoClient(api_key=API_KEY, base_url=BASE_URL) + + +# ----- result tracking ----- +results: list[tuple[str, bool, str]] = [] + + +def run(label: str, fn: Callable[[], Any], *, skip_if_blank: str | None = None) -> Any: + """Run a smoke step; record PASS/FAIL; return its value (or None on fail).""" + if skip_if_blank is not None and not skip_if_blank: + results.append((label, True, "skipped: dependency unavailable")) + print(f" SKIP {label}") + return None + try: + out = fn() + results.append((label, True, "ok")) + print(f" PASS {label}") + return out + except Exception as exc: # noqa: BLE001 + results.append((label, False, f"{type(exc).__name__}: {exc}")) + print(f" FAIL {label} {type(exc).__name__}: {exc}") + traceback.print_exc(limit=2) + return None + + +# ============================================================================ +# Reference data +# ============================================================================ + +print("\n=== Reference data ===") + +dept_pager = run("list_departments(limit=2)", lambda: client.list_departments(limit=2)) +dept_code = "" +if dept_pager and dept_pager.results: + dept_code = str(dept_pager.results[0].get("code", "")) +run( + f"get_department({dept_code!r})", + lambda: client.get_department(dept_code), + skip_if_blank=dept_code, +) + +psc_pager = run("list_psc(limit=2)", lambda: client.list_psc(limit=2)) +psc_code = "" +if psc_pager and psc_pager.results: + psc_code = str(psc_pager.results[0].get("code", "")) +run( + f"get_psc({psc_code!r})", + lambda: client.get_psc(psc_code), + skip_if_blank=psc_code, +) +run( + f"get_psc_metrics({psc_code!r}, 12, 'month')", + lambda: client.get_psc_metrics(psc_code, 12, "month"), + skip_if_blank=psc_code, +) + +# A NAICS we know exists in seed data +NAICS_CODE = "541511" +run(f"get_naics({NAICS_CODE!r})", lambda: client.get_naics(NAICS_CODE)) +run( + f"get_naics_metrics({NAICS_CODE!r}, 12, 'month')", + lambda: client.get_naics_metrics(NAICS_CODE, 12, "month"), +) + +bt_pager = run("list_business_types(limit=1)", lambda: client.list_business_types(limit=1)) +bt_code = "" +if bt_pager and bt_pager.results: + bt_code = str(bt_pager.results[0].code or "") +run( + f"get_business_type({bt_code!r})", + lambda: client.get_business_type(bt_code), + skip_if_blank=bt_code, +) + +al_pager = run( + "list_assistance_listings(limit=2)", lambda: client.list_assistance_listings(limit=2) +) +al_number = "" +if al_pager and al_pager.results: + al_number = str(al_pager.results[0].get("number", "")) +run( + f"get_assistance_listing({al_number!r})", + lambda: client.get_assistance_listing(al_number), + skip_if_blank=al_number, +) + +sin_pager = run("list_mas_sins(limit=2)", lambda: client.list_mas_sins(limit=2)) +sin = "" +if sin_pager and sin_pager.results: + r = sin_pager.results[0] + sin = str(r.get("sin") or r.get("code") or r.get("number") or "") +run( + f"get_mas_sin({sin!r})", + lambda: client.get_mas_sin(sin), + skip_if_blank=sin, +) + + +# ============================================================================ +# Entity sub-resources +# ============================================================================ + +print("\n=== Entity sub-resources ===") +ent_pager = client.list_entities(limit=1) +uei = "" +if ent_pager.results: + uei = str(ent_pager.results[0].get("uei") or "") + +run( + f"list_entity_contracts({uei!r}, limit=2)", + lambda: client.list_entity_contracts(uei, limit=2), + skip_if_blank=uei, +) +run( + f"list_entity_idvs({uei!r}, limit=2)", + lambda: client.list_entity_idvs(uei, limit=2), + skip_if_blank=uei, +) +run( + f"list_entity_otas({uei!r}, limit=2)", + lambda: client.list_entity_otas(uei, limit=2), + skip_if_blank=uei, +) +run( + f"list_entity_otidvs({uei!r}, limit=2)", + lambda: client.list_entity_otidvs(uei, limit=2), + skip_if_blank=uei, +) +run( + f"list_entity_subawards({uei!r}, limit=2)", + lambda: client.list_entity_subawards(uei, limit=2), + skip_if_blank=uei, +) +run( + f"list_entity_lcats({uei!r}, limit=2)", + lambda: client.list_entity_lcats(uei, limit=2), + skip_if_blank=uei, +) +run( + f"get_entity_metrics({uei!r}, 12, 'month')", + lambda: client.get_entity_metrics(uei, 12, "month"), + skip_if_blank=uei, +) + + +# ============================================================================ +# IDV / Agency sub-resources +# ============================================================================ + +print("\n=== IDV / Agency sub-resources ===") +idv_pager = client.list_idvs(limit=1) +idv_key = "" +if idv_pager.results: + idv_key = str(idv_pager.results[0]["key"]) +run( + f"list_idv_lcats({idv_key!r}, limit=2)", + lambda: client.list_idv_lcats(idv_key, limit=2), + skip_if_blank=idv_key, +) + +ag_pager = client.list_agencies(limit=1) +ag_code = "" +if ag_pager.results: + ag_code = str(ag_pager.results[0].code or "") +run( + f"list_agency_awarding_contracts({ag_code!r}, limit=2)", + lambda: client.list_agency_awarding_contracts(ag_code, limit=2), + skip_if_blank=ag_code, +) +# funding-contracts can 504 locally if the agency has a wide net of awards +# (heavy aggregation). Catch and SKIP server timeouts. +_fund_label = f"list_agency_funding_contracts({ag_code!r}, limit=2)" +if ag_code: + try: + client.list_agency_funding_contracts(ag_code, limit=2) + results.append((_fund_label, True, "ok")) + print(f" PASS {_fund_label}") + except Exception as exc: # noqa: BLE001 + msg = f"{type(exc).__name__}: {exc}" + if "504" in msg or "timeout" in msg.lower(): + results.append((_fund_label, True, f"skipped: server {msg}")) + print(f" SKIP {_fund_label} — {msg}") + else: + results.append((_fund_label, False, msg)) + print(f" FAIL {_fund_label} — {msg}") +else: + results.append((_fund_label, True, "skipped: dependency unavailable")) + print(f" SKIP {_fund_label}") + + +# ============================================================================ +# ordering= round-trips +# ============================================================================ + +print("\n=== ordering= round-trips ===") +run( + "list_forecasts(ordering='fiscal_year', limit=2)", + lambda: client.list_forecasts(ordering="fiscal_year", limit=2), +) +run( + "list_grants(ordering='-posted_date', limit=2)", + lambda: client.list_grants(ordering="-posted_date", limit=2), +) +run( + "list_opportunities(ordering='-last_notice_date', limit=2)", + lambda: client.list_opportunities(ordering="-last_notice_date", limit=2), +) +run( + "list_notices(limit=2) (no ordering — endpoint rejects ordering server-side)", + lambda: client.list_notices(limit=2), +) +run( + "list_protests(limit=2) (no ordering — endpoint rejects ordering server-side)", + lambda: client.list_protests(limit=2), +) +run( + "list_subawards(ordering='-last_modified_date', limit=2)", + lambda: client.list_subawards(ordering="-last_modified_date", limit=2), +) +run( + "list_gsa_elibrary_contracts(ordering='piid', limit=2)", + lambda: client.list_gsa_elibrary_contracts(ordering="piid", limit=2), +) + + +# ============================================================================ +# resolve / validate +# ============================================================================ + +print("\n=== resolve / validate ===") +run( + "resolve('Microsoft', target_type='entity')", + lambda: client.resolve("Microsoft", target_type="entity"), +) +run( + "validate('uei', 'TESTUEI12345')", + lambda: client.validate("uei", "TESTUEI12345"), +) +run( + "validate('piid', '47QSMA22D08PT')", + lambda: client.validate("piid", "47QSMA22D08PT"), +) + + +# ============================================================================ +# Misc +# ============================================================================ + +print("\n=== Misc ===") +run("get_version()", client.get_version) +run("list_api_keys()", client.list_api_keys) +# attachment-search may 404 locally if the feature flag / RAG index isn't set +# up. Treat 404 as SKIP, anything else as a real result. +_attach_label = "search_opportunity_attachments(q='cyber', top_k=3)" +try: + client.search_opportunity_attachments(q="cyber", top_k=3) + results.append((_attach_label, True, "ok")) + print(f" PASS {_attach_label}") +except Exception as exc: # noqa: BLE001 + if type(exc).__name__ == "TangoNotFoundError": + results.append( + (_attach_label, True, "skipped: 404 (feature flag / index not enabled locally)") + ) + print(f" SKIP {_attach_label} — 404 locally") + else: + results.append((_attach_label, False, f"{type(exc).__name__}: {exc}")) + print(f" FAIL {_attach_label} — {type(exc).__name__}: {exc}") + + +# ============================================================================ +# _post json= kwarg backcompat +# ============================================================================ + +print("\n=== _post json= kwarg backcompat ===") +run( + "_post(json=) backcompat (resolve via internal helper)", + lambda: client._post( + "/api/resolve/", + json={"name": "Microsoft", "target_type": "entity"}, + ), +) + + +# ============================================================================ +# Webhook write methods (mutations: create + verify + delete) +# ============================================================================ + +print("\n=== Webhook endpoint create/update/delete ===") +endpoint_name = f"smoke-python-{int(time.time())}" +created_endpoint: WebhookEndpoint | None = None + + +def _create_endpoint() -> WebhookEndpoint: + return client.create_webhook_endpoint( + callback_url=CALLBACK_URL, is_active=True, name=endpoint_name + ) + + +created_endpoint = run("create_webhook_endpoint(name=...)", _create_endpoint) + +if created_endpoint: + ep_id = created_endpoint.id + + run( + f"get_webhook_endpoint({ep_id!r})", + lambda: client.get_webhook_endpoint(ep_id), + ) + run( + "update_webhook_endpoint(is_active=False)", + lambda: client.update_webhook_endpoint(ep_id, is_active=False), + ) + + # Alert (filter subscription) — pass endpoint explicitly so this works + # for multi-endpoint accounts as well as single-endpoint ones. + alert: WebhookAlert | None = run( + "create_webhook_alert(endpoint=...)", + lambda: client.create_webhook_alert( + name=f"smoke-alert-{int(time.time())}", + query_type="opportunity", + filters={"naics": "541511"}, + frequency="daily", + endpoint=ep_id, + ), + ) + + if alert: + aid = alert.alert_id + run(f"get_webhook_alert({aid!r})", lambda: client.get_webhook_alert(aid)) + run( + "update_webhook_alert(name='renamed')", + lambda: client.update_webhook_alert(aid, name="renamed"), + ) + run("list_webhook_alerts()", client.list_webhook_alerts) + run( + "delete_webhook_alert(...)", + lambda: client.delete_webhook_alert(aid), + ) + else: + run("list_webhook_alerts()", client.list_webhook_alerts) + + # Cleanup endpoint + run( + f"delete_webhook_endpoint({ep_id!r})", + lambda: client.delete_webhook_endpoint(ep_id), + ) + + +# ============================================================================ +# Summary +# ============================================================================ + +passed = sum(1 for _, ok, _ in results if ok) +failed = sum(1 for _, ok, _ in results if not ok) +total = len(results) +print("\n" + "=" * 70) +print(f"Smoke summary: {passed} passed, {failed} failed, {total} total") +print("=" * 70) +if failed: + for label, ok, msg in results: + if not ok: + print(f" FAIL {label}: {msg}") + sys.exit(1) +sys.exit(0) diff --git a/tango/__init__.py b/tango/__init__.py index 814d393..9c25b42 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -13,15 +13,21 @@ ITDashboardInvestment, PaginatedResponse, RateLimitInfo, + ResolveCandidate, + ResolveResult, SearchFilters, ShapeConfig, + ValidateResult, Vehicle, VehicleMetrics, + WebhookAlert, WebhookEndpoint, WebhookEventType, WebhookEventTypesResponse, - WebhookSubjectTypeDefinition, - WebhookSubscription, + WebhookSampleDelivery, + WebhookSamplePayloadAllResponse, + WebhookSamplePayloadResponse, + WebhookSamplePayloadSingleResponse, WebhookTestDeliveryResult, ) from .shapes import ( @@ -37,7 +43,7 @@ ) from .webhooks.receiver import Delivery, WebhookReceiver -__version__ = "0.6.0" +__version__ = "1.0.0" __all__ = [ "TangoClient", "TangoAPIError", @@ -46,18 +52,24 @@ "TangoValidationError", "TangoRateLimitError", "RateLimitInfo", + "ResolveCandidate", + "ResolveResult", "GsaElibraryContract", "ITDashboardInvestment", "PaginatedResponse", "SearchFilters", "ShapeConfig", + "ValidateResult", "Vehicle", "VehicleMetrics", + "WebhookAlert", "WebhookEndpoint", "WebhookEventType", "WebhookEventTypesResponse", - "WebhookSubscription", - "WebhookSubjectTypeDefinition", + "WebhookSampleDelivery", + "WebhookSamplePayloadAllResponse", + "WebhookSamplePayloadResponse", + "WebhookSamplePayloadSingleResponse", "WebhookTestDeliveryResult", "ShapeParser", "ModelFactory", diff --git a/tango/client.py b/tango/client.py index 108aa83..a4d98e4 100644 --- a/tango/client.py +++ b/tango/client.py @@ -35,15 +35,18 @@ PaginatedResponse, Protest, RateLimitInfo, + ResolveCandidate, + ResolveResult, SearchFilters, ShapeConfig, Subaward, + ValidateResult, Vehicle, + WebhookAlert, WebhookEndpoint, WebhookEventType, WebhookEventTypesResponse, - WebhookSubjectTypeDefinition, - WebhookSubscription, + WebhookSamplePayloadResponse, WebhookTestDeliveryResult, ) from tango.shapes import ( @@ -202,13 +205,51 @@ def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, """Make a GET request""" return self._request("GET", endpoint, params=params) - def _post(self, endpoint: str, json_data: dict[str, Any]) -> dict[str, Any]: - """Make a POST request""" - return self._request("POST", endpoint, json_data=json_data) + def _post( + self, + endpoint: str, + json_data: dict[str, Any] | None = None, + *, + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make a POST request. - def _patch(self, endpoint: str, json_data: dict[str, Any]) -> dict[str, Any]: - """Make a PATCH request""" - return self._request("PATCH", endpoint, json_data=json_data) + Accepts either ``json_data`` (positional) or ``json=`` (keyword) for + backward compatibility with internal callers and docs examples. + Passing both raises ``TangoValidationError`` rather than silently + picking one — that ambiguity would hide caller bugs. + """ + if json_data is not None and json is not None: + raise TangoValidationError( + "_post: pass `json_data` or `json`, not both." + ) + body = json_data if json_data is not None else json + if body is None: + body = {} + return self._request("POST", endpoint, json_data=body) + + def _patch( + self, + endpoint: str, + json_data: dict[str, Any] | None = None, + *, + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make a PATCH request. + + Accepts either ``json_data`` (positional) or ``json=`` (keyword) for + backward compatibility with internal callers and docs examples. + Passing both raises ``TangoValidationError`` rather than silently + picking one — that ambiguity would hide caller bugs. + """ + if json_data is not None and json is not None: + raise TangoValidationError( + "_patch: pass `json_data` or `json`, not both." + ) + body = json_data if json_data is not None else json + if body is None: + body = {} + return self._request("PATCH", endpoint, json_data=body) def _delete(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]: """Make a DELETE request""" @@ -1218,6 +1259,7 @@ def list_subawards( fiscal_year_gte: int | None = None, fiscal_year_lte: int | None = None, funding_agency: str | None = None, + ordering: str | None = None, prime_uei: str | None = None, recipient: str | None = None, sub_uei: str | None = None, @@ -1239,6 +1281,7 @@ def list_subawards( ("fiscal_year_gte", fiscal_year_gte), ("fiscal_year_lte", fiscal_year_lte), ("funding_agency", funding_agency), + ("ordering", ordering), ("prime_uei", prime_uei), ("recipient", recipient), ("sub_uei", sub_uei), @@ -1271,6 +1314,7 @@ def list_gsa_elibrary_contracts( joiner: str = ".", contract_number: str | None = None, key: str | None = None, + ordering: str | None = None, piid: str | None = None, schedule: str | None = None, search: str | None = None, @@ -1292,6 +1336,7 @@ def list_gsa_elibrary_contracts( for k, val in ( ("contract_number", contract_number), ("key", key), + ("ordering", ordering), ("piid", piid), ("schedule", schedule), ("search", search), @@ -1895,6 +1940,7 @@ def list_forecasts( modified_before: str | None = None, naics_code: str | None = None, naics_starts_with: str | None = None, + ordering: str | None = None, search: str | None = None, source_system: str | None = None, status: str | None = None, @@ -1918,6 +1964,7 @@ def list_forecasts( modified_before: Modified before date naics_code: NAICS code filter naics_starts_with: NAICS code prefix filter + ordering: Sort field (prefix with '-' for descending) search: Search query source_system: Source system filter status: Status filter @@ -1944,6 +1991,7 @@ def list_forecasts( ("modified_before", modified_before), ("naics_code", naics_code), ("naics_starts_with", naics_starts_with), + ("ordering", ordering), ("search", search), ("source_system", source_system), ("status", status), @@ -1982,6 +2030,7 @@ def list_opportunities( last_notice_date_before: str | None = None, naics: str | None = None, notice_type: str | None = None, + ordering: str | None = None, place_of_performance: str | None = None, psc: str | None = None, response_deadline_after: str | None = None, @@ -2007,6 +2056,7 @@ def list_opportunities( last_notice_date_before: Last notice date before naics: NAICS code filter notice_type: Notice type filter + ordering: Sort field (prefix with '-' for descending) place_of_performance: Place of performance filter psc: PSC code filter response_deadline_after: Response deadline after @@ -2035,6 +2085,7 @@ def list_opportunities( ("last_notice_date_before", last_notice_date_before), ("naics", naics), ("notice_type", notice_type), + ("ordering", ordering), ("place_of_performance", place_of_performance), ("psc", psc), ("response_deadline_after", response_deadline_after), @@ -2085,6 +2136,9 @@ def list_notices( """ List contract notices + Note: the notices viewset rejects every ``?ordering=`` value at + runtime, so this method does not expose an ``ordering`` kwarg. + Args: page: Page number limit: Results per page (max 100) @@ -2177,6 +2231,10 @@ def list_protests( Returns case-level protest records. Use shape=...,dockets(...) to include nested dockets. API reference: https://tango.makegov.com/docs/api-reference/protests.md + Note: the protests viewset does not advertise ``ordering`` and + rejects every value at runtime, so this method does not expose an + ``ordering`` kwarg. + Args: page: Page number limit: Results per page (max 100) @@ -2284,6 +2342,7 @@ def list_grants( funding_categories: str | None = None, funding_instruments: str | None = None, opportunity_number: str | None = None, + ordering: str | None = None, posted_date_after: str | None = None, posted_date_before: str | None = None, response_date_after: str | None = None, @@ -2306,6 +2365,7 @@ def list_grants( funding_categories: Funding categories filter funding_instruments: Funding instruments filter opportunity_number: Opportunity number filter + ordering: Sort field (prefix with '-' for descending) posted_date_after: Posted date after posted_date_before: Posted date before response_date_after: Response date after @@ -2331,6 +2391,7 @@ def list_grants( ("funding_categories", funding_categories), ("funding_instruments", funding_instruments), ("opportunity_number", opportunity_number), + ("ordering", ordering), ("posted_date_after", posted_date_after), ("posted_date_before", posted_date_before), ("response_date_after", response_date_after), @@ -2361,13 +2422,12 @@ def list_grants( # ============================================================================ def list_webhook_event_types(self) -> WebhookEventTypesResponse: - """Discover supported webhook event types and subject types.""" + """Discover supported webhook event types.""" data = self._get("/api/webhooks/event-types/") event_types = [ WebhookEventType( event_type=str(e.get("event_type", "")), - default_subject_type=str(e.get("default_subject_type", "")), description=str(e.get("description", "")), schema_version=int(e.get("schema_version", 1)), ) @@ -2375,123 +2435,7 @@ def list_webhook_event_types(self) -> WebhookEventTypesResponse: if isinstance(e, dict) ] - subject_types = [str(x) for x in (data.get("subject_types") or [])] - - subject_type_definitions = [ - WebhookSubjectTypeDefinition( - subject_type=str(d.get("subject_type", "")), - description=str(d.get("description", "")), - id_format=str(d.get("id_format", "")), - status=str(d.get("status", "active")), - ) - for d in (data.get("subject_type_definitions") or []) - if isinstance(d, dict) - ] - - return WebhookEventTypesResponse( - event_types=event_types, - subject_types=subject_types, - subject_type_definitions=subject_type_definitions, - ) - - def list_webhook_subscriptions( - self, page: int = 1, page_size: int | None = None - ) -> PaginatedResponse[WebhookSubscription]: - """ - List webhook subscriptions for the authenticated user's endpoint. - - Notes: - - This endpoint uses `page` + `page_size` (tier-capped) rather than `limit`. - """ - params: dict[str, Any] = {"page": page} - if page_size is not None: - params["page_size"] = page_size - - data = self._get("/api/webhooks/subscriptions/", params) - results = [ - WebhookSubscription( - id=str(item.get("id", "")), - endpoint=str(item.get("endpoint")) if item.get("endpoint") is not None else None, - subscription_name=str(item.get("subscription_name", "")), - payload=item.get("payload"), - created_at=str(item.get("created_at", "")), - ) - for item in (data.get("results") or []) - if isinstance(item, dict) - ] - - return PaginatedResponse( - count=int(data.get("count", len(results))), - next=data.get("next"), - previous=data.get("previous"), - results=results, - ) - - def get_webhook_subscription(self, subscription_id: str) -> WebhookSubscription: - """Get a single webhook subscription by id (UUID).""" - if not subscription_id: - raise TangoValidationError("Webhook subscription_id is required") - - data = self._get(f"/api/webhooks/subscriptions/{subscription_id}/") - return WebhookSubscription( - id=str(data.get("id", "")), - endpoint=str(data.get("endpoint")) if data.get("endpoint") is not None else None, - subscription_name=str(data.get("subscription_name", "")), - payload=data.get("payload"), - created_at=str(data.get("created_at", "")), - ) - - def create_webhook_subscription( - self, subscription_name: str, payload: dict[str, Any] - ) -> WebhookSubscription: - """Create a webhook subscription.""" - if not subscription_name: - raise TangoValidationError("Webhook subscription_name is required") - - data = self._post( - "/api/webhooks/subscriptions/", - {"subscription_name": subscription_name, "payload": payload}, - ) - - return WebhookSubscription( - id=str(data.get("id", "")), - endpoint=str(data.get("endpoint")) if data.get("endpoint") is not None else None, - subscription_name=str(data.get("subscription_name", "")), - payload=data.get("payload"), - created_at=str(data.get("created_at", "")), - ) - - def update_webhook_subscription( - self, - subscription_id: str, - *, - subscription_name: str | None = None, - payload: dict[str, Any] | None = None, - ) -> WebhookSubscription: - """Patch a webhook subscription.""" - if not subscription_id: - raise TangoValidationError("Webhook subscription_id is required") - - body: dict[str, Any] = {} - if subscription_name is not None: - body["subscription_name"] = subscription_name - if payload is not None: - body["payload"] = payload - - data = self._patch(f"/api/webhooks/subscriptions/{subscription_id}/", body) - return WebhookSubscription( - id=str(data.get("id", "")), - endpoint=str(data.get("endpoint")) if data.get("endpoint") is not None else None, - subscription_name=str(data.get("subscription_name", "")), - payload=data.get("payload"), - created_at=str(data.get("created_at", "")), - ) - - def delete_webhook_subscription(self, subscription_id: str) -> None: - """Delete a webhook subscription.""" - if not subscription_id: - raise TangoValidationError("Webhook subscription_id is required") - self._delete(f"/api/webhooks/subscriptions/{subscription_id}/") + return WebhookEventTypesResponse(event_types=event_types) def get_webhook_endpoint(self, endpoint_id: str) -> WebhookEndpoint: """Get a webhook endpoint by id (UUID).""" @@ -2542,21 +2486,48 @@ def list_webhook_endpoints( results=results, ) - def create_webhook_endpoint(self, callback_url: str, is_active: bool = True) -> WebhookEndpoint: + def create_webhook_endpoint( + self, + callback_url: str, + is_active: bool = True, + *, + name: str | None = None, + ) -> WebhookEndpoint: """ Create a webhook endpoint for the authenticated user. + Args: + callback_url: HTTPS URL to receive POSTed webhook events. + is_active: Whether deliveries are enabled. + name: Human-readable name for this endpoint. Required by the + Tango API (the server enforces ``unique(user, name)``). + Currently keyword-optional in the SDK for backward + compatibility — passing it explicitly is strongly + recommended; a future major version will make it required. + When omitted, callers will see a server-side 400 + ``"name: This field is required"``. + Note: - - The server generates `secret` and manages `name`. - - Only one endpoint per user is allowed; if one already exists, this will fail. + The server generates ``secret``. A user may have multiple + endpoints (unique on ``(user, name)``). """ if not callback_url: raise TangoValidationError("Webhook callback_url is required") + if name is None: + # `name` was a deprecation warning in 0.7.0 (never publicly + # released). 1.0.0 makes it a hard error since the server + # enforces unique(user, name) and the warn-then-400 path + # was a worse DX than just raising client-side. + raise TangoValidationError( + "create_webhook_endpoint(): `name=` is required. " + "The Tango API enforces unique(user, name) on endpoints; " + "omitting it would return a 400 server-side. " + "Pass name='your-endpoint-name'." + ) - data = self._post( - "/api/webhooks/endpoints/", - {"callback_url": callback_url, "is_active": is_active}, - ) + body: dict[str, Any] = {"callback_url": callback_url, "is_active": is_active, "name": name} + + data = self._post("/api/webhooks/endpoints/", body) return WebhookEndpoint( id=str(data.get("id", "")), name=str(data.get("name", "")), @@ -2571,6 +2542,7 @@ def update_webhook_endpoint( self, endpoint_id: str, *, + name: str | None = None, callback_url: str | None = None, is_active: bool | None = None, ) -> WebhookEndpoint: @@ -2579,6 +2551,8 @@ def update_webhook_endpoint( raise TangoValidationError("Webhook endpoint_id is required") body: dict[str, Any] = {} + if name is not None: + body["name"] = name if callback_url is not None: body["callback_url"] = callback_url if is_active is not None: @@ -2605,11 +2579,14 @@ def test_webhook_delivery(self, endpoint_id: str | None = None) -> WebhookTestDe """ Send an immediate test webhook to your endpoint. - If endpoint_id is not provided, the server will use your default endpoint. + If endpoint_id is not provided, the server will use your default + endpoint. The kwarg is named ``endpoint_id`` for backwards-compat; + the wire payload uses the canonical ``endpoint`` key (the server + still accepts ``endpoint_id`` as a deprecated alias on this route). """ body: dict[str, Any] = {} if endpoint_id: - body["endpoint_id"] = endpoint_id + body["endpoint"] = endpoint_id data = self._post("/api/webhooks/endpoints/test-delivery/", body) return WebhookTestDeliveryResult( success=bool(data.get("success", False)), @@ -2628,14 +2605,820 @@ def test_webhook_delivery(self, endpoint_id: str | None = None) -> WebhookTestDe test_payload=data.get("test_payload"), ) - def get_webhook_sample_payload(self, event_type: str | None = None) -> dict[str, Any]: + def get_webhook_sample_payload( + self, event_type: str | None = None + ) -> WebhookSamplePayloadResponse: """ Fetch Tango-shaped sample webhook deliveries. - - If event_type is provided, returns the single-event response. - - Otherwise returns a `samples` mapping for all supported event types. + - If ``event_type`` is provided, returns a :class:`WebhookSamplePayloadSingleResponse`. + - Otherwise returns a :class:`WebhookSamplePayloadAllResponse` with a ``samples`` + mapping keyed by event type. """ params: dict[str, Any] = {} if event_type: params["event_type"] = event_type - return self._get("/api/webhooks/endpoints/sample-payload/", params) + return self._get("/api/webhooks/endpoints/sample-payload/", params) # type: ignore[return-value] + + # ============================================================================ + # Webhook Alerts (filter-based subscriptions) + # ============================================================================ + + @staticmethod + def _parse_webhook_alert(data: dict[str, Any]) -> WebhookAlert: + """Hydrate an Alert dict from /api/webhooks/alerts/ into a WebhookAlert.""" + return WebhookAlert( + alert_id=str(data.get("alert_id") or data.get("id") or ""), + name=str(data.get("name") or data.get("subscription_name") or ""), + query_type=(str(data["query_type"]) if data.get("query_type") is not None else None), + filters=data.get("filters") or data.get("filter_definition"), + frequency=str(data.get("frequency", "realtime")), + cron_expression=( + str(data["cron_expression"]) if data.get("cron_expression") is not None else None + ), + status=str(data.get("status", "active")), + created_at=str(data.get("created_at", "")), + last_checked_at=( + str(data["last_checked_at"]) if data.get("last_checked_at") is not None else None + ), + match_count=int(data.get("match_count", 0) or 0), + ) + + def list_webhook_alerts( + self, page: int = 1, page_size: int | None = None + ) -> PaginatedResponse[WebhookAlert]: + """List filter-based webhook subscriptions (alerts). + + Backed by ``GET /api/webhooks/alerts/`` — the canonical (and only) + write surface for webhook subscriptions. Uses the cleaner ``alert_id`` + / ``name`` / ``filters`` shape rather than the raw subscriptions + model fields. + """ + params: dict[str, Any] = {"page": page} + if page_size is not None: + params["page_size"] = page_size + data = self._get("/api/webhooks/alerts/", params) + results = [ + self._parse_webhook_alert(item) + for item in (data.get("results") or []) + if isinstance(item, dict) + ] + return PaginatedResponse( + count=int(data.get("count", len(results))), + next=data.get("next"), + previous=data.get("previous"), + results=results, + ) + + def get_webhook_alert(self, alert_id: str) -> WebhookAlert: + """Get a single filter-based webhook subscription by alert_id (UUID).""" + if not alert_id: + raise TangoValidationError("Webhook alert_id is required") + data = self._get(f"/api/webhooks/alerts/{alert_id}/") + return self._parse_webhook_alert(data) + + def create_webhook_alert( + self, + name: str, + query_type: str, + filters: dict[str, Any], + *, + frequency: str = "realtime", + cron_expression: str | None = None, + endpoint: str | None = None, + ) -> WebhookAlert: + """Create a filter-based webhook subscription (alert). + + Args: + name: Human-readable name for this alert. + query_type: One of ``opportunity``, ``contract``, ``idv``, ``ota``, + ``otidv``, ``entity``, ``grant``, ``forecast``. + filters: Dict of query parameters that the alert matches against + (e.g. ``{"naics": "541330", "set_aside": "SBA"}``). + frequency: ``realtime`` | ``daily`` | ``weekly`` | ``custom``. + ``custom`` requires ``cron_expression`` and Pro+ tier. + cron_expression: 5-field cron expression, only valid when + ``frequency="custom"``. + endpoint: Optional UUID of the :class:`WebhookEndpoint` this + alert delivers to. Required when the account has multiple + endpoints (the server returns 400 otherwise); for + single-endpoint accounts the server auto-resolves. + + Returns: + The created (or, if a dedup-matched alert already exists, the + existing) :class:`WebhookAlert`. Both 201-Created and 200-OK + (dedup) responses are normalized to the same shape. + """ + if not name: + raise TangoValidationError("Webhook alert name is required") + if not query_type: + raise TangoValidationError("Webhook alert query_type is required") + if not filters or not isinstance(filters, dict): + raise TangoValidationError( + "Webhook alert filters must be a non-empty dict of query params" + ) + + body: dict[str, Any] = { + "name": name, + "query_type": query_type, + "filters": filters, + "frequency": frequency, + } + if cron_expression is not None: + body["cron_expression"] = cron_expression + if endpoint is not None: + body["endpoint"] = endpoint + + data = self._post("/api/webhooks/alerts/", body) + return self._parse_webhook_alert(data) + + def update_webhook_alert( + self, + alert_id: str, + *, + name: str | None = None, + frequency: str | None = None, + cron_expression: str | None = None, + is_active: bool | None = None, + ) -> WebhookAlert: + """Patch a webhook alert (filter subscription). + + Only ``name``, ``frequency``, ``cron_expression``, and ``is_active`` + are writable — ``query_type`` and ``filters`` are read-only after + creation (the server treats them as part of the alert's identity + via ``filter_hash``). + """ + if not alert_id: + raise TangoValidationError("Webhook alert_id is required") + + body: dict[str, Any] = {} + if name is not None: + body["name"] = name + if frequency is not None: + body["frequency"] = frequency + if cron_expression is not None: + body["cron_expression"] = cron_expression + if is_active is not None: + body["is_active"] = is_active + + data = self._patch(f"/api/webhooks/alerts/{alert_id}/", body) + return self._parse_webhook_alert(data) + + def delete_webhook_alert(self, alert_id: str) -> None: + """Delete a webhook alert (filter subscription).""" + if not alert_id: + raise TangoValidationError("Webhook alert_id is required") + self._delete(f"/api/webhooks/alerts/{alert_id}/") + + # ============================================================================ + # Resolve & Validate (POST endpoints) + # ============================================================================ + + def resolve( + self, + name: str, + target_type: str, + *, + state: str | None = None, + city: str | None = None, + context: str | None = None, + ) -> ResolveResult: + """Resolve a free-text name to ranked entity or organization candidates. + + ``POST /api/resolve/`` + + Args: + name: Name to resolve. + target_type: ``"entity"`` or ``"organization"``. + state: Optional 2-letter US state code to bias matching. + city: Optional city name to bias matching. + context: Optional freeform additional context for better matching. + + Returns: + :class:`ResolveResult` with ranked candidates. Free-tier callers + get up to 3 candidates with ``identifier`` and ``display_name``; + Pro+ callers get up to 5 with an additional ``match_tier`` field. + Other server-returned fields are preserved on each candidate's + ``extra`` dict. + """ + if not name: + raise TangoValidationError("resolve(): name is required") + if target_type not in ("entity", "organization"): + raise TangoValidationError("resolve(): target_type must be 'entity' or 'organization'") + + body: dict[str, Any] = {"name": name, "target_type": target_type} + if state is not None: + body["state"] = state + if city is not None: + body["city"] = city + if context is not None: + body["context"] = context + + data = self._post("/api/resolve/", body) + candidates: list[ResolveCandidate] = [] + for raw in data.get("candidates") or []: + if not isinstance(raw, dict): + continue + extras = { + k: v + for k, v in raw.items() + if k not in {"identifier", "display_name", "match_tier"} + } + candidates.append( + ResolveCandidate( + identifier=raw.get("identifier"), + display_name=raw.get("display_name"), + match_tier=raw.get("match_tier"), + extra=extras or None, + ) + ) + return ResolveResult( + candidates=candidates, + count=int(data.get("count", len(candidates))), + ) + + def validate(self, identifier_type: str, value: str) -> ValidateResult: + """Validate the format of a PIID, solicitation number, or UEI. + + ``POST /api/validate/`` + + Args: + identifier_type: One of ``"piid"``, ``"solicitation"``, ``"uei"``. + (Maps to the API's ``type`` field — ``identifier_type`` here + avoids shadowing the Python builtin.) + value: The identifier value to validate. + + Returns: + :class:`ValidateResult` with ``result`` in ``{"valid", + "not_valid", "low_confidence"}``. ``low_confidence`` applies only + to solicitation numbers that pass basic checks but don't match a + named pattern. + """ + if identifier_type not in ("piid", "solicitation", "uei"): + raise TangoValidationError( + "validate(): identifier_type must be 'piid', 'solicitation', or 'uei'" + ) + if not value: + raise TangoValidationError("validate(): value is required") + + data = self._post("/api/validate/", {"type": identifier_type, "value": value}) + return ValidateResult( + result=str(data.get("result", "")), + type=str(data.get("type", identifier_type)), + value=str(data.get("value", value)), + errors=list(data.get("errors") or []) or None, + ) + + # ============================================================================ + # Reference data + # ============================================================================ + + def list_departments(self, page: int = 1, limit: int = 25) -> PaginatedResponse[dict[str, Any]]: + """List departments (`/api/departments/`).""" + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + data = self._get("/api/departments/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + def get_department(self, code: str) -> dict[str, Any]: + """Get a department by code (`/api/departments/{code}/`).""" + if not code: + raise TangoValidationError("Department code is required") + return self._get(f"/api/departments/{code}/") + + def list_psc(self, page: int = 1, limit: int = 25) -> PaginatedResponse[dict[str, Any]]: + """List Product Service Codes (`/api/psc/`).""" + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + data = self._get("/api/psc/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + def get_psc(self, code: str) -> dict[str, Any]: + """Get a Product Service Code by code (`/api/psc/{code}/`).""" + if not code: + raise TangoValidationError("PSC code is required") + return self._get(f"/api/psc/{code}/") + + def get_psc_metrics(self, code: str, months: int, period_grouping: str) -> dict[str, Any]: + """Get rolling PSC metrics (`/api/psc/{code}/metrics/{months}/{period_grouping}/`). + + Args: + code: PSC code. + months: Window size in months (e.g. 6, 12, 24, 36). + period_grouping: ``"month"``, ``"quarter"``, or ``"year"`` (the + values the API accepts on the path segment). + """ + if not code: + raise TangoValidationError("PSC code is required") + return self._get(f"/api/psc/{code}/metrics/{months}/{period_grouping}/") + + def get_naics(self, code: str) -> dict[str, Any]: + """Get a NAICS code by code (`/api/naics/{code}/`).""" + if not code: + raise TangoValidationError("NAICS code is required") + return self._get(f"/api/naics/{code}/") + + def get_naics_metrics(self, code: str, months: int, period_grouping: str) -> dict[str, Any]: + """Get rolling NAICS metrics (`/api/naics/{code}/metrics/{months}/{period_grouping}/`).""" + if not code: + raise TangoValidationError("NAICS code is required") + return self._get(f"/api/naics/{code}/metrics/{months}/{period_grouping}/") + + def get_business_type(self, code: str) -> dict[str, Any]: + """Get a business type by code (`/api/business_types/{code}/`).""" + if not code: + raise TangoValidationError("Business type code is required") + return self._get(f"/api/business_types/{code}/") + + def list_assistance_listings( + self, page: int = 1, limit: int = 25 + ) -> PaginatedResponse[dict[str, Any]]: + """List Assistance Listings (CFDA programs) (`/api/assistance_listings/`).""" + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + data = self._get("/api/assistance_listings/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + def get_assistance_listing(self, number: str) -> dict[str, Any]: + """Get an Assistance Listing by CFDA number (`/api/assistance_listings/{number}/`).""" + if not number: + raise TangoValidationError("Assistance listing number is required") + return self._get(f"/api/assistance_listings/{number}/") + + def list_mas_sins( + self, + page: int = 1, + limit: int = 25, + search: str | None = None, + ) -> PaginatedResponse[dict[str, Any]]: + """List GSA MAS SINs (`/api/mas_sins/`).""" + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + if search is not None: + params["search"] = search + data = self._get("/api/mas_sins/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + def get_mas_sin(self, sin: str) -> dict[str, Any]: + """Get a MAS SIN by code (`/api/mas_sins/{sin}/`).""" + if not sin: + raise TangoValidationError("MAS SIN is required") + return self._get(f"/api/mas_sins/{sin}/") + + # ============================================================================ + # Entity sub-resources + # ============================================================================ + + def _entity_subresource_contracts( + self, + uei: str, + endpoint_segment: str, + model: type, + *, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + **filters: Any, + ) -> PaginatedResponse: + """Shared helper for /api/entities/{uei}/{contracts,idvs,otas,otidvs}/.""" + params: dict[str, Any] = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + if shape is None: + # Conservative minimal default — caller can override per-call. + shape = "key,piid,award_date,recipient(display_name)" + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + for k, v in filters.items(): + if v is not None: + params[k] = v + data = self._get(f"/api/entities/{uei}/{endpoint_segment}/", params) + results = [ + self._parse_response_with_shape(obj, shape, model, flat, flat_lists, joiner=joiner) + for obj in (data.get("results") or []) + ] + return PaginatedResponse( + count=int(data.get("count") or len(results)), + next=data.get("next"), + previous=data.get("previous"), + results=results, + cursor=data.get("cursor"), + page_metadata=data.get("page_metadata"), + ) + + def list_entity_contracts( + self, + uei: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List contracts awarded to an entity (`/api/entities/{uei}/contracts/`). + + Supports the same filter set as /api/contracts/ scoped to the recipient. + """ + if not uei: + raise TangoValidationError("UEI is required") + if shape is None: + shape = ShapeConfig.CONTRACTS_MINIMAL + return self._entity_subresource_contracts( + uei, + "contracts", + Contract, + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + def list_entity_idvs( + self, + uei: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List IDVs held by an entity (`/api/entities/{uei}/idvs/`).""" + if not uei: + raise TangoValidationError("UEI is required") + if shape is None: + shape = ShapeConfig.IDVS_MINIMAL + return self._entity_subresource_contracts( + uei, + "idvs", + IDV, + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + def list_entity_otas( + self, + uei: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List OTAs held by an entity (`/api/entities/{uei}/otas/`).""" + if not uei: + raise TangoValidationError("UEI is required") + if shape is None: + shape = ShapeConfig.OTAS_MINIMAL + return self._entity_subresource_contracts( + uei, + "otas", + OTA, + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + def list_entity_otidvs( + self, + uei: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List OTIDVs held by an entity (`/api/entities/{uei}/otidvs/`).""" + if not uei: + raise TangoValidationError("UEI is required") + if shape is None: + shape = ShapeConfig.OTIDVS_MINIMAL + return self._entity_subresource_contracts( + uei, + "otidvs", + OTIDV, + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + def list_entity_subawards( + self, + uei: str, + page: int = 1, + limit: int = 25, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + ordering: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List subawards for an entity (`/api/entities/{uei}/subawards/`).""" + if not uei: + raise TangoValidationError("UEI is required") + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + if shape is None: + shape = ShapeConfig.SUBAWARDS_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if flat_lists: + params["flat_lists"] = "true" + if ordering: + params["ordering"] = ordering + for k, v in filters.items(): + if v is not None: + params[k] = v + data = self._get(f"/api/entities/{uei}/subawards/", params) + results = [ + self._parse_response_with_shape(obj, shape, Subaward, flat, flat_lists) + for obj in (data.get("results") or []) + ] + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=results, + ) + + def list_entity_lcats( + self, + uei: str, + page: int = 1, + limit: int = 25, + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse[dict[str, Any]]: + """List GSA-eLibrary Labor Categories (LCATs) for an entity + (`/api/entities/{uei}/lcats/`). + """ + if not uei: + raise TangoValidationError("UEI is required") + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + if ordering: + params["ordering"] = ordering + if search is not None: + params["search"] = search + for k, v in filters.items(): + if v is not None: + params[k] = v + data = self._get(f"/api/entities/{uei}/lcats/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + def get_entity_metrics(self, uei: str, months: int, period_grouping: str) -> dict[str, Any]: + """Get rolling metrics for an entity + (`/api/entities/{uei}/metrics/{months}/{period_grouping}/`). + """ + if not uei: + raise TangoValidationError("UEI is required") + return self._get(f"/api/entities/{uei}/metrics/{months}/{period_grouping}/") + + # ============================================================================ + # IDV sub-resources + # ============================================================================ + + def list_idv_lcats( + self, + key: str, + page: int = 1, + limit: int = 25, + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse[dict[str, Any]]: + """List Labor Categories under an IDV (`/api/idvs/{key}/lcats/`).""" + if not key: + raise TangoValidationError("IDV key is required") + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + if ordering: + params["ordering"] = ordering + if search is not None: + params["search"] = search + for k, v in filters.items(): + if v is not None: + params[k] = v + data = self._get(f"/api/idvs/{key}/lcats/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + # ============================================================================ + # Agency sub-resources + # ============================================================================ + + def _agency_contracts( + self, + code: str, + which: str, + *, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + if not code: + raise TangoValidationError("Agency code is required") + if which not in ("awarding", "funding"): + raise TangoValidationError("Agency contracts which= must be 'awarding' or 'funding'") + params: dict[str, Any] = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + if shape is None: + shape = ShapeConfig.CONTRACTS_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + if ordering: + params["ordering"] = ordering + if search is not None: + params["search"] = search + for k, v in filters.items(): + if v is not None: + params[k] = v + data = self._get(f"/api/agencies/{code}/contracts/{which}/", params) + results = [ + self._parse_response_with_shape(obj, shape, Contract, flat, flat_lists, joiner=joiner) + for obj in (data.get("results") or []) + ] + return PaginatedResponse( + count=int(data.get("count") or len(results)), + next=data.get("next"), + previous=data.get("previous"), + results=results, + cursor=data.get("cursor"), + page_metadata=data.get("page_metadata"), + ) + + def list_agency_awarding_contracts( + self, + code: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List contracts where this agency is the awarding agency + (`/api/agencies/{code}/contracts/awarding/`). + """ + return self._agency_contracts( + code, + "awarding", + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + def list_agency_funding_contracts( + self, + code: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List contracts where this agency is the funding agency + (`/api/agencies/{code}/contracts/funding/`). + """ + return self._agency_contracts( + code, + "funding", + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + # ============================================================================ + # Opportunity attachment search & misc + # ============================================================================ + + def search_opportunity_attachments( + self, + q: str, + top_k: int | None = None, + include_extracted_text: bool | None = None, + ) -> dict[str, Any]: + """Semantic search over opportunity attachments + (`/api/opportunities/attachment-search/`). + """ + if not q: + raise TangoValidationError("search_opportunity_attachments(): q is required") + params: dict[str, Any] = {"q": q} + if top_k is not None: + params["top_k"] = top_k + if include_extracted_text is not None: + params["include_extracted_text"] = "true" if include_extracted_text else "false" + return self._get("/api/opportunities/attachment-search/", params) + + def get_version(self) -> dict[str, Any]: + """Get the Tango API version info (`/api/version/`).""" + return self._get("/api/version/") + + def list_api_keys(self) -> dict[str, Any]: + """List the authenticated user's API keys (`/api/api-keys/`).""" + return self._get("/api/api-keys/") diff --git a/tango/models.py b/tango/models.py index 95ca73e..54a2207 100644 --- a/tango/models.py +++ b/tango/models.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal -from typing import Any, Final, TypeVar +from typing import Any, Final, Literal, TypedDict, TypeVar T = TypeVar("T") @@ -345,13 +345,40 @@ class OTIDV: @dataclass class Subaward: - """Schema definition for Subaward (not used for instances)""" + """Schema definition for Subaward (not used for instances) + + Mirrors the server's ``SubawardSerializer``. Fields are: + + - ``key`` / ``award_key`` / ``piid`` — identifiers (the prime award PIID is + denormalized onto the subaward row). + - ``prime_awardee_*`` and ``recipient_*`` — denormalized lookup fields the + API exposes alongside ``prime_recipient`` / ``subaward_recipient`` + expansions for filter parity. + - ``usaspending_permalink`` — direct USAspending URL for the subaward. + + Expandable objects (request via ``shape="..."``): + ``awarding_office``, ``funding_office`` — AwardOffice payload + ``prime_recipient``, ``subaward_recipient`` — RecipientProfile + ``place_of_performance`` — city/state/zip/country_code + ``subaward_details`` — action_date/amount/fiscal_year/number/type/description + ``fsrs_details`` — FSRS submission provenance + ``highly_compensated_officers`` — list of {name, amount} + """ - id: str | None = None + key: str | None = None award_key: str | None = None - prime_uei: str | None = None - sub_uei: str | None = None - amount: Decimal | None = None + piid: str | None = None + usaspending_permalink: str | None = None + prime_awardee_name: str | None = None + prime_awardee_uei: str | None = None + recipient_business_types: list[str] | None = None + recipient_dba_name: str | None = None + recipient_duns: str | None = None + recipient_name: str | None = None + recipient_parent_duns: str | None = None + recipient_parent_name: str | None = None + recipient_parent_uei: str | None = None + recipient_uei: str | None = None @dataclass @@ -571,33 +598,37 @@ class APIKey: @dataclass class WebhookEventType: event_type: str - default_subject_type: str description: str schema_version: int -@dataclass -class WebhookSubjectTypeDefinition: - subject_type: str - description: str - id_format: str - status: str - - @dataclass class WebhookEventTypesResponse: event_types: list[WebhookEventType] - subject_types: list[str] - subject_type_definitions: list[WebhookSubjectTypeDefinition] -@dataclass -class WebhookSubscription: - id: str - subscription_name: str - payload: dict[str, Any] | None - created_at: str - endpoint: str | None = None +class WebhookSampleDelivery(TypedDict): + timestamp: str + events: list[dict[str, Any]] + + +class WebhookSamplePayloadSingleResponse(TypedDict): + event_type: str + sample_delivery: WebhookSampleDelivery + signature_header: str + note: str + + +class WebhookSamplePayloadAllResponse(TypedDict): + samples: dict[str, dict[str, Any]] + usage: str + signature_header: str + note: str + + +WebhookSamplePayloadResponse = ( + WebhookSamplePayloadSingleResponse | WebhookSamplePayloadAllResponse +) @dataclass @@ -623,6 +654,65 @@ class WebhookTestDeliveryResult: test_payload: dict[str, Any] | None = None +@dataclass +class WebhookAlert: + """Filter-based webhook subscription (alert), backed by + ``/api/webhooks/alerts/``. + + The canonical (and only) write surface for webhook subscriptions — + every delivery is filter-driven; subject-based subscriptions were + removed in v0.7.0. + + Field notes: + query_type: Always non-null on current data. Legacy subject-based rows + (which had null query_type) were purged in the tango#2275 migration + (``webhooks/migrations/0019_drop_subject_webhooks.py``). + filters: Always non-null on current data; ``filter_definition`` is a + non-nullable JSONField on the server model after migration 0019. + status: Exactly ``"active"`` or ``"paused"`` — the server serializer + maps ``is_active=True`` → ``"active"`` and ``False`` → ``"paused"``. + """ + + alert_id: str + name: str + query_type: str + filters: dict[str, Any] + frequency: str + cron_expression: str | None + status: Literal["active", "paused"] + created_at: str + last_checked_at: str | None = None + match_count: int = 0 + + +@dataclass +class ResolveCandidate: + """A single ranked candidate from /api/resolve/.""" + + identifier: str | None = None + display_name: str | None = None + match_tier: str | None = None + extra: dict[str, Any] | None = None + + +@dataclass +class ResolveResult: + """Result of POST /api/resolve/ — ranked entity/organization candidates.""" + + candidates: list[ResolveCandidate] + count: int + + +@dataclass +class ValidateResult: + """Result of POST /api/validate/ — identifier format validation.""" + + result: str # "valid" | "not_valid" | "low_confidence" + type: str + value: str + errors: list[str] | None = None + + @dataclass class PaginatedResponse[T]: """Paginated API response @@ -738,8 +828,7 @@ class ShapeConfig: # Default for list_vehicle_orders() VEHICLE_ORDERS_MINIMAL: Final = ( - "key,piid,award_date,obligated,total_contract_value,description," - "recipient(display_name,uei)" + "key,piid,award_date,obligated,total_contract_value,description,recipient(display_name,uei)" ) # Default for list_organizations() diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index 77067e9..d821f28 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -327,6 +327,11 @@ ), "major_program": FieldSchema(name="major_program", type=str, is_optional=True, is_list=False), "naics_code": FieldSchema(name="naics_code", type=int, is_optional=True, is_list=False), + # Expand form: shape=naics(code,description). Server PR #2259 also accepts + # naics_code(...) as an alias which the SDK parser normalizes to naics. + "naics": FieldSchema( + name="naics", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), "number_of_actions": FieldSchema( name="number_of_actions", type=int, is_optional=True, is_list=False ), @@ -355,6 +360,11 @@ name="price_evaluation_percent_difference", type=str, is_optional=True, is_list=False ), "psc_code": FieldSchema(name="psc_code", type=str, is_optional=True, is_list=False), + # Expand form: shape=psc(code,description). Server PR #2259 also accepts + # psc_code(...) as an alias which the SDK parser normalizes to psc. + "psc": FieldSchema( + name="psc", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), "purchase_card_as_payment_method": FieldSchema( name="purchase_card_as_payment_method", type=str, is_optional=True, is_list=False ), @@ -572,6 +582,11 @@ "id": FieldSchema(name="id", type=int, is_optional=False, is_list=False), "is_active": FieldSchema(name="is_active", type=bool, is_optional=True, is_list=False), "naics_code": FieldSchema(name="naics_code", type=str, is_optional=True, is_list=False), + # Expand form: shape=naics(code,description). Server PR #2259 also accepts + # naics_code(...) as an alias which the SDK parser normalizes to naics. + "naics": FieldSchema( + name="naics", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), "place_of_performance": FieldSchema( name="place_of_performance", type=str, is_optional=False, is_list=False ), @@ -611,6 +626,11 @@ ), "meta": FieldSchema(name="meta", type=dict, is_optional=False, is_list=False), "naics_code": FieldSchema(name="naics_code", type=int, is_optional=True, is_list=False), + # Expand form: shape=naics(code,description). Server PR #2259 also accepts + # naics_code(...) as an alias which the SDK parser normalizes to naics. + "naics": FieldSchema( + name="naics", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), "notice_history": FieldSchema( name="notice_history", type=dict, @@ -631,6 +651,11 @@ name="primary_contact", type=dict, is_optional=True, is_list=False, nested_model="Contact" ), "psc_code": FieldSchema(name="psc_code", type=str, is_optional=True, is_list=False), + # Expand form: shape=psc(code,description). Server PR #2259 also accepts + # psc_code(...) as an alias which the SDK parser normalizes to psc. + "psc": FieldSchema( + name="psc", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), "response_deadline": FieldSchema( name="response_deadline", type=datetime, is_optional=False, is_list=False ), @@ -654,10 +679,20 @@ name="last_updated", type=datetime, is_optional=False, is_list=False ), "naics_code": FieldSchema(name="naics_code", type=str, is_optional=True, is_list=False), + # Expand form: shape=naics(code,description). Server PR #2259 also accepts + # naics_code(...) as an alias which the SDK parser normalizes to naics. + "naics": FieldSchema( + name="naics", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), "notice_id": FieldSchema(name="notice_id", type=str, is_optional=False, is_list=False), "opportunity": FieldSchema(name="opportunity", type=dict, is_optional=False, is_list=False), "posted_date": FieldSchema(name="posted_date", type=datetime, is_optional=False, is_list=False), "psc_code": FieldSchema(name="psc_code", type=str, is_optional=False, is_list=False), + # Expand form: shape=psc(code,description). Server PR #2259 also accepts + # psc_code(...) as an alias which the SDK parser normalizes to psc. + "psc": FieldSchema( + name="psc", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), "response_deadline": FieldSchema( name="response_deadline", type=datetime, is_optional=False, is_list=False ), @@ -1120,7 +1155,17 @@ ), "opportunity_id": FieldSchema(name="opportunity_id", type=str, is_optional=True, is_list=False), "naics_code": FieldSchema(name="naics_code", type=int, is_optional=True, is_list=False), + # Expand form: shape=naics(code,description). Server PR #2259 also accepts + # naics_code(...) as an alias which the SDK parser normalizes to naics. + "naics": FieldSchema( + name="naics", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), "psc_code": FieldSchema(name="psc_code", type=str, is_optional=True, is_list=False), + # Expand form: shape=psc(code,description). Server PR #2259 also accepts + # psc_code(...) as an alias which the SDK parser normalizes to psc. + "psc": FieldSchema( + name="psc", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), "set_aside": FieldSchema(name="set_aside", type=str, is_optional=True, is_list=False), # Shaping expansions "awardees": FieldSchema( @@ -1205,10 +1250,125 @@ } # Subaward (prime/sub awards) -SUBAWARD_SCHEMA: dict[str, FieldSchema] = { +# +# Mirrors awards.serializers.subawards.SubawardSerializer on the server. The +# top-level field list is the canonical `Meta.fields` plus the denormalized +# lookup fields the API exposes for filter parity (prime_awardee_*, +# recipient_*, usaspending_permalink). Expandable objects are modeled with +# `nested_model` so callers can request, e.g. `awarding_office(office_code)`. + +# `subaward_details` payload (action_date, amount, fiscal_year, ...). +SUBAWARD_DETAILS_SCHEMA: dict[str, FieldSchema] = { + "action_date": FieldSchema(name="action_date", type=date, is_optional=True, is_list=False), + "amount": FieldSchema(name="amount", type=Decimal, is_optional=True, is_list=False), + "description": FieldSchema(name="description", type=str, is_optional=True, is_list=False), + "fiscal_year": FieldSchema(name="fiscal_year", type=int, is_optional=True, is_list=False), + "number": FieldSchema(name="number", type=str, is_optional=True, is_list=False), + "type": FieldSchema(name="type", type=str, is_optional=True, is_list=False), +} + +# `fsrs_details` payload — provenance for the underlying FSRS submission. +FSRS_DETAILS_SCHEMA: dict[str, FieldSchema] = { "id": FieldSchema(name="id", type=str, is_optional=True, is_list=False), - "award_key": FieldSchema(name="award_key", type=str, is_optional=True, is_list=False), + "last_modified_date": FieldSchema( + name="last_modified_date", type=date, is_optional=True, is_list=False + ), + "month": FieldSchema(name="month", type=int, is_optional=True, is_list=False), + "year": FieldSchema(name="year", type=int, is_optional=True, is_list=False), +} + +# Subaward-specific place_of_performance — flat 4-key payload (city/state/zip/ +# country_code), distinct from the richer PLACE_OF_PERFORMANCE_SCHEMA used by +# contracts/IDVs/vehicles. +SUBAWARD_PLACE_OF_PERFORMANCE_SCHEMA: dict[str, FieldSchema] = { + "city": FieldSchema(name="city", type=str, is_optional=True, is_list=False), + "country_code": FieldSchema(name="country_code", type=str, is_optional=True, is_list=False), + "state": FieldSchema(name="state", type=str, is_optional=True, is_list=False), + "zip": FieldSchema(name="zip", type=str, is_optional=True, is_list=False), +} + +# `highly_compensated_officers` element shape (list-of-dict expansion). +HIGHLY_COMPENSATED_OFFICER_SCHEMA: dict[str, FieldSchema] = { "amount": FieldSchema(name="amount", type=Decimal, is_optional=True, is_list=False), + "name": FieldSchema(name="name", type=str, is_optional=True, is_list=False), +} + +SUBAWARD_SCHEMA: dict[str, FieldSchema] = { + # Core identifiers + "key": FieldSchema(name="key", type=str, is_optional=True, is_list=False), + "award_key": FieldSchema(name="award_key", type=str, is_optional=True, is_list=False), + "piid": FieldSchema(name="piid", type=str, is_optional=True, is_list=False), + "usaspending_permalink": FieldSchema( + name="usaspending_permalink", type=str, is_optional=True, is_list=False + ), + # Denormalized prime-awardee lookup fields (mirrored from prime_awardee_uei) + "prime_awardee_name": FieldSchema( + name="prime_awardee_name", type=str, is_optional=True, is_list=False + ), + "prime_awardee_uei": FieldSchema( + name="prime_awardee_uei", type=str, is_optional=True, is_list=False + ), + # Denormalized subaward-recipient lookup fields (mirrored from recipient_uei) + "recipient_business_types": FieldSchema( + name="recipient_business_types", type=str, is_optional=True, is_list=True + ), + "recipient_dba_name": FieldSchema( + name="recipient_dba_name", type=str, is_optional=True, is_list=False + ), + "recipient_duns": FieldSchema( + name="recipient_duns", type=str, is_optional=True, is_list=False + ), + "recipient_name": FieldSchema( + name="recipient_name", type=str, is_optional=True, is_list=False + ), + "recipient_parent_duns": FieldSchema( + name="recipient_parent_duns", type=str, is_optional=True, is_list=False + ), + "recipient_parent_name": FieldSchema( + name="recipient_parent_name", type=str, is_optional=True, is_list=False + ), + "recipient_parent_uei": FieldSchema( + name="recipient_parent_uei", type=str, is_optional=True, is_list=False + ), + "recipient_uei": FieldSchema( + name="recipient_uei", type=str, is_optional=True, is_list=False + ), + # Expandable nested objects + "awarding_office": FieldSchema( + name="awarding_office", + type=dict, + is_optional=True, + is_list=False, + nested_model="AwardOffice", + ), + "funding_office": FieldSchema( + name="funding_office", + type=dict, + is_optional=True, + is_list=False, + nested_model="AwardOffice", + ), + "fsrs_details": FieldSchema( + name="fsrs_details", + type=dict, + is_optional=True, + is_list=False, + nested_model="FsrsDetails", + ), + "highly_compensated_officers": FieldSchema( + name="highly_compensated_officers", + type=list, + is_optional=True, + is_list=True, + nested_model="HighlyCompensatedOfficer", + ), + "place_of_performance": FieldSchema( + name="place_of_performance", + type=dict, + is_optional=True, + is_list=False, + nested_model="SubawardPlaceOfPerformance", + ), "prime_recipient": FieldSchema( name="prime_recipient", type=dict, @@ -1216,6 +1376,13 @@ is_list=False, nested_model="RecipientProfile", ), + "subaward_details": FieldSchema( + name="subaward_details", + type=dict, + is_optional=True, + is_list=False, + nested_model="SubawardDetails", + ), "subaward_recipient": FieldSchema( name="subaward_recipient", type=dict, @@ -1358,6 +1525,10 @@ "OTA": OTA_SCHEMA, "OTIDV": OTIDV_SCHEMA, "Subaward": SUBAWARD_SCHEMA, + "SubawardDetails": SUBAWARD_DETAILS_SCHEMA, + "FsrsDetails": FSRS_DETAILS_SCHEMA, + "SubawardPlaceOfPerformance": SUBAWARD_PLACE_OF_PERFORMANCE_SCHEMA, + "HighlyCompensatedOfficer": HIGHLY_COMPENSATED_OFFICER_SCHEMA, # GSA eLibrary "GsaElibraryContract": GSA_ELIBRARY_CONTRACT_SCHEMA, "GsaElibraryIdvRef": GSA_ELIBRARY_IDV_REF_SCHEMA, diff --git a/tango/shapes/parser.py b/tango/shapes/parser.py index bb73917..5fd7187 100644 --- a/tango/shapes/parser.py +++ b/tango/shapes/parser.py @@ -26,6 +26,62 @@ from tango.shapes.models import FieldSpec, ShapeSpec from tango.shapes.schema import SchemaRegistry +# Global expand-name aliases. Mirrors the server's `_EXPAND_ALIASES` in +# `api/shaping/grammar.py` (Tango PR #2259, issue #2266). Keys are user-typed +# alias names; values are the canonical names returned by the API and used by +# the schema registry. The rewrite only fires when the alias is used as an +# *expansion* (has nested fields or a wildcard) — bare scalars like +# `shape=naics_code` are left alone and continue to return the raw column. +# +# The canonical name (``naics`` / ``psc``) becomes the output key on the +# response regardless of which spelling the caller used. Keep this list short: +# aliases are for well-known historical spellings, not naming inconsistencies. +_EXPAND_ALIASES: dict[str, str] = { + "naics_code": "naics", + "psc_code": "psc", +} + + +def _normalize_expand_aliases(fields: list[FieldSpec]) -> None: + """Rewrite expand-form alias names to their canonical form, in place. + + Walks ``fields`` recursively. A field is treated as an "expansion" (and + therefore eligible for alias rewriting) when it has ``nested_fields`` or + ``is_wildcard`` set. Bare scalar leaves are left untouched so callers can + still request the raw column value via ``shape=naics_code``. + + If both the alias and its canonical name appear as expansions at the same + level (e.g. ``shape=naics(code),naics_code(description)``), the canonical + wins and the alias entry is dropped silently — this matches the server's + behavior and avoids emitting two output keys for the same data. + + Args: + fields: List of FieldSpec objects to normalize (mutated in place). + """ + # First pass: collect names of expand-form fields at this level so we can + # detect canonical/alias collisions before rewriting. + expand_names = {f.name for f in fields if f.nested_fields or f.is_wildcard} + + # Second pass: rewrite or drop aliases, then recurse into nested fields. + rewritten: list[FieldSpec] = [] + for field in fields: + is_expand = bool(field.nested_fields) or field.is_wildcard + canonical = _EXPAND_ALIASES.get(field.name) if is_expand else None + + if canonical is not None: + if canonical in expand_names and canonical != field.name: + # Canonical already requested at this level — drop the alias. + continue + field.name = canonical + + if field.nested_fields: + _normalize_expand_aliases(field.nested_fields) + + rewritten.append(field) + + # Replace the contents of the input list (caller holds the reference). + fields[:] = rewritten + def _suggest_field_correction(invalid_field: str, valid_fields: list[str]) -> str | None: """Suggest a correction for an invalid field name @@ -148,6 +204,10 @@ def parse(self, shape: str) -> ShapeSpec: # Parse the shape try: fields = self._parse_field_list(shape, 0)[0] + # Rewrite expand-form aliases (e.g. naics_code(...) -> naics(...)) + # to their canonical names. Mirrors server's `_EXPAND_ALIASES` so + # both spellings are accepted client-side. See issue #2266. + _normalize_expand_aliases(fields) shape_spec = ShapeSpec(fields=fields) # Cache the result diff --git a/tango/webhooks/cli.py b/tango/webhooks/cli.py index 6c20d1c..04243e0 100644 --- a/tango/webhooks/cli.py +++ b/tango/webhooks/cli.py @@ -199,7 +199,7 @@ def simulate_cmd( client = TangoClient(api_key=api_key, base_url=base_url) payload = client.get_webhook_sample_payload(event_type=event_type) else: - payload = {"events": [{"event_type": "tango.cli.simulated", "subject_ids": []}]} + payload = {"events": [{"event_type": "tango.cli.simulated"}]} if target_url is None: signed = simulate.sign(payload, secret) @@ -217,13 +217,17 @@ def simulate_cmd( return result = simulate.deliver(target_url=target_url, payload=payload, secret=secret) + # `result.signature` is the bare hex on the SimulationResult dataclass; + # render the prefixed wire form (matches X-Tango-Signature exactly). + from tango.webhooks.signing import SIGNATURE_PREFIX + click.echo( json.dumps( { "delivered": True, "target_url": target_url, "status_code": result.status_code, - "signature": f"sha256={result.signature}", + "signature": f"{SIGNATURE_PREFIX}{result.signature}", "sent_payload": payload, "receiver_response": result.response_body[:500], }, @@ -288,7 +292,7 @@ def list_event_types_cmd(api_key: str | None, base_url: str) -> None: # --------------------------------------------------------------------------- -# Endpoint and subscription management +# Endpoint management # --------------------------------------------------------------------------- # # These commands wrap the SDK's CRUD methods. Common --api-key / --base-url @@ -361,16 +365,23 @@ def endpoints_get_cmd(endpoint_id: str, api_key: str | None, base_url: str) -> N @endpoints_group.command("create") @click.option("--url", "callback_url", required=True, help="Receiver URL Tango will POST to.") +@click.option( + "--name", + required=True, + help="Human-readable name for this endpoint. Must be unique per account (server enforces unique(user, name)).", +) @click.option("--inactive", is_flag=True, default=False, help="Create the endpoint disabled.") @_common_api_options def endpoints_create_cmd( - callback_url: str, inactive: bool, api_key: str | None, base_url: str + callback_url: str, name: str, inactive: bool, api_key: str | None, base_url: str ) -> None: """Create a webhook endpoint. Output includes the generated secret — save it.""" from dataclasses import asdict client = _tango_client(api_key, base_url) - endpoint = client.create_webhook_endpoint(callback_url=callback_url, is_active=not inactive) + endpoint = client.create_webhook_endpoint( + callback_url=callback_url, name=name, is_active=not inactive + ) click.echo(json.dumps(asdict(endpoint), indent=2, sort_keys=True)) @@ -387,109 +398,6 @@ def endpoints_delete_cmd(endpoint_id: str, yes: bool, api_key: str | None, base_ click.echo(json.dumps({"deleted": endpoint_id})) -@webhooks.group("subscriptions") -def subscriptions_group() -> None: - """Manage webhook subscriptions (what Tango delivers).""" - - -@subscriptions_group.command("list") -@click.option("--page", type=int, default=1, show_default=True) -@click.option("--page-size", type=int, default=None) -@_common_api_options -def subscriptions_list_cmd( - page: int, page_size: int | None, api_key: str | None, base_url: str -) -> None: - """List webhook subscriptions configured for your account.""" - from dataclasses import asdict - - client = _tango_client(api_key, base_url) - resp = client.list_webhook_subscriptions(page=page, page_size=page_size) - click.echo( - json.dumps( - { - "count": resp.count, - "results": [asdict(s) for s in resp.results], - }, - indent=2, - sort_keys=True, - ) - ) - - -@subscriptions_group.command("get") -@click.argument("subscription_id") -@_common_api_options -def subscriptions_get_cmd(subscription_id: str, api_key: str | None, base_url: str) -> None: - """Show one subscription by id.""" - from dataclasses import asdict - - client = _tango_client(api_key, base_url) - sub = client.get_webhook_subscription(subscription_id) - click.echo(json.dumps(asdict(sub), indent=2, sort_keys=True)) - - -@subscriptions_group.command("create") -@click.option("--name", "subscription_name", required=True, help="Human-readable name.") -@click.option("--event-type", required=True, help="Event type to subscribe to.") -@click.option( - "--subject-type", - required=True, - help="Subject type (e.g. 'entity', 'opportunity'). See `list-event-types`.", -) -@click.option( - "--subject-id", - "subject_ids", - multiple=True, - required=True, - help="One or more subject ids. Repeat the flag for multiple.", -) -@_common_api_options -def subscriptions_create_cmd( - subscription_name: str, - event_type: str, - subject_type: str, - subject_ids: tuple[str, ...], - api_key: str | None, - base_url: str, -) -> None: - """Create a webhook subscription with a single records[] entry. - - For multi-record subscriptions, use the SDK's - `create_webhook_subscription` directly with a custom payload. - """ - from dataclasses import asdict - - client = _tango_client(api_key, base_url) - sub = client.create_webhook_subscription( - subscription_name=subscription_name, - payload={ - "records": [ - { - "event_type": event_type, - "subject_type": subject_type, - "subject_ids": list(subject_ids), - } - ] - }, - ) - click.echo(json.dumps(asdict(sub), indent=2, sort_keys=True)) - - -@subscriptions_group.command("delete") -@click.argument("subscription_id") -@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.") -@_common_api_options -def subscriptions_delete_cmd( - subscription_id: str, yes: bool, api_key: str | None, base_url: str -) -> None: - """Delete a webhook subscription.""" - if not yes: - click.confirm(f"Delete subscription {subscription_id}?", abort=True) - client = _tango_client(api_key, base_url) - client.delete_webhook_subscription(subscription_id) - click.echo(json.dumps({"deleted": subscription_id})) - - def _print_delivery(delivery: Delivery) -> None: """Default ``listen`` callback: write a one-line summary plus body.""" summary = _summarize(delivery.body_json) diff --git a/tango/webhooks/receiver.py b/tango/webhooks/receiver.py index 8679252..10e2a8e 100644 --- a/tango/webhooks/receiver.py +++ b/tango/webhooks/receiver.py @@ -113,11 +113,18 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 return def do_POST(self) -> None: # noqa: N802 (stdlib API) + # Always drain the request body before responding, even on + # error paths. Windows surfaces an undrained body as + # `WinError 10053` (connection aborted by the host) when + # the server closes the socket mid-request; Linux/macOS + # absorb it silently. Reading first keeps the response + # cycle clean across platforms. + length = int(self.headers.get("Content-Length", "0") or 0) + body = self.rfile.read(length) if length > 0 else b"" + if self.path != receiver.path: self._write_json(404, {"ok": False, "error": "not_found"}) return - length = int(self.headers.get("Content-Length", "0") or 0) - body = self.rfile.read(length) if length > 0 else b"" signature = self.headers.get(SIGNATURE_HEADER) verified = bool(receiver.secret) and verify_signature( body, receiver.secret, signature diff --git a/tango/webhooks/signing.py b/tango/webhooks/signing.py index 37f9338..b2f5901 100644 --- a/tango/webhooks/signing.py +++ b/tango/webhooks/signing.py @@ -19,8 +19,15 @@ def generate_signature(body: bytes, secret: str) -> str: - """Return the lowercase hex HMAC-SHA256 of ``body`` keyed by ``secret``.""" - return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + """Return the wire-format signature for ``body`` keyed by ``secret``. + + Output is the full ``sha256=`` form Tango emits in the + ``X-Tango-Signature`` header, so the return value can be assigned to + the header directly without a wrapping format string. To get the bare + hex digest, strip the prefix with :func:`parse_signature_header`. + """ + digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + return f"{SIGNATURE_PREFIX}{digest}" def parse_signature_header(value: str | None) -> str | None: @@ -39,12 +46,18 @@ def parse_signature_header(value: str | None) -> str | None: def verify_signature(body: bytes, secret: str, signature_header: str | None) -> bool: """Return True if ``signature_header`` matches the HMAC of ``body``. - Uses :func:`hmac.compare_digest` for constant-time comparison. - Returns False for an absent or malformed header rather than raising — let - callers decide how to respond (typically a 401 / 403). + Accepts both the prefixed form (``sha256=``) and the bare-hex form + in ``signature_header`` — callers passing in pre-stripped headers keep + working. Uses :func:`hmac.compare_digest` for constant-time comparison. + Returns False for an absent or malformed header rather than raising — + let callers decide how to respond (typically a 401 / 403). """ received = parse_signature_header(signature_header) if not received: return False - expected = generate_signature(body, secret) + # generate_signature now returns the prefixed wire form; strip it for + # the bare-hex comparison. + expected = parse_signature_header(generate_signature(body, secret)) + if not expected: + return False return hmac.compare_digest(expected, received) diff --git a/tango/webhooks/simulate.py b/tango/webhooks/simulate.py index 064a78e..5702fd4 100644 --- a/tango/webhooks/simulate.py +++ b/tango/webhooks/simulate.py @@ -2,7 +2,7 @@ This module is the offline counterpart to ``test_webhook_delivery``: it never talks to the Tango API. Use it when you want to drive a downstream -receiver without provisioning a real subscription, or when you want to +receiver without provisioning a real alert, or when you want to fuzz event shapes that Tango wouldn't naturally emit. Example:: @@ -23,7 +23,7 @@ from dataclasses import dataclass from typing import Any -from tango.webhooks.signing import SIGNATURE_HEADER, SIGNATURE_PREFIX, generate_signature +from tango.webhooks.signing import SIGNATURE_HEADER, generate_signature, parse_signature_header @dataclass(frozen=True) @@ -31,7 +31,7 @@ class SignedRequest: """A Tango-shaped signed request, ready to be POSTed.""" body: bytes - signature: str # bare lowercase hex + signature: str # bare lowercase hex (header-prefix stripped) headers: dict[str, str] # includes Content-Type and X-Tango-Signature @@ -52,13 +52,14 @@ def sign(payload: dict[str, Any] | list[Any] | bytes | str, secret: str) -> Sign receive, or for hand-rolling deliveries with a custom HTTP client. """ body = _to_bytes(payload) - signature_hex = generate_signature(body, secret) + header_value = generate_signature(body, secret) + bare_hex = parse_signature_header(header_value) or "" return SignedRequest( body=body, - signature=signature_hex, + signature=bare_hex, headers={ "Content-Type": "application/json", - SIGNATURE_HEADER: f"{SIGNATURE_PREFIX}{signature_hex}", + SIGNATURE_HEADER: header_value, }, ) diff --git a/tests/production/test_production_smoke.py b/tests/production/test_production_smoke.py index 83ccce4..fe75dae 100644 --- a/tests/production/test_production_smoke.py +++ b/tests/production/test_production_smoke.py @@ -684,10 +684,6 @@ def test_list_webhook_event_types(self, production_client): assert hasattr(response, "event_types"), "Response should have event_types" assert isinstance(response.event_types, list), "event_types should be a list" - # Response should have subject_types list - assert hasattr(response, "subject_types"), "Response should have subject_types" - assert isinstance(response.subject_types, list), "subject_types should be a list" - @handle_rate_limit @handle_auth_error def test_list_webhook_endpoints(self, production_client): @@ -704,14 +700,14 @@ def test_list_webhook_endpoints(self, production_client): @handle_rate_limit @handle_auth_error - def test_list_webhook_subscriptions(self, production_client): - """Test webhook subscriptions listing + def test_list_webhook_alerts(self, production_client): + """Test webhook alerts listing Validates: - - Webhook subscriptions listing works + - Webhook alerts listing works (canonical filter-subscription path) - Response parsing is correct """ - response = production_client.list_webhook_subscriptions() + response = production_client.list_webhook_alerts() validate_pagination(response) assert response.count >= 0, "Count should be non-negative" diff --git a/tests/test_api_parity.py b/tests/test_api_parity.py new file mode 100644 index 0000000..1987eb3 --- /dev/null +++ b/tests/test_api_parity.py @@ -0,0 +1,474 @@ +"""Unit tests for API-parity additions (feat/api-parity branch). + +Mock-driven tests — no network. Verifies that the new methods build the +right HTTP request and parse the response into the expected shapes. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from tango import ( + ResolveResult, + TangoClient, + ValidateResult, + WebhookAlert, +) +from tango.exceptions import TangoValidationError + + +def _mock_response(payload: dict[str, Any], status: int = 200) -> Mock: + resp = Mock() + resp.is_success = 200 <= status < 400 + resp.status_code = status + resp.json.return_value = payload + resp.content = b'{"x": 1}' + resp.headers = {} + return resp + + +@patch("tango.client.httpx.Client.request") +class TestPostJsonKwargAlias: + """`_post` should accept either `json_data` (positional) or `json` (keyword).""" + + def test_positional_json_data(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"ok": True}) + client = TangoClient(api_key="x", base_url="https://t.example") + client._post("/api/foo/", {"a": 1}) + assert mock_request.call_args[1]["json"] == {"a": 1} + + def test_keyword_json_alias(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"ok": True}) + client = TangoClient(api_key="x", base_url="https://t.example") + client._post("/api/foo/", json={"a": 2}) + assert mock_request.call_args[1]["json"] == {"a": 2} + + def test_keyword_json_data_alias(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"ok": True}) + client = TangoClient(api_key="x", base_url="https://t.example") + client._post("/api/foo/", json_data={"a": 3}) + assert mock_request.call_args[1]["json"] == {"a": 3} + + def test_patch_json_kwarg(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"ok": True}) + client = TangoClient(api_key="x", base_url="https://t.example") + client._patch("/api/foo/1/", json={"b": 4}) + assert mock_request.call_args[1]["json"] == {"b": 4} + + +@patch("tango.client.httpx.Client.request") +class TestResolveValidate: + def test_resolve_builds_request(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "candidates": [ + { + "identifier": "ABC123", + "display_name": "Acme Corp", + "match_tier": "high", + "score": 0.95, + }, + {"identifier": "DEF456", "display_name": "Acme LLC"}, + ], + "count": 2, + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.resolve("Acme", target_type="entity", state="CA", city="LA", context="cyber") + + # Request shape + call = mock_request.call_args + assert call[1]["method"] == "POST" + assert call[1]["url"].endswith("/api/resolve/") + body = call[1]["json"] + assert body["name"] == "Acme" + assert body["target_type"] == "entity" + assert body["state"] == "CA" + assert body["city"] == "LA" + assert body["context"] == "cyber" + + # Response parsing + assert isinstance(out, ResolveResult) + assert out.count == 2 + assert out.candidates[0].identifier == "ABC123" + assert out.candidates[0].match_tier == "high" + # extra fields preserved + assert out.candidates[0].extra == {"score": 0.95} + assert out.candidates[1].match_tier is None + + def test_resolve_validates_target_type(self, mock_request: Mock) -> None: + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError): + client.resolve("Acme", target_type="bogus") + + def test_resolve_requires_name(self, mock_request: Mock) -> None: + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError): + client.resolve("", target_type="entity") + + def test_validate_builds_request(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"result": "valid", "type": "uei", "value": "ABC123"} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.validate("uei", "ABC123") + + call = mock_request.call_args + assert call[1]["method"] == "POST" + assert call[1]["url"].endswith("/api/validate/") + # Maps `identifier_type` -> `type` in the body + assert call[1]["json"] == {"type": "uei", "value": "ABC123"} + + assert isinstance(out, ValidateResult) + assert out.result == "valid" + assert out.type == "uei" + assert out.value == "ABC123" + assert out.errors is None + + def test_validate_rejects_unknown_type(self, mock_request: Mock) -> None: + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError): + client.validate("naics", "541511") + + +@patch("tango.client.httpx.Client.request") +class TestWebhookAlerts: + def test_create_webhook_alert(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "alert_id": "alert-1", + "name": "my-alert", + "query_type": "opportunity", + "filters": {"naics": "541511"}, + "frequency": "daily", + "cron_expression": None, + "status": "active", + "created_at": "2026-05-11T00:00:00Z", + "last_checked_at": None, + "match_count": 0, + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.create_webhook_alert( + name="my-alert", + query_type="opportunity", + filters={"naics": "541511"}, + frequency="daily", + ) + + call = mock_request.call_args + assert call[1]["method"] == "POST" + assert call[1]["url"].endswith("/api/webhooks/alerts/") + assert call[1]["json"] == { + "name": "my-alert", + "query_type": "opportunity", + "filters": {"naics": "541511"}, + "frequency": "daily", + } + + assert isinstance(out, WebhookAlert) + assert out.alert_id == "alert-1" + assert out.filters == {"naics": "541511"} + + def test_create_webhook_alert_validates_inputs(self, mock_request: Mock) -> None: + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError): + client.create_webhook_alert(name="", query_type="entity", filters={"x": 1}) + with pytest.raises(TangoValidationError): + client.create_webhook_alert(name="n", query_type="", filters={"x": 1}) + with pytest.raises(TangoValidationError): + client.create_webhook_alert(name="n", query_type="entity", filters={}) + + def test_update_webhook_alert(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "alert_id": "alert-1", + "name": "renamed", + "query_type": "opportunity", + "filters": {"naics": "541511"}, + "frequency": "weekly", + "cron_expression": None, + "status": "active", + "created_at": "2026-05-11T00:00:00Z", + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.update_webhook_alert("alert-1", name="renamed", frequency="weekly") + + call = mock_request.call_args + assert call[1]["method"] == "PATCH" + assert call[1]["json"] == {"name": "renamed", "frequency": "weekly"} + assert out.name == "renamed" + assert out.frequency == "weekly" + + def test_list_webhook_alerts(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "alert_id": "a1", + "name": "x", + "query_type": "entity", + "filters": {"uei": "U"}, + "frequency": "realtime", + "cron_expression": None, + "status": "active", + "created_at": "2026-01-01T00:00:00Z", + "match_count": 5, + } + ], + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.list_webhook_alerts() + assert out.count == 1 + assert out.results[0].alert_id == "a1" + assert out.results[0].match_count == 5 + + +@patch("tango.client.httpx.Client.request") +class TestWebhookEndpointWriteFixes: + def test_create_endpoint_passes_name(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "id": "ep-1", + "name": "primary", + "callback_url": "https://x/", + "secret": "s", + "is_active": True, + "created_at": "2026-01-01", + "updated_at": "2026-01-01", + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.create_webhook_endpoint("https://x/", name="primary") + + body = mock_request.call_args[1]["json"] + assert body["name"] == "primary" + assert body["callback_url"] == "https://x/" + + def test_create_endpoint_without_name_raises(self, mock_request: Mock) -> None: + # 1.0.0 turned the 0.7.0 DeprecationWarning into a hard error: the + # server enforces unique(user, name), so omitting name would 400 + # anyway. Raising client-side gives a better error message and + # avoids the wasted round-trip. + mock_request.return_value = _mock_response({}) + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError, match="name"): + client.create_webhook_endpoint("https://x/") + # And the request never went out. + mock_request.assert_not_called() + + def test_update_endpoint_passes_name(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "id": "ep-1", + "name": "renamed", + "callback_url": "https://x/", + "is_active": True, + "created_at": "2026-01-01", + "updated_at": "2026-01-01", + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.update_webhook_endpoint("ep-1", name="renamed") + body = mock_request.call_args[1]["json"] + assert body == {"name": "renamed"} + + +@patch("tango.client.httpx.Client.request") +class TestWebhookAlertEndpointKwarg: + def test_create_alert_passes_endpoint(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "alert_id": "alert-1", + "name": "ep-pinned", + "query_type": "opportunity", + "filters": {"naics": "541511"}, + "frequency": "realtime", + "cron_expression": None, + "status": "active", + "created_at": "2026-01-01", + "last_checked_at": None, + "match_count": 0, + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.create_webhook_alert( + name="ep-pinned", + query_type="opportunity", + filters={"naics": "541511"}, + endpoint="ep-1", + ) + body = mock_request.call_args[1]["json"] + assert body["endpoint"] == "ep-1" + assert body["name"] == "ep-pinned" + + +@patch("tango.client.httpx.Client.request") +class TestOrderingParam: + """Verify ordering kwarg lands in query params on the five list_* methods + that the server actually accepts ordering on (notices/protests rejected + every value at runtime, so no kwarg is exposed for them).""" + + @pytest.mark.parametrize( + "method,path,extra_kwargs", + [ + ("list_forecasts", "/api/forecasts/", {}), + ("list_grants", "/api/grants/", {}), + ("list_subawards", "/api/subawards/", {}), + ("list_gsa_elibrary_contracts", "/api/gsa_elibrary_contracts/", {}), + ("list_opportunities", "/api/opportunities/", {}), + ], + ) + def test_ordering_threads_through( + self, + mock_request: Mock, + method: str, + path: str, + extra_kwargs: dict[str, Any], + ) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + fn = getattr(client, method) + fn(ordering="-foo", **extra_kwargs) + + call = mock_request.call_args + params = call[1]["params"] + assert params["ordering"] == "-foo", ( + f"{method}: expected ordering='-foo' in query, got {params}" + ) + assert call[1]["url"].endswith(path) + + +@patch("tango.client.httpx.Client.request") +class TestReferenceData: + def test_list_departments(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 1, "next": None, "previous": None, "results": [{"code": "97"}]} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.list_departments(limit=2) + assert out.results == [{"code": "97"}] + assert mock_request.call_args[1]["url"].endswith("/api/departments/") + + def test_get_department(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"code": "97", "name": "DoD"}) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.get_department("97") + assert out["code"] == "97" + assert mock_request.call_args[1]["url"].endswith("/api/departments/97/") + + def test_get_psc_metrics(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"metrics": []}) + client = TangoClient(api_key="x", base_url="https://t.example") + client.get_psc_metrics("R425", 12, "month") + assert mock_request.call_args[1]["url"].endswith("/api/psc/R425/metrics/12/month/") + + def test_get_naics(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"code": "541511"}) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.get_naics("541511") + assert out["code"] == "541511" + + def test_list_mas_sins_with_search(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_mas_sins(search="cyber") + assert mock_request.call_args[1]["params"]["search"] == "cyber" + + +@patch("tango.client.httpx.Client.request") +class TestEntitySubResources: + def test_list_entity_contracts(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_entity_contracts("UEI123", limit=10, ordering="-award_date", naics="541511") + call = mock_request.call_args + assert call[1]["url"].endswith("/api/entities/UEI123/contracts/") + params = call[1]["params"] + assert params["ordering"] == "-award_date" + assert params["naics"] == "541511" + assert params["limit"] == 10 + + def test_get_entity_metrics(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"obligations": []}) + client = TangoClient(api_key="x", base_url="https://t.example") + client.get_entity_metrics("UEI1", 24, "quarter") + assert mock_request.call_args[1]["url"].endswith("/api/entities/UEI1/metrics/24/quarter/") + + def test_list_entity_lcats(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_entity_lcats("UEI1", search="engineer") + assert mock_request.call_args[1]["params"]["search"] == "engineer" + + def test_uei_required(self, mock_request: Mock) -> None: + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError): + client.list_entity_contracts("") + + +@patch("tango.client.httpx.Client.request") +class TestAgencyContracts: + def test_list_agency_awarding_contracts(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_agency_awarding_contracts("4732", limit=5) + assert mock_request.call_args[1]["url"].endswith("/api/agencies/4732/contracts/awarding/") + + def test_list_agency_funding_contracts(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_agency_funding_contracts("4732", limit=5) + assert mock_request.call_args[1]["url"].endswith("/api/agencies/4732/contracts/funding/") + + +@patch("tango.client.httpx.Client.request") +class TestMiscMethods: + def test_get_version(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"version": "4.5.0"}) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.get_version() + assert out["version"] == "4.5.0" + + def test_list_api_keys(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"keys": []}) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_api_keys() + assert mock_request.call_args[1]["url"].endswith("/api/api-keys/") + + def test_search_opportunity_attachments(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"matches": []}) + client = TangoClient(api_key="x", base_url="https://t.example") + client.search_opportunity_attachments(q="cyber", top_k=5, include_extracted_text=True) + params = mock_request.call_args[1]["params"] + assert params["q"] == "cyber" + assert params["top_k"] == 5 + assert params["include_extracted_text"] == "true" + + def test_list_idv_lcats(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_idv_lcats("IDV-1") + assert mock_request.call_args[1]["url"].endswith("/api/idvs/IDV-1/lcats/") diff --git a/tests/test_client.py b/tests/test_client.py index df948cf..084451b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -537,21 +537,11 @@ def test_list_webhook_event_types(self, mock_request): mock_response.json.return_value = { "event_types": [ { - "event_type": "awards.new_award", - "default_subject_type": "entity", + "event_type": "alerts.contract.match", "description": "", "schema_version": 1, } ], - "subject_types": ["entity"], - "subject_type_definitions": [ - { - "subject_type": "entity", - "description": "Entity UEI", - "id_format": "UEI", - "status": "active", - } - ], } mock_response.content = b'{"event_types": []}' mock_request.return_value = mock_response @@ -559,104 +549,12 @@ def test_list_webhook_event_types(self, mock_request): client = TangoClient(api_key="test-key") resp = client.list_webhook_event_types() - assert resp.event_types[0].event_type == "awards.new_award" - assert resp.subject_types == ["entity"] - assert resp.subject_type_definitions[0].subject_type == "entity" + assert resp.event_types[0].event_type == "alerts.contract.match" call_args = mock_request.call_args assert call_args[1]["method"] == "GET" assert call_args[1]["url"].endswith("/api/webhooks/event-types/") - @patch("tango.client.httpx.Client.request") - def test_webhook_subscriptions_crud(self, mock_request): - client = TangoClient(api_key="test-key", base_url="https://example.test") - - # list - list_response = Mock() - list_response.is_success = True - list_response.status_code = 200 - list_response.json.return_value = { - "count": 1, - "next": None, - "previous": None, - "results": [ - { - "id": "sub-1", - "endpoint": "endpoint-1", - "subscription_name": "My sub", - "payload": {"records": []}, - "created_at": "2026-01-01T00:00:00Z", - } - ], - } - list_response.content = b'{"count": 1}' - - # create - create_response = Mock() - create_response.is_success = True - create_response.status_code = 201 - create_response.json.return_value = { - "id": "sub-1", - "endpoint": "endpoint-1", - "subscription_name": "My sub", - "payload": {"records": []}, - "created_at": "2026-01-01T00:00:00Z", - } - create_response.content = b'{"id": "sub-1"}' - - # update - update_response = Mock() - update_response.is_success = True - update_response.status_code = 200 - update_response.json.return_value = { - "id": "sub-1", - "endpoint": "endpoint-1", - "subscription_name": "Updated", - "payload": {"records": []}, - "created_at": "2026-01-01T00:00:00Z", - } - update_response.content = b'{"id": "sub-1"}' - - # delete (204, empty content) - delete_response = Mock() - delete_response.is_success = True - delete_response.status_code = 204 - delete_response.content = b"" - - mock_request.side_effect = [ - list_response, - create_response, - update_response, - delete_response, - ] - - subs = client.list_webhook_subscriptions(page=2, page_size=25) - assert subs.count == 1 - assert subs.results[0].subscription_name == "My sub" - - created = client.create_webhook_subscription("My sub", {"records": []}) - assert created.id == "sub-1" - - updated = client.update_webhook_subscription("sub-1", subscription_name="Updated") - assert updated.subscription_name == "Updated" - - client.delete_webhook_subscription("sub-1") - - # Ensure correct request params/bodies were used - calls = mock_request.call_args_list - assert calls[0][1]["method"] == "GET" - assert calls[0][1]["params"]["page"] == 2 - assert calls[0][1]["params"]["page_size"] == 25 - - assert calls[1][1]["method"] == "POST" - assert calls[1][1]["json"]["subscription_name"] == "My sub" - assert calls[1][1]["json"]["payload"] == {"records": []} - - assert calls[2][1]["method"] == "PATCH" - assert calls[2][1]["json"]["subscription_name"] == "Updated" - - assert calls[3][1]["method"] == "DELETE" - @patch("tango.client.httpx.Client.request") def test_webhook_test_delivery_and_sample_payload(self, mock_request): client = TangoClient(api_key="test-key", base_url="https://example.test") @@ -768,7 +666,7 @@ def test_webhook_endpoints_crud(self, mock_request): assert endpoints.results[0].name == "yoni" created = client.create_webhook_endpoint( - "https://example.com/tango/webhooks", is_active=True + "https://example.com/tango/webhooks", is_active=True, name="primary" ) assert created.secret == "secret" @@ -785,6 +683,7 @@ def test_webhook_endpoints_crud(self, mock_request): assert calls[1][1]["method"] == "POST" assert calls[1][1]["json"]["callback_url"] == "https://example.com/tango/webhooks" assert calls[1][1]["json"]["is_active"] is True + assert calls[1][1]["json"]["name"] == "primary" assert calls[2][1]["method"] == "PATCH" assert calls[2][1]["json"]["is_active"] is False diff --git a/tests/test_shapes.py b/tests/test_shapes.py index 61cd869..8faa42f 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -465,6 +465,142 @@ def test_validate_wildcard_always_valid(self): parser.validate(spec, MockModel) # Should not raise +class TestShapeParserExpandAliases: + """Test naics_code/psc_code -> naics/psc expand-alias normalization. + + Mirrors the server's `_EXPAND_ALIASES` map (Tango PR #2259, issue #2266): + when used as an expansion (with parens / wildcard), `naics_code(...)` is + rewritten to `naics(...)` and `psc_code(...)` to `psc(...)`. Bare scalar + leaves are left alone so `shape=naics_code` still returns the raw column. + """ + + def test_canonical_naics_expand_accepted_on_contract(self): + """Canonical `naics(code,description)` validates against Contract.""" + parser = ShapeParser() + spec = parser.parse("naics(code,description)") + + assert spec.fields[0].name == "naics" + assert spec.fields[0].nested_fields is not None + assert [f.name for f in spec.fields[0].nested_fields] == ["code", "description"] + + parser.validate(spec, Contract) # Should not raise + + def test_alias_naics_code_expand_rewritten_to_naics(self): + """Alias form `naics_code(code,description)` is rewritten to `naics`.""" + parser = ShapeParser() + spec = parser.parse("naics_code(code,description)") + + # Name is rewritten at parse time so downstream type generation and + # factory parsing see the canonical key the server returns. + assert spec.fields[0].name == "naics" + assert [f.name for f in spec.fields[0].nested_fields] == ["code", "description"] + + parser.validate(spec, Contract) # Should not raise + + def test_canonical_psc_expand_accepted_on_contract(self): + """Canonical `psc(code,description)` validates against Contract.""" + parser = ShapeParser() + spec = parser.parse("psc(code,description)") + + assert spec.fields[0].name == "psc" + parser.validate(spec, Contract) # Should not raise + + def test_alias_psc_code_expand_rewritten_to_psc(self): + """Alias form `psc_code(code,description)` is rewritten to `psc`.""" + parser = ShapeParser() + spec = parser.parse("psc_code(code,description)") + + assert spec.fields[0].name == "psc" + parser.validate(spec, Contract) # Should not raise + + def test_bare_naics_code_scalar_is_not_rewritten(self): + """Bare scalar `naics_code` keeps its name (returns raw column).""" + parser = ShapeParser() + spec = parser.parse("naics_code") + + # Scalar form is NOT touched — the alias only fires for expansions. + assert spec.fields[0].name == "naics_code" + assert spec.fields[0].nested_fields is None + assert spec.fields[0].is_wildcard is False + + parser.validate(spec, Contract) # naics_code is a real scalar field + + def test_bare_psc_code_scalar_is_not_rewritten(self): + """Bare scalar `psc_code` keeps its name (returns raw column).""" + parser = ShapeParser() + spec = parser.parse("psc_code") + + assert spec.fields[0].name == "psc_code" + assert spec.fields[0].nested_fields is None + + parser.validate(spec, Contract) + + def test_alias_naics_code_wildcard_expand_rewritten(self): + """Wildcard expansion `naics_code(*)` is rewritten to `naics(*)`.""" + parser = ShapeParser() + spec = parser.parse("naics_code(*)") + + assert spec.fields[0].name == "naics" + assert spec.fields[0].is_wildcard is True + + parser.validate(spec, Contract) # Should not raise + + def test_alias_collision_drops_alias_keeps_canonical(self): + """When both `naics(...)` and `naics_code(...)` appear, canonical wins. + + Matches server behavior — emitting two output keys for the same data + would surprise callers, so the alias entry is dropped silently. + """ + parser = ShapeParser() + spec = parser.parse("naics(code),naics_code(description)") + + # Only one entry should remain — the canonical `naics` one. + names = [f.name for f in spec.fields] + assert names == ["naics"] + assert [f.name for f in spec.fields[0].nested_fields] == ["code"] + + def test_scalar_and_expand_alias_coexist(self): + """Scalar `naics_code` and expand `naics_code(...)` both survive. + + The expand gets rewritten to `naics`; the scalar stays as + `naics_code`. They're now distinct keys with distinct meanings — + the scalar returns the raw int/str, the expand returns the dict. + """ + parser = ShapeParser() + spec = parser.parse("key,naics_code,naics_code(code,description)") + + names = [f.name for f in spec.fields] + assert names == ["key", "naics_code", "naics"] + assert spec.fields[1].nested_fields is None # scalar + assert spec.fields[2].nested_fields is not None # expand + + def test_alias_rewrite_applies_in_nested_expansions(self): + """Aliases nested inside another expansion are also rewritten. + + The parent expansion field is unrelated; we just want to confirm the + normalization walks recursively through ``nested_fields``. + """ + parser = ShapeParser() + # `recipient` is a valid expansion on Contract; nest a naics_code + # alias inside to confirm the walk recurses. + spec = parser.parse("recipient(uei,display_name),naics_code(code)") + + assert [f.name for f in spec.fields] == ["recipient", "naics"] + assert spec.fields[1].nested_fields[0].name == "code" + + def test_alias_accepted_on_opportunity(self): + """Server accepts the alias on opportunities too — schema covers it.""" + # Use a model class that exists; Contract is already covered above. + # Smoke-test that the validator finds `naics` on a couple of schemas + # that previously only had `naics_code`. + from tango.models import Opportunity + + parser = ShapeParser() + spec = parser.parse("naics_code(code,description)") + assert spec.fields[0].name == "naics" + parser.validate(spec, Opportunity) # Should not raise + + class TestShapeParserCaching: """Test shape parser caching""" diff --git a/tests/test_webhooks_cli.py b/tests/test_webhooks_cli.py index bacd84f..14a751f 100644 --- a/tests/test_webhooks_cli.py +++ b/tests/test_webhooks_cli.py @@ -73,7 +73,7 @@ def test_cli_simulate_with_payload_file(tmp_path: object) -> None: import pathlib p = pathlib.Path(str(tmp_path)) / "payload.json" - payload = {"events": [{"event_type": "from.file", "subject_ids": ["S1"]}]} + payload = {"events": [{"event_type": "from.file"}]} p.write_text(json.dumps(payload), encoding="utf-8") runner = CliRunner() @@ -130,20 +130,16 @@ def test_cli_list_event_types_prints_table() -> None: api_response = { "event_types": [ { - "event_type": "entities.updated", - "default_subject_type": "entity", - "description": "Entity updated", + "event_type": "alerts.entity.match", + "description": "Entity match", "schema_version": 1, }, { - "event_type": "awards.created", - "default_subject_type": "award", - "description": "New award", + "event_type": "alerts.contract.match", + "description": "Contract match", "schema_version": 1, }, ], - "subject_types": [], - "subject_type_definitions": [], } mock_response = Mock() mock_response.status_code = 200 @@ -155,9 +151,9 @@ def test_cli_list_event_types_prints_table() -> None: with patch("tango.client.httpx.Client.request", return_value=mock_response): result = runner.invoke(main, ["webhooks", "list-event-types", "--api-key", "k"]) assert result.exit_code == 0, result.output - assert "entities.updated" in result.output - assert "Entity updated" in result.output - assert "awards.created" in result.output + assert "alerts.entity.match" in result.output + assert "Entity match" in result.output + assert "alerts.contract.match" in result.output def _mock_response(api_response: dict[str, object]) -> object: @@ -221,6 +217,8 @@ def test_cli_endpoints_create_returns_secret() -> None: "create", "--url", "https://example/wh", + "--name", + "default", "--api-key", "k", ], @@ -253,73 +251,6 @@ def test_cli_endpoints_delete_requires_confirmation() -> None: assert json.loads(result.output) == {"deleted": "ep-1"} -def test_cli_subscriptions_create_builds_records_payload() -> None: - """Verify the `--event-type / --subject-type / --subject-id` flags get folded - into the right `payload.records[0]` shape Tango expects.""" - from unittest.mock import patch - - api = { - "id": "sub-1", - "endpoint": "ep-1", - "subscription_name": "ent-watch", - "payload": { - "records": [ - { - "event_type": "entities.updated", - "subject_type": "entity", - "subject_ids": ["UEI1", "UEI2"], - } - ] - }, - "created_at": "2026-05-07T00:00:00Z", - } - runner = CliRunner() - with patch( - "tango.client.httpx.Client.request", return_value=_mock_response(api) - ) as mock_request: - result = runner.invoke( - main, - [ - "webhooks", - "subscriptions", - "create", - "--name", - "ent-watch", - "--event-type", - "entities.updated", - "--subject-type", - "entity", - "--subject-id", - "UEI1", - "--subject-id", - "UEI2", - "--api-key", - "k", - ], - ) - assert result.exit_code == 0, result.output - # The SDK was called with the constructed payload. - sent_json = mock_request.call_args.kwargs["json"] - assert sent_json["subscription_name"] == "ent-watch" - assert sent_json["payload"]["records"][0]["subject_ids"] == ["UEI1", "UEI2"] - - -def test_cli_subscriptions_list() -> None: - from unittest.mock import patch - - api = { - "count": 0, - "next": None, - "previous": None, - "results": [], - } - runner = CliRunner() - with patch("tango.client.httpx.Client.request", return_value=_mock_response(api)): - result = runner.invoke(main, ["webhooks", "subscriptions", "list", "--api-key", "k"]) - assert result.exit_code == 0, result.output - assert json.loads(result.output) == {"count": 0, "results": []} - - def test_cli_simulate_rejects_both_modes(tmp_path: object) -> None: import pathlib diff --git a/tests/test_webhooks_receiver.py b/tests/test_webhooks_receiver.py index 63c389c..f5659b2 100644 --- a/tests/test_webhooks_receiver.py +++ b/tests/test_webhooks_receiver.py @@ -15,13 +15,14 @@ def _post_signed(url: str, body: bytes, secret: str) -> httpx.Response: - sig = generate_signature(body, secret) + # generate_signature returns the wire form ("sha256=") so it can + # be assigned to the header directly with no wrapping. return httpx.post( url, content=body, headers={ "Content-Type": "application/json", - "X-Tango-Signature": f"sha256={sig}", + "X-Tango-Signature": generate_signature(body, secret), }, timeout=5.0, ) diff --git a/tests/test_webhooks_signing.py b/tests/test_webhooks_signing.py index a136324..b47844b 100644 --- a/tests/test_webhooks_signing.py +++ b/tests/test_webhooks_signing.py @@ -15,14 +15,15 @@ from tango.webhooks import generate_signature, parse_signature_header, verify_signature KNOWN_VECTORS: list[tuple[bytes, str, str]] = [ - # (body_bytes, secret, expected_lowercase_hex_hmac_sha256) - (b"", "dev_secret", hmac.new(b"dev_secret", b"", hashlib.sha256).hexdigest()), + # (body_bytes, secret, expected_wire_signature) — full sha256= form + (b"", "dev_secret", "sha256=" + hmac.new(b"dev_secret", b"", hashlib.sha256).hexdigest()), ( - b'{"events":[{"event_type":"entities.updated","uei":"ABC123"}]}', + b'{"events":[{"event_type":"alerts.entity.match","alert_id":"ABC"}]}', "shh", - hmac.new( + "sha256=" + + hmac.new( b"shh", - b'{"events":[{"event_type":"entities.updated","uei":"ABC123"}]}', + b'{"events":[{"event_type":"alerts.entity.match","alert_id":"ABC"}]}', hashlib.sha256, ).hexdigest(), ), @@ -34,29 +35,50 @@ def test_generate_signature_matches_reference_algorithm() -> None: assert generate_signature(body, secret) == expected -def test_generate_signature_is_lowercase_hex() -> None: +def test_generate_signature_returns_prefixed_wire_form() -> None: + """generate_signature returns the full ``sha256=`` header value, so + callers can assign it directly to X-Tango-Signature without wrapping.""" sig = generate_signature(b"payload", "secret") - assert sig == sig.lower() - int(sig, 16) # must parse as hex + assert sig.startswith("sha256=") + bare = sig[len("sha256=") :] + assert bare == bare.lower() + int(bare, 16) # must parse as hex def test_verify_signature_round_trip() -> None: - body = b'{"events":[{"event_type":"awards.created"}]}' + body = b'{"events":[{"event_type":"alerts.contract.match"}]}' secret = "rotating-secret" sig = generate_signature(body, secret) - assert verify_signature(body, secret, f"sha256={sig}") is True - assert verify_signature(body, secret, sig) is True # bare hex also accepted + # Prefixed form (what generate_signature returns and what Tango sends) + assert verify_signature(body, secret, sig) is True + # Bare-hex form (callers passing pre-stripped headers) + bare = parse_signature_header(sig) + assert bare is not None + assert verify_signature(body, secret, bare) is True + + +def test_verify_signature_accepts_both_prefixed_and_bare_hex() -> None: + """Regression test: verify_signature must accept BOTH the wire form + (sha256=) and the pre-stripped bare-hex form. Callers that strip + the prefix themselves before passing in must keep working.""" + body = b"hello" + secret = "k" + sig = generate_signature(body, secret) + bare = parse_signature_header(sig) + assert bare is not None and bare != sig # sanity: they really differ + assert verify_signature(body, secret, sig) is True + assert verify_signature(body, secret, bare) is True def test_verify_signature_rejects_tampered_body() -> None: secret = "secret" sig = generate_signature(b"original", secret) - assert verify_signature(b"tampered", secret, f"sha256={sig}") is False + assert verify_signature(b"tampered", secret, sig) is False def test_verify_signature_rejects_wrong_secret() -> None: sig = generate_signature(b"body", "right") - assert verify_signature(b"body", "wrong", f"sha256={sig}") is False + assert verify_signature(b"body", "wrong", sig) is False def test_verify_signature_handles_missing_or_empty_header() -> None: diff --git a/uv.lock b/uv.lock index a9a235b..2a00102 100644 --- a/uv.lock +++ b/uv.lock @@ -1843,7 +1843,7 @@ wheels = [ [[package]] name = "tango-python" -version = "0.6.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "httpx" },