Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,45 @@ 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]
## [0.6.0] - 2026-05-07

### Added
- Vehicles: new top-level fields `program_acronym`, `idv_count`, `total_obligated`, `is_synthetic_solicitation`, `latest_award_date`, `description`, `opportunity_id`.
- Vehicles: new `metrics(*)` shape expansion bundling 12 computed metrics: `avg_offers_received`, `award_concentration_hhi`, `order_concentration_hhi`, `competed_rate`, `using_agency_count`, `avg_order_value`, `max_order_value`, `top_recipient_share`, `recent_obligations_24mo`, `recent_orders_24mo`, `days_since_last_order`, `obligation_to_ceiling_ratio`. Backed by a new `VehicleMetrics` schema.
- `list_vehicle_orders(uuid, ...)` for the new `/api/vehicles/{uuid}/orders/` endpoint, returning task orders under the vehicle's IDVs with two-phase pagination.
- `list_vehicles` gained 21 explicit filter parameters per API 4.3.0: `vehicle_type`, `type_of_idc`, `contract_type`, `set_aside` (multi-value via `|`), `who_can_use`, `naics_code`, `psc_code`, `program_acronym`, `agency`, `organization_id`, `total_obligated_min`/`max`, `idv_count_min`/`max`, `order_count_min`/`max`, `fiscal_year`, `award_date_after`/`before`, `last_date_to_order_after`/`before`.
- `list_vehicle_awardees` gained a `search` parameter for entity-aware full-text search across IDV fields and recipient entity details (API 4.3.0).
- `ordering` parameter on `list_vehicles` (whitelist: `vehicle_obligations`, `latest_award_date`, `total_obligated`, `award_date`, `last_date_to_order`, `fiscal_year`, `idv_count`, `order_count`) and on `list_vehicle_orders` (whitelist: `award_date`, `obligated`, `total_contract_value`). Prefix with `-` for descending.
- `ShapeConfig.VEHICLE_ORDERS_MINIMAL` default for the new orders endpoint.
- Shaping: New `organization(*)` expand on `Vehicle`, `Forecast`, `Grant`, `ITDashboardInvestment`, and `Protest` schemas — returns the canonical 7-key office payload (`organization_id`, `office_code`, `office_name`, `agency_code`, `agency_name`, `department_code`, `department_name`). Selectable as the bare leaf (`shape=...,organization`) or as a sub-selectable expansion (`shape=...,organization(office_code,...)`).
- Shaping: New `vehicle(*)` expand on `Contract` — request the parent vehicle inline from `/api/contracts/` (API 4.2.0).
- `Vehicle` and `VehicleMetrics` are now exported from the top-level `tango` package.
- `tango.webhooks` subpackage with HMAC-SHA256 signing helpers (`verify_signature`, `generate_signature`, `parse_signature_header`) that mirror the canonical Tango server scheme byte-for-byte. Importable from a default `pip install tango-python` (pure stdlib).
- `WebhookReceiver`: a stdlib-based local HTTP listener for development and integration tests. Verifies signatures, optionally forwards each delivery to a downstream URL, and records deliveries in memory for inspection. Usable as a context manager (`with WebhookReceiver(secret=...).run() as rx: ...`).
- `tango.webhooks.simulate.deliver(...)`: locally sign and POST a payload to any URL — no Tango involvement. Useful for offline iteration on receiver code.
- New `tango[webhooks]` extra (adds `click`) ships a `tango` console script covering the full webhook lifecycle for developer integrations:
- `listen` — local receiver
- `simulate` — sign a payload locally; with `--to`, also POST it
- `trigger` — ask Tango to send a real test delivery
- `fetch-sample` — print the canonical payload Tango emits for an event type
- `list-event-types` — discover what's subscribable
- `endpoints list|get|create|delete` — manage delivery endpoints
- `subscriptions list|get|create|delete` — manage what events you receive
Together these let a developer go from zero to receiving real Tango webhooks without leaving the shell or dropping into Python.

### Changed
- `ShapeConfig.VEHICLES_MINIMAL` and `VEHICLES_COMPREHENSIVE` now include the new top-level fields and the `organization` expansion. `VEHICLES_COMPREHENSIVE` defaults to `metrics(*)` and no longer pulls the deprecated `competition_details(*)` blob.

### Deprecated
- Vehicles shape fields `agency_details`, `competition_details`, and the `opportunity` expansion. The upstream API now sends a `Deprecation: true` header for these and recomputes them at request time. Explicit use in `shape=...` emits a Python `DeprecationWarning`. Sunset timeline TBD upstream.

### Notes
- Console script name `tango` may be revisited in a future release if it conflicts with sibling tooling (`tango-scripts` reuses the bare name).

### Documentation
- New `docs/WEBHOOKS.md` — comprehensive guide covering install, concepts, a zero-to-receiving quickstart, full CLI reference, and programmatic patterns for `WebhookReceiver` / `simulate.sign` / `simulate.deliver` in pytest fixtures.
- `docs/API_REFERENCE.md`: filled in `get_webhook_subscription`, replaced the hand-rolled signature-verification snippet with a pointer to `tango.webhooks.verify_signature`, and added a new "Webhook tooling (`tango.webhooks`)" section that documents every importable from the new subpackage.
- `README.md`: new "Webhook Tooling" section under Advanced Features, plus the new guide is linked from the Documentation index.

## [0.5.0] - 2026-04-08

Expand Down
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,14 @@ otidvs = client.list_otidvs(limit=25)
### Vehicles

```python
vehicles = client.list_vehicles(search="GSA schedule", shape=ShapeConfig.VEHICLES_MINIMAL)
vehicles = client.list_vehicles(
search="GSA schedule",
ordering="-vehicle_obligations",
shape=ShapeConfig.VEHICLES_MINIMAL,
)
vehicle = client.get_vehicle("UUID", shape=ShapeConfig.VEHICLES_COMPREHENSIVE)
awardees = client.list_vehicle_awardees("UUID")
orders = client.list_vehicle_orders("UUID", ordering="-obligated")
```

### Entities (Vendors/Recipients)
Expand Down Expand Up @@ -327,6 +332,46 @@ contracts = client.list_contracts(
# Returns: {"key": "...", "transactions.0.action_date": "...", "transactions.0.obligated": "..."}
```

### Webhook Tooling

The SDK ships first-class tooling for **building and testing webhook integrations against the Tango API** — including signing helpers, a local receiver, and a command-line tool covering the full lifecycle:

```bash
pip install 'tango-python[webhooks]'
```

This adds a `tango` console script with subcommands for the full webhook lifecycle:

```bash
# Discover what's available
tango webhooks list-event-types
tango webhooks fetch-sample --event-type entities.updated

# Local development
tango webhooks listen --port 8011 --secret $SECRET # receiver
tango webhooks simulate --secret $SECRET --event-type entities.updated # sign + print
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

# Force a real test delivery from Tango
tango webhooks trigger
```

The signing helpers (`verify_signature`, `generate_signature`) are pure stdlib and importable from the default install — your receiver code doesn't need the extra:

```python
from tango.webhooks import verify_signature

if not verify_signature(raw_body, secret, request.headers.get("X-Tango-Signature")):
return 401, "invalid signature"
```

For the full guide — workflow, CLI reference, and programmatic patterns for pytest fixtures — see [`docs/WEBHOOKS.md`](docs/WEBHOOKS.md).

### Type Hints with IDE Support

Import TypedDict types for IDE autocomplete:
Expand Down Expand Up @@ -476,6 +521,7 @@ tango-python/
- [Shape System Guide](docs/SHAPES.md) - Comprehensive guide to response shaping
- [API Reference](docs/API_REFERENCE.md) - Detailed API documentation
- [Developer Guide](docs/DEVELOPERS.md) - Technical documentation for developers
- [Webhooks Guide](docs/WEBHOOKS.md) - Workflow, CLI reference, and programmatic patterns for webhook integrations
- [Quick Start Notebook](docs/quick_start.ipynb) - Interactive Jupyter notebook with examples

## Requirements
Expand Down
161 changes: 150 additions & 11 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,13 +503,14 @@ Vehicles provide a solicitation-centric way to discover groups of related IDVs a

### list_vehicles()

List vehicles with optional vehicle-level full-text search.
List vehicles with optional vehicle-level full-text search and ordering.

```python
vehicles = client.list_vehicles(
page=1,
limit=25,
search="GSA schedule",
ordering="-vehicle_obligations",
shape=ShapeConfig.VEHICLES_MINIMAL,
flat=False,
flat_lists=False,
Expand All @@ -520,6 +521,7 @@ vehicles = client.list_vehicles(
- `page` (int): Page number (default: 1)
- `limit` (int): Results per page (default: 25, max: 100)
- `search` (str, optional): Vehicle-level search term
- `ordering` (str, optional): Server-side sort. Allowed: `vehicle_obligations`, `latest_award_date`. Prefix with `-` for descending.
- `shape` (str, optional): Shape string (defaults to `ShapeConfig.VEHICLES_MINIMAL`)
- `flat` (bool): Flatten nested objects in shaped response
- `flat_lists` (bool): Flatten arrays using indexed keys
Expand Down Expand Up @@ -552,6 +554,67 @@ awardees = client.list_vehicle_awardees(
)
```

### list_vehicle_orders()

List task orders under a vehicle's IDVs (`/api/vehicles/{uuid}/orders/`). Optimized for fast pagination over large vehicles.

```python
orders = client.list_vehicle_orders(
uuid="00000000-0000-0000-0000-000000000001",
limit=25,
ordering="-obligated",
shape=ShapeConfig.VEHICLE_ORDERS_MINIMAL,
)
```

**Parameters:**
- `uuid` (str): Vehicle UUID
- `page` (int): Page number (default: 1)
- `limit` (int): Results per page (default: 25, max: 100)
- `ordering` (str, optional): Server-side sort. Allowed: `award_date` (default), `obligated`, `total_contract_value`. Prefix with `-` for descending.
- `shape` (str, optional): Shape string (defaults to `ShapeConfig.VEHICLE_ORDERS_MINIMAL`)
- `flat`, `flat_lists`, `joiner`: as on other vehicles methods

**Returns:** [PaginatedResponse](#paginatedresponse) with order (Contract) dictionaries

### Vehicle response fields

The post-cutover (May 2026) vehicle response includes these top-level fields, all addressable via the `shape` parameter:

| Field | Type | Notes |
| ----- | ---- | ----- |
| `uuid` | str | Stable identifier. |
| `solicitation_identifier` | str | Solicitation shared by underlying IDVs. |
| `is_synthetic_solicitation` | bool | `True` for GWAC orphans recovered via `ACRO:` prefix. |
| `agency_id` | str | From IDV award-key suffix. |
| `program_acronym` | str \| None | New post-cutover field. |
| `organization_id` | str \| None | Awarding organization. |
| `organization` | dict \| None | Live awarding-org snapshot `{organization_id, office_code, office_name, agency_code, agency_name, department_code, department_name}`. Selected as a leaf field (`shape=...,organization`); not currently sub-selectable. |
| `vehicle_type`, `who_can_use`, `type_of_idc`, `contract_type` | dict \| None | Returned as `{code, description}`. |
| `description` | str \| None | Common text across IDV descriptions. |
| `descriptions` | list[str] \| None | Distinct IDV descriptions. |
| `idv_count`, `awardee_count`, `order_count` | int \| None | Denormalized rollups. |
| `total_obligated`, `vehicle_obligations`, `vehicle_contracts_value` | Decimal \| None | Denormalized rollups. |
| `award_date`, `latest_award_date`, `last_date_to_order` | date \| None | |
| `solicitation_title`, `solicitation_description`, `solicitation_date`, `opportunity_id` | str / date / None | From SAM.gov via the linked Opportunity. |
| `naics_code`, `psc_code`, `set_aside`, `fiscal_year` | int / str / None | |

### Vehicle shape expansions

- `awardees(...)` — underlying IDV awards. Supports nested `orders(...)`.
- `metrics(*)` — bundled computed metrics: `avg_offers_received`, `award_concentration_hhi`, `order_concentration_hhi`, `competed_rate`, `using_agency_count`, `avg_order_value`, `max_order_value`, `top_recipient_share`, `recent_obligations_24mo`, `recent_orders_24mo`, `days_since_last_order`, `obligation_to_ceiling_ratio`. Defaults included in `ShapeConfig.VEHICLES_COMPREHENSIVE`.
- `organization` — live awarding-org snapshot (selected as a leaf field; not sub-selectable).

### Deprecated shape fields

The following fields and expansions are still served by the API (recomputed at request time from the underlying IDVs) but the API now returns a `Deprecation: true` response header for them. They will be removed in a future tango API release.

- `agency_details` (top-level field and `agency_details(*)` expansion)
- `competition_details` (top-level field and `competition_details(*)` expansion)
- `opportunity(*)` expansion (use the new top-level `solicitation_*` and `opportunity_id` fields instead)

If you pass any of these in `shape=...`, the SDK will emit a Python `DeprecationWarning`. The default shapes (`VEHICLES_MINIMAL`, `VEHICLES_COMPREHENSIVE`) no longer include them.

---

## IDVs
Expand Down Expand Up @@ -1227,6 +1290,8 @@ for code in naics.results:

Webhook APIs let **Large / Enterprise** users manage subscription filters for outbound Tango webhooks.

> **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 and subject types.
Expand All @@ -1246,6 +1311,12 @@ Notes:

- This endpoint uses `page` + `page_size` (tier-capped) rather than `limit`.

### get_webhook_subscription()

```python
sub = client.get_webhook_subscription("SUBSCRIPTION_UUID")
```

### create_webhook_subscription()

```python
Expand Down Expand Up @@ -1335,21 +1406,89 @@ Every delivery includes an HMAC signature header:

Compute the digest over the **raw request body bytes** using your shared secret.

The SDK ships a stdlib-only verifier that mirrors the Tango server's signing scheme byte-for-byte. Use it instead of hand-rolling — it's importable from a default install (no extras needed):

```python
import hashlib
import hmac
from tango.webhooks import verify_signature

if not verify_signature(raw_body, secret, request.headers.get("X-Tango-Signature")):
return 401
```

`verify_signature` returns `False` for missing/empty/malformed headers — it never raises. Comparison is constant-time.

---

## Webhook tooling (`tango.webhooks`)

The `tango.webhooks` subpackage adds testing and developer-tooling primitives on top of the API methods above. Signing helpers ship with the default install; the receiver and CLI ship with `pip install 'tango-python[webhooks]'`. See [`docs/WEBHOOKS.md`](WEBHOOKS.md) for usage guides; this section is the import-level reference.

### Signing (default install)

def verify_tango_webhook_signature(secret: str, raw_body: bytes, signature_header: str | None) -> bool:
if not signature_header:
return False
sig = signature_header.strip()
if sig.startswith("sha256="):
sig = sig[len("sha256=") :]
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
```python
from tango.webhooks import (
verify_signature, # (body: bytes, secret: str, header: str | None) -> bool
generate_signature, # (body: bytes, secret: str) -> str (lowercase hex)
parse_signature_header, # (header: str | None) -> str | None (strips "sha256=")
SIGNATURE_HEADER, # "X-Tango-Signature"
SIGNATURE_PREFIX, # "sha256="
)
```

### `WebhookReceiver` (with `[webhooks]` extra)

A stdlib-based local HTTP receiver, useful in tests and during local development.

```python
from tango.webhooks import WebhookReceiver, Delivery

with WebhookReceiver(secret="dev").run() as rx:
# ... cause something to POST to rx.url ...
deliveries: list[Delivery] = rx.deliveries
```

Constructor (all keyword arguments):

| Arg | Default | Meaning |
|---|---|---|
| `secret` | `""` | Shared secret. Empty means signatures are not verified. |
| `path` | `/tango/webhooks` | URL path to accept POSTs on. |
| `host` | `127.0.0.1` | Bind address. |
| `port` | `0` | TCP port. `0` = OS picks a free port. |
| `forward_to` | `None` | Optional URL to mirror each delivery to. |
| `max_history` | `256` | Cap on the in-memory `deliveries` deque. |
| `on_delivery` | `None` | Callback fired for every delivery (verified or not). |
| `require_signature` | `None` | Override default (require iff `secret` is set). |

Each `Delivery` is a dataclass: `received_at`, `path`, `signature_header`, `body_bytes`, `body_json`, `verified`, `remote_addr`, `forward_status`, `forward_error`.

### `simulate.sign` and `simulate.deliver`

```python
from tango.webhooks import sign, SignedRequest
from tango.webhooks import simulate

# Offline — produce the signed wire form without POSTing:
signed: SignedRequest = sign({"events": [{"event_type": "..."}]}, secret="s")
signed.body # bytes you would put on the wire
signed.signature # bare lowercase hex
signed.headers # {"Content-Type": ..., "X-Tango-Signature": "sha256=..."}

# With delivery — sign and POST to a target URL:
result = simulate.deliver(target_url="http://localhost:8011/tango/webhooks",
payload={...}, secret="s")
result.status_code # status from the receiver
result.signature # bare hex
result.sent_bytes # exact bytes that were POSTed
result.response_body # body the receiver returned
```

`simulate.deliver` and `simulate.sign` accept payloads as `dict`, `list`, `str`, or raw `bytes`. Dicts/lists are serialized via `json.dumps(..., sort_keys=True, separators=(",", ":"))` so signatures are reproducible across runs.

### CLI entry point

The `tango[webhooks]` extra also installs a `tango` console script. See [`docs/WEBHOOKS.md` § CLI reference](WEBHOOKS.md#cli-reference) for the full command list.

---

## Response Objects
Expand Down
4 changes: 3 additions & 1 deletion docs/SHAPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ idvs = client.list_idvs(shape=ShapeConfig.IDVS_MINIMAL)
grants = client.list_grants(shape=ShapeConfig.GRANTS_MINIMAL)
```

**Available constants:** Contracts (`CONTRACTS_MINIMAL`), Entities (`ENTITIES_MINIMAL`, `ENTITIES_COMPREHENSIVE`), Forecasts, Opportunities, Notices, Grants, IDVs, Vehicles, Organizations, OTAs, OTIDVs, Subawards. See [API Reference – ShapeConfig](API_REFERENCE.md#shapeconfig-predefined-shapes) for the full table and which method uses which constant.
**Available constants:** Contracts (`CONTRACTS_MINIMAL`), Entities (`ENTITIES_MINIMAL`, `ENTITIES_COMPREHENSIVE`), Forecasts, Opportunities, Notices, Grants, IDVs, Vehicles (`VEHICLES_MINIMAL`, `VEHICLES_COMPREHENSIVE`, `VEHICLE_AWARDEES_MINIMAL`, `VEHICLE_ORDERS_MINIMAL`), Organizations, OTAs, OTIDVs, Subawards. See [API Reference – ShapeConfig](API_REFERENCE.md#shapeconfig-predefined-shapes) for the full table and which method uses which constant.

> **Vehicles `metrics(*)` expansion:** The vehicles surface bundles 12 computed metrics under a single `metrics(*)` expansion (e.g. `award_concentration_hhi`, `competed_rate`, `top_recipient_share`). It is included in `VEHICLES_COMPREHENSIVE` by default. The `agency_details`, `competition_details`, and `opportunity` shape entries are deprecated and emit `DeprecationWarning` if requested explicitly.

## Basic Shaping

Expand Down
Loading
Loading