Skip to content

Commit f9f4309

Browse files
v0.6.0 (#24)
1 parent 35f8d77 commit f9f4309

30 files changed

Lines changed: 3366 additions & 179 deletions

CHANGELOG.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,45 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased]
8+
## [0.6.0] - 2026-05-07
9+
10+
### Added
11+
- Vehicles: new top-level fields `program_acronym`, `idv_count`, `total_obligated`, `is_synthetic_solicitation`, `latest_award_date`, `description`, `opportunity_id`.
12+
- 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.
13+
- `list_vehicle_orders(uuid, ...)` for the new `/api/vehicles/{uuid}/orders/` endpoint, returning task orders under the vehicle's IDVs with two-phase pagination.
14+
- `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`.
15+
- `list_vehicle_awardees` gained a `search` parameter for entity-aware full-text search across IDV fields and recipient entity details (API 4.3.0).
16+
- `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.
17+
- `ShapeConfig.VEHICLE_ORDERS_MINIMAL` default for the new orders endpoint.
18+
- 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,...)`).
19+
- Shaping: New `vehicle(*)` expand on `Contract` — request the parent vehicle inline from `/api/contracts/` (API 4.2.0).
20+
- `Vehicle` and `VehicleMetrics` are now exported from the top-level `tango` package.
21+
- `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).
22+
- `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: ...`).
23+
- `tango.webhooks.simulate.deliver(...)`: locally sign and POST a payload to any URL — no Tango involvement. Useful for offline iteration on receiver code.
24+
- New `tango[webhooks]` extra (adds `click`) ships a `tango` console script covering the full webhook lifecycle for developer integrations:
25+
- `listen` — local receiver
26+
- `simulate` — sign a payload locally; with `--to`, also POST it
27+
- `trigger` — ask Tango to send a real test delivery
28+
- `fetch-sample` — print the canonical payload Tango emits for an event type
29+
- `list-event-types` — discover what's subscribable
30+
- `endpoints list|get|create|delete` — manage delivery endpoints
31+
- `subscriptions list|get|create|delete` — manage what events you receive
32+
Together these let a developer go from zero to receiving real Tango webhooks without leaving the shell or dropping into Python.
33+
34+
### Changed
35+
- `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.
36+
37+
### Deprecated
38+
- 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.
39+
40+
### Notes
41+
- Console script name `tango` may be revisited in a future release if it conflicts with sibling tooling (`tango-scripts` reuses the bare name).
42+
43+
### Documentation
44+
- 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.
45+
- `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.
46+
- `README.md`: new "Webhook Tooling" section under Advanced Features, plus the new guide is linked from the Documentation index.
947

1048
## [0.5.0] - 2026-04-08
1149

README.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,14 @@ otidvs = client.list_otidvs(limit=25)
169169
### Vehicles
170170

171171
```python
172-
vehicles = client.list_vehicles(search="GSA schedule", shape=ShapeConfig.VEHICLES_MINIMAL)
172+
vehicles = client.list_vehicles(
173+
search="GSA schedule",
174+
ordering="-vehicle_obligations",
175+
shape=ShapeConfig.VEHICLES_MINIMAL,
176+
)
173177
vehicle = client.get_vehicle("UUID", shape=ShapeConfig.VEHICLES_COMPREHENSIVE)
174178
awardees = client.list_vehicle_awardees("UUID")
179+
orders = client.list_vehicle_orders("UUID", ordering="-obligated")
175180
```
176181

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

335+
### Webhook Tooling
336+
337+
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:
338+
339+
```bash
340+
pip install 'tango-python[webhooks]'
341+
```
342+
343+
This adds a `tango` console script with subcommands for the full webhook lifecycle:
344+
345+
```bash
346+
# Discover what's available
347+
tango webhooks list-event-types
348+
tango webhooks fetch-sample --event-type entities.updated
349+
350+
# Local development
351+
tango webhooks listen --port 8011 --secret $SECRET # receiver
352+
tango webhooks simulate --secret $SECRET --event-type entities.updated # sign + print
353+
tango webhooks simulate --secret $SECRET --event-type entities.updated \
354+
--to http://127.0.0.1:8011/tango/webhooks # also POST
355+
356+
# Manage real subscriptions and endpoints
357+
tango webhooks endpoints create|list|get|delete
358+
tango webhooks subscriptions create|list|get|delete
359+
360+
# Force a real test delivery from Tango
361+
tango webhooks trigger
362+
```
363+
364+
The signing helpers (`verify_signature`, `generate_signature`) are pure stdlib and importable from the default install — your receiver code doesn't need the extra:
365+
366+
```python
367+
from tango.webhooks import verify_signature
368+
369+
if not verify_signature(raw_body, secret, request.headers.get("X-Tango-Signature")):
370+
return 401, "invalid signature"
371+
```
372+
373+
For the full guide — workflow, CLI reference, and programmatic patterns for pytest fixtures — see [`docs/WEBHOOKS.md`](docs/WEBHOOKS.md).
374+
330375
### Type Hints with IDE Support
331376

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

481527
## Requirements

docs/API_REFERENCE.md

Lines changed: 150 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -503,13 +503,14 @@ Vehicles provide a solicitation-centric way to discover groups of related IDVs a
503503

504504
### list_vehicles()
505505

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

508508
```python
509509
vehicles = client.list_vehicles(
510510
page=1,
511511
limit=25,
512512
search="GSA schedule",
513+
ordering="-vehicle_obligations",
513514
shape=ShapeConfig.VEHICLES_MINIMAL,
514515
flat=False,
515516
flat_lists=False,
@@ -520,6 +521,7 @@ vehicles = client.list_vehicles(
520521
- `page` (int): Page number (default: 1)
521522
- `limit` (int): Results per page (default: 25, max: 100)
522523
- `search` (str, optional): Vehicle-level search term
524+
- `ordering` (str, optional): Server-side sort. Allowed: `vehicle_obligations`, `latest_award_date`. Prefix with `-` for descending.
523525
- `shape` (str, optional): Shape string (defaults to `ShapeConfig.VEHICLES_MINIMAL`)
524526
- `flat` (bool): Flatten nested objects in shaped response
525527
- `flat_lists` (bool): Flatten arrays using indexed keys
@@ -552,6 +554,67 @@ awardees = client.list_vehicle_awardees(
552554
)
553555
```
554556

557+
### list_vehicle_orders()
558+
559+
List task orders under a vehicle's IDVs (`/api/vehicles/{uuid}/orders/`). Optimized for fast pagination over large vehicles.
560+
561+
```python
562+
orders = client.list_vehicle_orders(
563+
uuid="00000000-0000-0000-0000-000000000001",
564+
limit=25,
565+
ordering="-obligated",
566+
shape=ShapeConfig.VEHICLE_ORDERS_MINIMAL,
567+
)
568+
```
569+
570+
**Parameters:**
571+
- `uuid` (str): Vehicle UUID
572+
- `page` (int): Page number (default: 1)
573+
- `limit` (int): Results per page (default: 25, max: 100)
574+
- `ordering` (str, optional): Server-side sort. Allowed: `award_date` (default), `obligated`, `total_contract_value`. Prefix with `-` for descending.
575+
- `shape` (str, optional): Shape string (defaults to `ShapeConfig.VEHICLE_ORDERS_MINIMAL`)
576+
- `flat`, `flat_lists`, `joiner`: as on other vehicles methods
577+
578+
**Returns:** [PaginatedResponse](#paginatedresponse) with order (Contract) dictionaries
579+
580+
### Vehicle response fields
581+
582+
The post-cutover (May 2026) vehicle response includes these top-level fields, all addressable via the `shape` parameter:
583+
584+
| Field | Type | Notes |
585+
| ----- | ---- | ----- |
586+
| `uuid` | str | Stable identifier. |
587+
| `solicitation_identifier` | str | Solicitation shared by underlying IDVs. |
588+
| `is_synthetic_solicitation` | bool | `True` for GWAC orphans recovered via `ACRO:` prefix. |
589+
| `agency_id` | str | From IDV award-key suffix. |
590+
| `program_acronym` | str \| None | New post-cutover field. |
591+
| `organization_id` | str \| None | Awarding organization. |
592+
| `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. |
593+
| `vehicle_type`, `who_can_use`, `type_of_idc`, `contract_type` | dict \| None | Returned as `{code, description}`. |
594+
| `description` | str \| None | Common text across IDV descriptions. |
595+
| `descriptions` | list[str] \| None | Distinct IDV descriptions. |
596+
| `idv_count`, `awardee_count`, `order_count` | int \| None | Denormalized rollups. |
597+
| `total_obligated`, `vehicle_obligations`, `vehicle_contracts_value` | Decimal \| None | Denormalized rollups. |
598+
| `award_date`, `latest_award_date`, `last_date_to_order` | date \| None | |
599+
| `solicitation_title`, `solicitation_description`, `solicitation_date`, `opportunity_id` | str / date / None | From SAM.gov via the linked Opportunity. |
600+
| `naics_code`, `psc_code`, `set_aside`, `fiscal_year` | int / str / None | |
601+
602+
### Vehicle shape expansions
603+
604+
- `awardees(...)` — underlying IDV awards. Supports nested `orders(...)`.
605+
- `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`.
606+
- `organization` — live awarding-org snapshot (selected as a leaf field; not sub-selectable).
607+
608+
### Deprecated shape fields
609+
610+
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.
611+
612+
- `agency_details` (top-level field and `agency_details(*)` expansion)
613+
- `competition_details` (top-level field and `competition_details(*)` expansion)
614+
- `opportunity(*)` expansion (use the new top-level `solicitation_*` and `opportunity_id` fields instead)
615+
616+
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.
617+
555618
---
556619

557620
## IDVs
@@ -1227,6 +1290,8 @@ for code in naics.results:
12271290

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

1293+
> **For testing, signing, and a CLI tool**, see [`docs/WEBHOOKS.md`](WEBHOOKS.md). This section covers SDK method signatures only.
1294+
12301295
### list_webhook_event_types()
12311296

12321297
Discover supported `event_type` values and subject types.
@@ -1246,6 +1311,12 @@ Notes:
12461311

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

1314+
### get_webhook_subscription()
1315+
1316+
```python
1317+
sub = client.get_webhook_subscription("SUBSCRIPTION_UUID")
1318+
```
1319+
12491320
### create_webhook_subscription()
12501321

12511322
```python
@@ -1335,21 +1406,89 @@ Every delivery includes an HMAC signature header:
13351406

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

1409+
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):
1410+
13381411
```python
1339-
import hashlib
1340-
import hmac
1412+
from tango.webhooks import verify_signature
13411413

1414+
if not verify_signature(raw_body, secret, request.headers.get("X-Tango-Signature")):
1415+
return 401
1416+
```
1417+
1418+
`verify_signature` returns `False` for missing/empty/malformed headers — it never raises. Comparison is constant-time.
1419+
1420+
---
1421+
1422+
## Webhook tooling (`tango.webhooks`)
1423+
1424+
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.
1425+
1426+
### Signing (default install)
13421427

1343-
def verify_tango_webhook_signature(secret: str, raw_body: bytes, signature_header: str | None) -> bool:
1344-
if not signature_header:
1345-
return False
1346-
sig = signature_header.strip()
1347-
if sig.startswith("sha256="):
1348-
sig = sig[len("sha256=") :]
1349-
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
1350-
return hmac.compare_digest(expected, sig)
1428+
```python
1429+
from tango.webhooks import (
1430+
verify_signature, # (body: bytes, secret: str, header: str | None) -> bool
1431+
generate_signature, # (body: bytes, secret: str) -> str (lowercase hex)
1432+
parse_signature_header, # (header: str | None) -> str | None (strips "sha256=")
1433+
SIGNATURE_HEADER, # "X-Tango-Signature"
1434+
SIGNATURE_PREFIX, # "sha256="
1435+
)
13511436
```
13521437

1438+
### `WebhookReceiver` (with `[webhooks]` extra)
1439+
1440+
A stdlib-based local HTTP receiver, useful in tests and during local development.
1441+
1442+
```python
1443+
from tango.webhooks import WebhookReceiver, Delivery
1444+
1445+
with WebhookReceiver(secret="dev").run() as rx:
1446+
# ... cause something to POST to rx.url ...
1447+
deliveries: list[Delivery] = rx.deliveries
1448+
```
1449+
1450+
Constructor (all keyword arguments):
1451+
1452+
| Arg | Default | Meaning |
1453+
|---|---|---|
1454+
| `secret` | `""` | Shared secret. Empty means signatures are not verified. |
1455+
| `path` | `/tango/webhooks` | URL path to accept POSTs on. |
1456+
| `host` | `127.0.0.1` | Bind address. |
1457+
| `port` | `0` | TCP port. `0` = OS picks a free port. |
1458+
| `forward_to` | `None` | Optional URL to mirror each delivery to. |
1459+
| `max_history` | `256` | Cap on the in-memory `deliveries` deque. |
1460+
| `on_delivery` | `None` | Callback fired for every delivery (verified or not). |
1461+
| `require_signature` | `None` | Override default (require iff `secret` is set). |
1462+
1463+
Each `Delivery` is a dataclass: `received_at`, `path`, `signature_header`, `body_bytes`, `body_json`, `verified`, `remote_addr`, `forward_status`, `forward_error`.
1464+
1465+
### `simulate.sign` and `simulate.deliver`
1466+
1467+
```python
1468+
from tango.webhooks import sign, SignedRequest
1469+
from tango.webhooks import simulate
1470+
1471+
# Offline — produce the signed wire form without POSTing:
1472+
signed: SignedRequest = sign({"events": [{"event_type": "..."}]}, secret="s")
1473+
signed.body # bytes you would put on the wire
1474+
signed.signature # bare lowercase hex
1475+
signed.headers # {"Content-Type": ..., "X-Tango-Signature": "sha256=..."}
1476+
1477+
# With delivery — sign and POST to a target URL:
1478+
result = simulate.deliver(target_url="http://localhost:8011/tango/webhooks",
1479+
payload={...}, secret="s")
1480+
result.status_code # status from the receiver
1481+
result.signature # bare hex
1482+
result.sent_bytes # exact bytes that were POSTed
1483+
result.response_body # body the receiver returned
1484+
```
1485+
1486+
`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.
1487+
1488+
### CLI entry point
1489+
1490+
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.
1491+
13531492
---
13541493

13551494
## Response Objects

docs/SHAPES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ idvs = client.list_idvs(shape=ShapeConfig.IDVS_MINIMAL)
5757
grants = client.list_grants(shape=ShapeConfig.GRANTS_MINIMAL)
5858
```
5959

60-
**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.
60+
**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.
61+
62+
> **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.
6163
6264
## Basic Shaping
6365

0 commit comments

Comments
 (0)