From 828dd36f26942bd68beae9c327ddd280442ae95b Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Tue, 5 May 2026 19:04:54 -0500 Subject: [PATCH 01/14] v0.6.0: vehicles lakehouse-cutover surface Bring the vehicles client up to date with the upstream tango "lakehouse cutover" (May 2026). Adds new top-level fields, a `metrics(*)` shape expansion, the `/api/vehicles/{uuid}/orders/` endpoint, an `ordering` query param, and DeprecationWarning emission for fields the API now sends a `Deprecation: true` header for. Cassettes re-recorded against current production. Defaults verified to match production's shape contract (organization is a leaf dict, /orders/ rejects `uuid`, organization payload includes `organization_id`). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 ++ README.md | 7 +- docs/API_REFERENCE.md | 65 ++++++- docs/SHAPES.md | 4 +- pyproject.toml | 2 +- tango/__init__.py | 6 +- tango/client.py | 98 +++++++++- tango/models.py | 49 ++++- tango/shapes/explicit_schemas.py | 108 ++++++++-- ...get_vehicle_supports_joiner_and_flat_lists | 65 ++++--- ...on.test_get_vehicle_with_metrics_expansion | 158 +++++++++++++++ ...t_list_vehicle_awardees_uses_default_shape | 80 ++++---- ...est_list_vehicle_orders_uses_default_shape | 184 ++++++++++++++++++ ...ist_vehicles_uses_default_shape_and_search | 108 +++++++--- ...tegration.test_list_vehicles_with_ordering | 78 ++++++++ .../test_itdashboard_integration.py | 24 +-- .../test_vehicles_idvs_integration.py | 167 ++++++++++++++-- tests/production/test_production_smoke.py | 19 ++ tests/test_client.py | 9 +- uv.lock | 2 +- 20 files changed, 1083 insertions(+), 164 deletions(-) create mode 100644 tests/cassettes/TestVehiclesIntegration.test_get_vehicle_with_metrics_expansion create mode 100644 tests/cassettes/TestVehiclesIntegration.test_list_vehicle_orders_uses_default_shape create mode 100644 tests/cassettes/TestVehiclesIntegration.test_list_vehicles_with_ordering diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fbc51b..5f1209e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Vehicles: new top-level fields `program_acronym`, `idv_count`, `total_obligated`, `is_synthetic_solicitation`, `latest_award_date`, `description`, `opportunity_id`, and a live nested `organization` object (office hierarchy) — reflecting the upstream tango lakehouse cutover (May 2026). +- Vehicles: new `metrics(*)` shape expansion bundling 12 lakehouse 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. +- `ordering` parameter on `list_vehicles` (whitelist: `vehicle_obligations`, `latest_award_date`; prefix `-` for descending) and on `list_vehicle_orders` (whitelist: `award_date`, `obligated`, `total_contract_value`). +- `ShapeConfig.VEHICLE_ORDERS_MINIMAL` default for the new orders endpoint. +- `Vehicle` and `VehicleMetrics` are now exported from the top-level `tango` package. + +### 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. + ## [0.5.0] - 2026-04-08 ### Added diff --git a/README.md b/README.md index d154abf..d08d113 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index e1d58ee..e1eee2b 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -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, @@ -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 @@ -552,6 +554,67 @@ awardees = client.list_vehicle_awardees( ) ``` +### list_vehicle_orders() + +List task orders under a vehicle's IDVs (`/api/vehicles/{uuid}/orders/`). Backed by a denormalized lakehouse table 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 lakehouse 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 diff --git a/docs/SHAPES.md b/docs/SHAPES.md index 6504733..8e6cb01 100644 --- a/docs/SHAPES.md +++ b/docs/SHAPES.md @@ -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 post-cutover (May 2026) vehicles surface bundles 12 lakehouse 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 diff --git a/pyproject.toml b/pyproject.toml index 640b5c2..1fa0a1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tango-python" -version = "0.5.0" +version = "0.6.0" description = "Python SDK for the Tango API" readme = "README.md" requires-python = ">=3.12" diff --git a/tango/__init__.py b/tango/__init__.py index ff0d6f5..3955756 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -15,6 +15,8 @@ RateLimitInfo, SearchFilters, ShapeConfig, + Vehicle, + VehicleMetrics, WebhookEndpoint, WebhookEventType, WebhookEventTypesResponse, @@ -29,7 +31,7 @@ TypeGenerator, ) -__version__ = "0.5.0" +__version__ = "0.6.0" __all__ = [ "TangoClient", "TangoAPIError", @@ -43,6 +45,8 @@ "PaginatedResponse", "SearchFilters", "ShapeConfig", + "Vehicle", + "VehicleMetrics", "WebhookEndpoint", "WebhookEventType", "WebhookEventTypesResponse", diff --git a/tango/client.py b/tango/client.py index 2d29c36..7f3d9b5 100644 --- a/tango/client.py +++ b/tango/client.py @@ -1,6 +1,7 @@ """Tango API Client""" import os +import warnings from datetime import date, datetime from decimal import Decimal from typing import Any @@ -123,6 +124,7 @@ def last_response_headers(self) -> httpx.Headers | None: @staticmethod def _parse_rate_limit_headers(headers: httpx.Headers) -> RateLimitInfo: """Extract rate limit info from response headers.""" + def _int_or_none(val: str | None) -> int | None: if val is None: return None @@ -1447,6 +1449,40 @@ def get_itdashboard_investment( # Vehicles (Awards) # ============================================================================ + @staticmethod + def _warn_deprecated_vehicle_shape(shape: str | None) -> None: + # Upstream sends `Deprecation: true` for these fields/expansions; warn + # callers who request them explicitly so they have time to migrate + # before tango publishes a Sunset timeline. + from tango.shapes.explicit_schemas import DEPRECATED_VEHICLE_SHAPE_FIELDS + + if not shape: + return + # Match top-level field tokens, ignoring nesting inside parentheses. + depth = 0 + token = "" + tokens: list[str] = [] + for ch in shape: + if ch == "(": + depth += 1 + elif ch == ")": + depth = max(0, depth - 1) + elif ch == "," and depth == 0: + tokens.append(token.strip()) + token = "" + continue + token += ch + tokens.append(token.strip()) + used = {t.split("(", 1)[0] for t in tokens} & DEPRECATED_VEHICLE_SHAPE_FIELDS + if used: + warnings.warn( + f"Vehicle shape field(s) {sorted(used)!r} are deprecated upstream " + "and may be removed in a future tango API version. The API currently " + "returns a `Deprecation: true` header for these.", + DeprecationWarning, + stacklevel=3, + ) + def list_vehicles( self, page: int = 1, @@ -1456,12 +1492,20 @@ def list_vehicles( flat_lists: bool = False, joiner: str = ".", search: str | None = None, + ordering: str | None = None, ) -> PaginatedResponse: - """List Vehicles (solicitation-centric groupings of IDVs).""" + """List Vehicles (solicitation-centric groupings of IDVs). + + Args: + ordering: Server-side sort. Allowed: ``vehicle_obligations``, + ``latest_award_date``. Prefix with ``-`` for descending. + """ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} if shape is None: shape = ShapeConfig.VEHICLES_MINIMAL + else: + self._warn_deprecated_vehicle_shape(shape) if shape: params["shape"] = shape if flat: @@ -1473,6 +1517,8 @@ def list_vehicles( if search: params["search"] = search + if ordering: + params["ordering"] = ordering data = self._get("/api/vehicles/", params) @@ -1504,6 +1550,8 @@ def get_vehicle( if shape is None: shape = ShapeConfig.VEHICLES_COMPREHENSIVE + else: + self._warn_deprecated_vehicle_shape(shape) if shape: params["shape"] = shape if flat: @@ -1560,6 +1608,54 @@ def list_vehicle_awardees( results=results, ) + def list_vehicle_orders( + self, + uuid: str, + page: int = 1, + limit: int = 25, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + ) -> PaginatedResponse: + """List task orders under a Vehicle's IDVs (``/api/vehicles/{uuid}/orders/``). + + Args: + ordering: Server-side sort. Allowed: ``award_date`` (default), + ``obligated``, ``total_contract_value``. Prefix with ``-`` for + descending. + """ + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + + if shape is None: + shape = ShapeConfig.VEHICLE_ORDERS_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 + + data = self._get(f"/api/vehicles/{uuid}/orders/", params) + + results = [ + self._parse_response_with_shape(order, shape, Contract, flat, flat_lists, joiner=joiner) + for order in data["results"] + ] + + return PaginatedResponse( + count=data["count"], + next=data.get("next"), + previous=data.get("previous"), + results=results, + ) + # Business Types endpoints def list_business_types(self, page: int = 1, limit: int = 25) -> PaginatedResponse: """List business types""" diff --git a/tango/models.py b/tango/models.py index 3cab2c0..9137199 100644 --- a/tango/models.py +++ b/tango/models.py @@ -413,6 +413,12 @@ class Vehicle: uuid: str solicitation_identifier: str agency_id: str + is_synthetic_solicitation: bool | None = None + program_acronym: str | None = None + description: str | None = None + idv_count: int | None = None + total_obligated: Decimal | None = None + latest_award_date: date | None = None solicitation_title: str | None = None solicitation_date: date | None = None award_date: date | None = None @@ -420,6 +426,24 @@ class Vehicle: fiscal_year: int | None = None +@dataclass +class VehicleMetrics: + """Schema definition for the Vehicle `metrics(*)` expansion (not used for instances)""" + + avg_offers_received: float | None = None + award_concentration_hhi: float | None = None + order_concentration_hhi: float | None = None + competed_rate: float | None = None + using_agency_count: int | None = None + avg_order_value: float | None = None + max_order_value: float | None = None + top_recipient_share: float | None = None + recent_obligations_24mo: float | None = None + recent_orders_24mo: int | None = None + days_since_last_order: int | None = None + obligation_to_ceiling_ratio: float | None = None + + @dataclass class Entity: """Schema definition for Entity (not used for instances)""" @@ -690,21 +714,34 @@ class ShapeConfig: # Default for list_vehicles() VEHICLES_MINIMAL: Final = ( - "uuid,solicitation_identifier,organization_id,awardee_count,order_count," - "vehicle_obligations,vehicle_contracts_value,solicitation_title,solicitation_date" + "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" ) # Default for get_vehicle() VEHICLES_COMPREHENSIVE: Final = ( - "uuid,solicitation_identifier,agency_id,organization_id,vehicle_type,who_can_use," - "solicitation_title,solicitation_description,solicitation_date,naics_code,psc_code,set_aside," - "fiscal_year,award_date,last_date_to_order,awardee_count,order_count,vehicle_obligations,vehicle_contracts_value," - "type_of_idc,contract_type,competition_details(*)" + "uuid,solicitation_identifier,is_synthetic_solicitation,agency_id,program_acronym," + "organization_id,organization,vehicle_type,who_can_use," + "solicitation_title,solicitation_description,solicitation_date,opportunity_id," + "naics_code,psc_code,set_aside," + "fiscal_year,award_date,latest_award_date,last_date_to_order," + "description,idv_count,awardee_count,order_count,total_obligated," + "vehicle_obligations,vehicle_contracts_value," + "type_of_idc,contract_type,metrics(*)" ) # Default for list_vehicle_awardees() VEHICLE_AWARDEES_MINIMAL: Final = "uuid,key,piid,award_date,title,order_count,idv_obligations,idv_contracts_value,recipient(display_name,uei)" + # Default for list_vehicle_orders() + VEHICLE_ORDERS_MINIMAL: Final = ( + "key,piid,award_date,obligated,total_contract_value,description," + "recipient(display_name,uei)" + ) + # Default for list_organizations() ORGANIZATIONS_MINIMAL: Final = "key,fh_key,name,level,type,short_name" diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index 3833cb6..829928c 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -838,6 +838,45 @@ } +# Vehicles expose a "metrics(...)" expansion bundling lakehouse-computed metrics. +VEHICLE_METRICS_SCHEMA: dict[str, FieldSchema] = { + "avg_offers_received": FieldSchema( + name="avg_offers_received", type=float, is_optional=True, is_list=False + ), + "award_concentration_hhi": FieldSchema( + name="award_concentration_hhi", type=float, is_optional=True, is_list=False + ), + "order_concentration_hhi": FieldSchema( + name="order_concentration_hhi", type=float, is_optional=True, is_list=False + ), + "competed_rate": FieldSchema(name="competed_rate", type=float, is_optional=True, is_list=False), + "using_agency_count": FieldSchema( + name="using_agency_count", type=int, is_optional=True, is_list=False + ), + "avg_order_value": FieldSchema( + name="avg_order_value", type=float, is_optional=True, is_list=False + ), + "max_order_value": FieldSchema( + name="max_order_value", type=float, is_optional=True, is_list=False + ), + "top_recipient_share": FieldSchema( + name="top_recipient_share", type=float, is_optional=True, is_list=False + ), + "recent_obligations_24mo": FieldSchema( + name="recent_obligations_24mo", type=float, is_optional=True, is_list=False + ), + "recent_orders_24mo": FieldSchema( + name="recent_orders_24mo", type=int, is_optional=True, is_list=False + ), + "days_since_last_order": FieldSchema( + name="days_since_last_order", type=int, is_optional=True, is_list=False + ), + "obligation_to_ceiling_ratio": FieldSchema( + name="obligation_to_ceiling_ratio", type=float, is_optional=True, is_list=False + ), +} + + # IDV schema (used for `/api/idvs/`, and also by Vehicles awardees shaping). IDV_SCHEMA: dict[str, FieldSchema] = { # Identifiers @@ -970,26 +1009,49 @@ "solicitation_identifier": FieldSchema( name="solicitation_identifier", type=str, is_optional=False, is_list=False ), + "is_synthetic_solicitation": FieldSchema( + name="is_synthetic_solicitation", type=bool, is_optional=True, is_list=False + ), "agency_id": FieldSchema(name="agency_id", type=str, is_optional=False, is_list=False), "organization_id": FieldSchema( name="organization_id", type=str, is_optional=True, is_list=False ), + # Live awarding-org snapshot. Returned as a flat dict with keys: + # `organization_id`, `office_code`, `office_name`, `agency_code`, + # `agency_name`, `department_code`, `department_name`. Selected as a leaf + # token (`shape=...,organization`) — not as a sub-selectable expansion. + "organization": FieldSchema( + name="organization", type=dict, is_optional=True, is_list=False + ), # Choice fields are returned as {code, description} objects. "vehicle_type": FieldSchema(name="vehicle_type", type=dict, is_optional=True, is_list=False), + "program_acronym": FieldSchema( + name="program_acronym", type=str, is_optional=True, is_list=False + ), "who_can_use": FieldSchema(name="who_can_use", type=dict, is_optional=True, is_list=False), "type_of_idc": FieldSchema(name="type_of_idc", type=dict, is_optional=True, is_list=False), "contract_type": FieldSchema(name="contract_type", type=dict, is_optional=True, is_list=False), + # Deprecated: recomputed from IDVs at request time. Upstream sends `Deprecation: true`. "agency_details": FieldSchema( name="agency_details", type=dict, is_optional=True, is_list=False ), + "description": FieldSchema(name="description", type=str, is_optional=True, is_list=False), "descriptions": FieldSchema(name="descriptions", type=str, is_optional=True, is_list=True), "fiscal_year": FieldSchema(name="fiscal_year", type=int, is_optional=True, is_list=False), "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False), + "latest_award_date": FieldSchema( + name="latest_award_date", type=date, is_optional=True, is_list=False + ), "last_date_to_order": FieldSchema( name="last_date_to_order", type=date, is_optional=True, is_list=False ), + # Denormalized rollups + "idv_count": FieldSchema(name="idv_count", type=int, is_optional=True, is_list=False), "awardee_count": FieldSchema(name="awardee_count", type=int, is_optional=True, is_list=False), "order_count": FieldSchema(name="order_count", type=int, is_optional=True, is_list=False), + "total_obligated": FieldSchema( + name="total_obligated", type=Decimal, is_optional=True, is_list=False + ), "vehicle_obligations": FieldSchema( name="vehicle_obligations", type=Decimal, is_optional=True, is_list=False ), @@ -1006,6 +1068,7 @@ "solicitation_date": FieldSchema( name="solicitation_date", type=date, is_optional=True, is_list=False ), + "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), "psc_code": FieldSchema(name="psc_code", type=str, is_optional=True, is_list=False), "set_aside": FieldSchema(name="set_aside", type=str, is_optional=True, is_list=False), @@ -1013,6 +1076,14 @@ "awardees": FieldSchema( name="awardees", type=dict, is_optional=True, is_list=True, nested_model="IDV" ), + "metrics": FieldSchema( + name="metrics", + type=dict, + is_optional=True, + is_list=False, + nested_model="VehicleMetrics", + ), + # Deprecated expansions (upstream sends `Deprecation: true`). "opportunity": FieldSchema( name="opportunity", type=dict, is_optional=True, is_list=False, nested_model="Opportunity" ), @@ -1026,6 +1097,14 @@ } +# Top-level vehicle shape fields that upstream marks deprecated (the API sends +# `Deprecation: true` and recomputes them from IDVs at request time). The client +# emits a `DeprecationWarning` when callers explicitly include these in `shape`. +DEPRECATED_VEHICLE_SHAPE_FIELDS: frozenset[str] = frozenset( + {"agency_details", "competition_details", "opportunity"} +) + + # Organization (agencies hierarchy) ORGANIZATION_SCHEMA: dict[str, FieldSchema] = { "key": FieldSchema(name="key", type=str, is_optional=True, is_list=False), @@ -1135,18 +1214,10 @@ # IT Dashboard Investment ITDASHBOARD_INVESTMENT_SCHEMA: dict[str, FieldSchema] = { "uii": FieldSchema(name="uii", type=str, is_optional=False, is_list=False), - "agency_code": FieldSchema( - name="agency_code", type=int, is_optional=True, is_list=False - ), - "agency_name": FieldSchema( - name="agency_name", type=str, is_optional=True, is_list=False - ), - "bureau_code": FieldSchema( - name="bureau_code", type=int, is_optional=True, is_list=False - ), - "bureau_name": FieldSchema( - name="bureau_name", type=str, is_optional=True, is_list=False - ), + "agency_code": FieldSchema(name="agency_code", type=int, is_optional=True, is_list=False), + "agency_name": FieldSchema(name="agency_name", type=str, is_optional=True, is_list=False), + "bureau_code": FieldSchema(name="bureau_code", type=int, is_optional=True, is_list=False), + "bureau_name": FieldSchema(name="bureau_name", type=str, is_optional=True, is_list=False), "investment_title": FieldSchema( name="investment_title", type=str, is_optional=True, is_list=False ), @@ -1167,15 +1238,9 @@ # Modeled as opaque dict/list since their inner shapes are dynamic. "funding": FieldSchema(name="funding", type=dict, is_optional=True, is_list=False), "details": FieldSchema(name="details", type=dict, is_optional=True, is_list=False), - "cio_evaluation": FieldSchema( - name="cio_evaluation", type=list, is_optional=True, is_list=True - ), - "contracts": FieldSchema( - name="contracts", type=list, is_optional=True, is_list=True - ), - "projects": FieldSchema( - name="projects", type=list, is_optional=True, is_list=True - ), + "cio_evaluation": FieldSchema(name="cio_evaluation", type=list, is_optional=True, is_list=True), + "contracts": FieldSchema(name="contracts", type=list, is_optional=True, is_list=True), + "projects": FieldSchema(name="projects", type=list, is_optional=True, is_list=True), "cost_pools_towers": FieldSchema( name="cost_pools_towers", type=list, is_optional=True, is_list=True ), @@ -1225,6 +1290,7 @@ "Vehicle": VEHICLE_SCHEMA, "IDV": IDV_SCHEMA, "VehicleCompetitionDetails": VEHICLE_COMPETITION_DETAILS_SCHEMA, + "VehicleMetrics": VEHICLE_METRICS_SCHEMA, # Nested schemas for Grant fields "CFDANumber": CFDA_NUMBER_SCHEMA, "CodeDescription": CODE_DESCRIPTION_SCHEMA, diff --git a/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_supports_joiner_and_flat_lists b/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_supports_joiner_and_flat_lists index 4c516e8..56ba2da 100644 --- a/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_supports_joiner_and_flat_lists +++ b/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_supports_joiner_and_flat_lists @@ -13,24 +13,29 @@ interactions: user-agent: - python-httpx/0.28.1 method: GET - uri: https://tango.makegov.com/api/vehicles/?page=1&limit=1&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date + uri: https://tango.makegov.com/api/vehicles/?page=1&limit=1&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Corganization%2Cvehicle_type%2Cdescription%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Clatest_award_date%2Csolicitation_title%2Csolicitation_date response: body: - string: '{"count":5874,"next":"https://tango.makegov.com/api/vehicles/?limit=1&page=2&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"uuid":"84d76669-61d8-5938-83fb-2d6f8a6c85b7","solicitation_identifier":"0","organization_id":"f6c88e61-3d34-5685-a5ab-670858289883","awardee_count":38,"order_count":65,"vehicle_obligations":8876449.66,"vehicle_contracts_value":9688796.56,"solicitation_title":"X1LZ--618-20-2-6190-0007 - - Service - Minneapolis VA CRRC Parking, 33 Parking Spaces - MPLS","solicitation_date":"2020-03-02"}]}' + string: '{"count":20452,"next":"https://tango.makegov.com/api/vehicles/?limit=1&page=2&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Corganization%2Cvehicle_type%2Cdescription%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Clatest_award_date%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"awardee_count":2,"description":["CATTLE + AND SWINE BACKTAG CEMENT","CATTLE AND SWINE BACKTAG CEMENT TO BE DELIVERED + ON AN IDIQ BASIS TO KS, MO."],"idv_count":2,"is_synthetic_solicitation":false,"latest_award_date":"2006-06-01","order_count":25,"organization":{"organization_id":"767bb2b0-239a-5bb4-8f3c-16d0a418fb71","office_code":"126395","office_name":"MRPBS + MINNEAPOLIS MN","agency_code":"12K3","agency_name":"ANIMAL AND PLANT HEALTH + INSPECTION SERVICE","department_code":"012","department_name":"AGRICULTURE, + DEPARTMENT OF"},"organization_id":"767bb2b0-239a-5bb4-8f3c-16d0a418fb71","program_acronym":"APHIS + VS","solicitation_date":null,"solicitation_identifier":"002-M-APHIS-06","solicitation_title":null,"total_obligated":5388564.69,"uuid":"8597845d-2a4c-5acd-99ff-1f5fe348501e","vehicle_contracts_value":5116733.97,"vehicle_obligations":5388564.6899999995,"vehicle_type":{"code":"B","description":"IDC"}}]}' headers: CF-RAY: - - 9d71a79d1e69ace2-MSP + - 9f73b81ebb963d59-JAX Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 04 Mar 2026 14:43:42 GMT + - Wed, 06 May 2026 00:02:59 GMT Nel: - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' Report-To: - - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=foISsWd3o4Crwk58iKLLuBVkQFtC11yIJkhkCK4vC%2F0w4I28Kbk4j%2FWdF2Z4gVU3QAwqVjUkR9L3KC6Sa4dIYTFIEYgsuvxpb5FBysskgBHHwX4lRUqueUSX"}]}' + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=nlkDo84xSKlQqOxVMCULQpSFUr%2ByvNqk5clNAiAUG3P3v9GZj4sWYIijGnArPlv%2BaO9z%2B637EZfDn5VLkMykl6392hG%2BWNMYaI9%2B0cXLrEgfbcn12luHzYHEcVOIk2By9FZVYoJbO6CEhCeoeNzS"}]}' Server: - cloudflare Transfer-Encoding: @@ -40,7 +45,7 @@ interactions: cf-cache-status: - DYNAMIC content-length: - - '668' + - '1333' cross-origin-opener-policy: - same-origin referrer-policy: @@ -50,27 +55,29 @@ interactions: x-content-type-options: - nosniff x-execution-time: - - 0.021s + - 0.024s x-frame-options: - DENY x-ratelimit-burst-limit: - '1000' x-ratelimit-burst-remaining: - - '910' + - '986' x-ratelimit-burst-reset: - - '6' + - '29' x-ratelimit-daily-limit: - '2000000' x-ratelimit-daily-remaining: - - '1999670' + - '1999986' x-ratelimit-daily-reset: - - '84966' + - '86220' x-ratelimit-limit: - '1000' x-ratelimit-remaining: - - '910' + - '986' x-ratelimit-reset: - - '6' + - '29' + x-results-counttype: + - exact status: code: 200 message: OK @@ -88,24 +95,26 @@ interactions: user-agent: - python-httpx/0.28.1 method: GET - uri: https://tango.makegov.com/api/vehicles/84d76669-61d8-5938-83fb-2d6f8a6c85b7/?shape=uuid%2Copportunity%28title%29&flat=true&joiner=__&flat_lists=true + uri: https://tango.makegov.com/api/vehicles/8597845d-2a4c-5acd-99ff-1f5fe348501e/?shape=uuid%2Corganization&flat=true&joiner=__&flat_lists=true response: body: - string: '{"uuid":"84d76669-61d8-5938-83fb-2d6f8a6c85b7","opportunity__title":"X1LZ--618-20-2-6190-0007 - - Service - Minneapolis VA CRRC Parking, 33 Parking Spaces - MPLS"}' + string: '{"organization__organization_id":"767bb2b0-239a-5bb4-8f3c-16d0a418fb71","organization__office_code":"126395","organization__office_name":"MRPBS + MINNEAPOLIS MN","organization__agency_code":"12K3","organization__agency_name":"ANIMAL + AND PLANT HEALTH INSPECTION SERVICE","organization__department_code":"012","organization__department_name":"AGRICULTURE, + DEPARTMENT OF","uuid":"8597845d-2a4c-5acd-99ff-1f5fe348501e"}' headers: CF-RAY: - - 9d71a79de845ace2-MSP + - 9f73b81f5ca93d59-JAX Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 04 Mar 2026 14:43:42 GMT + - Wed, 06 May 2026 00:02:59 GMT Nel: - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' Report-To: - - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=fTIzuP10eNwQ3RQvXSnqPSyKJ%2F0GkCtQtRiwPTkcg7j62kkVcPa6owX9t2k8dFjcoroz4y6rj8b251WYX0UtASQXH3CIXIV2MxlJ6CzJ9%2FUD%2FcYSU6Ckn1vO"}]}' + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=CqJJ85Cgv80%2Fz2c7k61dXKeYrVFFz8RUxhtcsY3j86jFwjPcYo7xcPBjBH5cyohJxE%2FWeS%2Bdy2YWh1tWkStjis8jta9f%2BGiiyKyRqvQ3Zb3kzqFQwTGjXnTMPTW%2FNrzQ8shECys8DGoRPOZnWOab"}]}' Server: - cloudflare Transfer-Encoding: @@ -115,7 +124,7 @@ interactions: cf-cache-status: - DYNAMIC content-length: - - '161' + - '413' cross-origin-opener-policy: - same-origin referrer-policy: @@ -125,27 +134,27 @@ interactions: x-content-type-options: - nosniff x-execution-time: - - 0.032s + - 0.016s x-frame-options: - DENY x-ratelimit-burst-limit: - '1000' x-ratelimit-burst-remaining: - - '909' + - '985' x-ratelimit-burst-reset: - - '6' + - '29' x-ratelimit-daily-limit: - '2000000' x-ratelimit-daily-remaining: - - '1999669' + - '1999985' x-ratelimit-daily-reset: - - '84966' + - '86220' x-ratelimit-limit: - '1000' x-ratelimit-remaining: - - '909' + - '985' x-ratelimit-reset: - - '6' + - '29' status: code: 200 message: OK diff --git a/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_with_metrics_expansion b/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_with_metrics_expansion new file mode 100644 index 0000000..f06f0e0 --- /dev/null +++ b/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_with_metrics_expansion @@ -0,0 +1,158 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - tango.makegov.com + user-agent: + - python-httpx/0.28.1 + method: GET + uri: https://tango.makegov.com/api/vehicles/?page=1&limit=1&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Corganization%2Cvehicle_type%2Cdescription%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Clatest_award_date%2Csolicitation_title%2Csolicitation_date + response: + body: + string: '{"count":20452,"next":"https://tango.makegov.com/api/vehicles/?limit=1&page=2&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Corganization%2Cvehicle_type%2Cdescription%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Clatest_award_date%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"awardee_count":2,"description":["CATTLE + AND SWINE BACKTAG CEMENT","CATTLE AND SWINE BACKTAG CEMENT TO BE DELIVERED + ON AN IDIQ BASIS TO KS, MO."],"idv_count":2,"is_synthetic_solicitation":false,"latest_award_date":"2006-06-01","order_count":25,"organization":{"organization_id":"767bb2b0-239a-5bb4-8f3c-16d0a418fb71","office_code":"126395","office_name":"MRPBS + MINNEAPOLIS MN","agency_code":"12K3","agency_name":"ANIMAL AND PLANT HEALTH + INSPECTION SERVICE","department_code":"012","department_name":"AGRICULTURE, + DEPARTMENT OF"},"organization_id":"767bb2b0-239a-5bb4-8f3c-16d0a418fb71","program_acronym":"APHIS + VS","solicitation_date":null,"solicitation_identifier":"002-M-APHIS-06","solicitation_title":null,"total_obligated":5388564.69,"uuid":"8597845d-2a4c-5acd-99ff-1f5fe348501e","vehicle_contracts_value":5116733.97,"vehicle_obligations":5388564.6899999995,"vehicle_type":{"code":"B","description":"IDC"}}]}' + headers: + CF-RAY: + - 9f73b823692c60c7-JAX + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 06 May 2026 00:02:59 GMT + Nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + Report-To: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=B6gMSdTzCoCyerbzAHIcVlqBefWD2yJM28l29mzuR4ye3cO5QBChXu%2Foof465c8FHkZe8jIIWNe9U9rJfvAMpvgPgzx8eLhyJYVdac5U3d9XyF0qKbxJt58%2FMsn51P4CcUG49wsHDkNwLfd7N8qo"}]}' + Server: + - cloudflare + Transfer-Encoding: + - chunked + allow: + - GET, HEAD, OPTIONS + cf-cache-status: + - DYNAMIC + content-length: + - '1333' + cross-origin-opener-policy: + - same-origin + referrer-policy: + - same-origin + vary: + - Accept, Cookie + x-content-type-options: + - nosniff + x-execution-time: + - 0.021s + x-frame-options: + - DENY + x-ratelimit-burst-limit: + - '1000' + x-ratelimit-burst-remaining: + - '981' + x-ratelimit-burst-reset: + - '28' + x-ratelimit-daily-limit: + - '2000000' + x-ratelimit-daily-remaining: + - '1999981' + x-ratelimit-daily-reset: + - '86220' + x-ratelimit-limit: + - '1000' + x-ratelimit-remaining: + - '981' + x-ratelimit-reset: + - '28' + x-results-counttype: + - exact + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - tango.makegov.com + user-agent: + - python-httpx/0.28.1 + method: GET + uri: https://tango.makegov.com/api/vehicles/8597845d-2a4c-5acd-99ff-1f5fe348501e/?shape=uuid%2Cmetrics%28%2A%29 + response: + body: + string: '{"metrics":{"avg_offers_received":1.5,"award_concentration_hhi":6058.0,"order_concentration_hhi":5200.0,"competed_rate":0.88,"using_agency_count":1,"avg_order_value":215542.58759999997,"max_order_value":602601.6,"top_recipient_share":72.99,"recent_obligations_24mo":0.0,"recent_orders_24mo":0,"days_since_last_order":5710,"obligation_to_ceiling_ratio":1.053125826277812},"uuid":"8597845d-2a4c-5acd-99ff-1f5fe348501e"}' + headers: + CF-RAY: + - 9f73b823da9360c7-JAX + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 06 May 2026 00:02:59 GMT + Nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + Report-To: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=5i%2Bk54VkK%2FXROY20rDQl9Rsx8mWgHA2Cn8jk3aUms2rC%2FpUJW8g4ecY5n8OZlTXhCF%2B5a0kpjUoAXs7btqOqBrmtTDnAaKNTjRYKpz72at%2Bbcu8K%2BCcopKM7zRf7KdNL9q9hPXB01yre8vUVrCJ0"}]}' + Server: + - cloudflare + Transfer-Encoding: + - chunked + allow: + - GET, HEAD, OPTIONS + cf-cache-status: + - DYNAMIC + content-length: + - '417' + cross-origin-opener-policy: + - same-origin + referrer-policy: + - same-origin + vary: + - Accept, Cookie + x-content-type-options: + - nosniff + x-execution-time: + - 0.016s + x-frame-options: + - DENY + x-ratelimit-burst-limit: + - '1000' + x-ratelimit-burst-remaining: + - '980' + x-ratelimit-burst-reset: + - '28' + x-ratelimit-daily-limit: + - '2000000' + x-ratelimit-daily-remaining: + - '1999980' + x-ratelimit-daily-reset: + - '86220' + x-ratelimit-limit: + - '1000' + x-ratelimit-remaining: + - '980' + x-ratelimit-reset: + - '28' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_awardees_uses_default_shape b/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_awardees_uses_default_shape index c8e7417..bdffcb9 100644 --- a/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_awardees_uses_default_shape +++ b/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_awardees_uses_default_shape @@ -13,24 +13,29 @@ interactions: user-agent: - python-httpx/0.28.1 method: GET - uri: https://tango.makegov.com/api/vehicles/?page=1&limit=1&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date + uri: https://tango.makegov.com/api/vehicles/?page=1&limit=1&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Corganization%2Cvehicle_type%2Cdescription%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Clatest_award_date%2Csolicitation_title%2Csolicitation_date response: body: - string: '{"count":5874,"next":"https://tango.makegov.com/api/vehicles/?limit=1&page=2&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"uuid":"84d76669-61d8-5938-83fb-2d6f8a6c85b7","solicitation_identifier":"0","organization_id":"f6c88e61-3d34-5685-a5ab-670858289883","awardee_count":38,"order_count":65,"vehicle_obligations":8876449.66,"vehicle_contracts_value":9688796.56,"solicitation_title":"X1LZ--618-20-2-6190-0007 - - Service - Minneapolis VA CRRC Parking, 33 Parking Spaces - MPLS","solicitation_date":"2020-03-02"}]}' + string: '{"count":20452,"next":"https://tango.makegov.com/api/vehicles/?limit=1&page=2&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Corganization%2Cvehicle_type%2Cdescription%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Clatest_award_date%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"awardee_count":2,"description":["CATTLE + AND SWINE BACKTAG CEMENT","CATTLE AND SWINE BACKTAG CEMENT TO BE DELIVERED + ON AN IDIQ BASIS TO KS, MO."],"idv_count":2,"is_synthetic_solicitation":false,"latest_award_date":"2006-06-01","order_count":25,"organization":{"organization_id":"767bb2b0-239a-5bb4-8f3c-16d0a418fb71","office_code":"126395","office_name":"MRPBS + MINNEAPOLIS MN","agency_code":"12K3","agency_name":"ANIMAL AND PLANT HEALTH + INSPECTION SERVICE","department_code":"012","department_name":"AGRICULTURE, + DEPARTMENT OF"},"organization_id":"767bb2b0-239a-5bb4-8f3c-16d0a418fb71","program_acronym":"APHIS + VS","solicitation_date":null,"solicitation_identifier":"002-M-APHIS-06","solicitation_title":null,"total_obligated":5388564.69,"uuid":"8597845d-2a4c-5acd-99ff-1f5fe348501e","vehicle_contracts_value":5116733.97,"vehicle_obligations":5388564.6899999995,"vehicle_type":{"code":"B","description":"IDC"}}]}' headers: CF-RAY: - - 9d71a79f4d4ba1c9-MSP + - 9f73b82028e92733-JAX Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 04 Mar 2026 14:43:42 GMT + - Wed, 06 May 2026 00:02:59 GMT Nel: - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' Report-To: - - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=g0j44m3AvOYMt1ZDTnMMFZ2MIbXGngySfFBMATk3ynlDhde23D0RJVscwUPb6%2FacawtgKkwd%2Bq2ufXW62HT1tqPmdW1YWEDTq8jkHS7HBqy1vBy1fFH36z7q"}]}' + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=pb6L38IhrE8slcKjDmpMOoPMm4XFe4ByVQdtJXbaKHCLmJmZg4mj8VaQO%2F1v7gOmIbL9H3TNqGXF7WPcULg3j8haT%2B%2FMMkOVPve4NgEzKZxAJL8XFw9Oklv86aTGvrYJyH8zq39%2BOrnMHDI0S5g0"}]}' Server: - cloudflare Transfer-Encoding: @@ -40,7 +45,7 @@ interactions: cf-cache-status: - DYNAMIC content-length: - - '668' + - '1333' cross-origin-opener-policy: - same-origin referrer-policy: @@ -56,21 +61,23 @@ interactions: x-ratelimit-burst-limit: - '1000' x-ratelimit-burst-remaining: - - '908' + - '984' x-ratelimit-burst-reset: - - '6' + - '29' x-ratelimit-daily-limit: - '2000000' x-ratelimit-daily-remaining: - - '1999668' + - '1999984' x-ratelimit-daily-reset: - - '84966' + - '86220' x-ratelimit-limit: - '1000' x-ratelimit-remaining: - - '908' + - '984' x-ratelimit-reset: - - '6' + - '29' + x-results-counttype: + - exact status: code: 200 message: OK @@ -88,41 +95,26 @@ interactions: user-agent: - python-httpx/0.28.1 method: GET - uri: https://tango.makegov.com/api/vehicles/84d76669-61d8-5938-83fb-2d6f8a6c85b7/awardees/?page=1&limit=10&shape=uuid%2Ckey%2Cpiid%2Caward_date%2Ctitle%2Corder_count%2Cidv_obligations%2Cidv_contracts_value%2Crecipient%28display_name%2Cuei%29 + uri: https://tango.makegov.com/api/vehicles/8597845d-2a4c-5acd-99ff-1f5fe348501e/awardees/?page=1&limit=10&shape=uuid%2Ckey%2Cpiid%2Caward_date%2Ctitle%2Corder_count%2Cidv_obligations%2Cidv_contracts_value%2Crecipient%28display_name%2Cuei%29 response: body: - string: '{"count":38,"next":"https://tango.makegov.com/api/vehicles/84d76669-61d8-5938-83fb-2d6f8a6c85b7/awardees/?limit=10&page=2&shape=uuid%2Ckey%2Cpiid%2Caward_date%2Ctitle%2Corder_count%2Cidv_obligations%2Cidv_contracts_value%2Crecipient%28display_name%2Cuei%29","previous":null,"results":[{"uuid":"d3eec2d2-f46d-5f3a-a2b3-da474202982d","key":"CONT_IDV_36C24925A0044_3600","piid":"36C24925A0044","award_date":"2025-02-26","title":null,"order_count":2,"idv_obligations":97177.1,"idv_contracts_value":97177.1,"recipient":{"uei":"HPNGJJKW7NZ3","display_name":"MIM - SOFTWARE INC"}},{"uuid":"6b35e54e-cdd7-547a-9912-4f32889904ee","key":"CONT_IDV_36C24923A0055_3600","piid":"36C24923A0055","award_date":"2023-09-21","title":"LEXINGTON - VAMC AMOS CONSIGNMENT","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"G8XGKTUWPM14","display_name":"AMO - SALES AND SERVICE, INC."}},{"uuid":"4734026e-f902-5e4d-ae05-2625e9ee09b3","key":"CONT_IDV_36C10X23A0024_3600","piid":"36C10X23A0024","award_date":"2023-09-19","title":"HR - SUPPORT SERVICES","order_count":2,"idv_obligations":929714.8,"idv_contracts_value":931419.55,"recipient":{"uei":"WRHHNUK5YBN1","display_name":"RIVIDIUM - INC."}},{"uuid":"a2304733-ca28-5157-b800-ace63deb1f79","key":"CONT_IDV_36C24723A0033_3600","piid":"36C24723A0033","award_date":"2023-06-23","title":"CONSIGNMENT - INVENTORY","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"KP5EVFMHUAN1","display_name":"ABBOTT - LABORATORIES INC."}},{"uuid":"8a721329-40f9-5ca3-858d-ed9a067371f2","key":"CONT_IDV_36C26022A0045_3600","piid":"36C26022A0045","award_date":"2022-09-12","title":"INTRA - OCULAR LENSE CONSIGNMENT AGREEMENT","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"G8XGKTUWPM14","display_name":"AMO - SALES AND SERVICE, INC."}},{"uuid":"113dac46-3903-5889-9fc1-d4a58c01c591","key":"CONT_IDV_36C24122A0070_3600","piid":"36C24122A0070","award_date":"2022-03-17","title":"BLANKET - PURCHASE AGREEMENT FOR SUPPLIES NATIONWIDE VHA","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"L3CLKHB2VE24","display_name":"Sage - Products, LLC"}},{"uuid":"040283f5-ed08-5434-9df4-907c7af62e4f","key":"CONT_IDV_36C25022A0009_3600","piid":"36C25022A0009","award_date":"2021-10-01","title":"COOK - MEDICAL LLC CONSIGNMENT - IMPLANTS","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"GG39AE315NK5","display_name":"COOK - MEDICAL LLC"}},{"uuid":"7fe6ae99-081f-5be6-bd03-6a9bbf354980","key":"CONT_IDV_36C25021A0056_3600","piid":"36C25021A0056","award_date":"2021-03-23","title":null,"order_count":5,"idv_obligations":128048.14,"idv_contracts_value":164888.14,"recipient":{"uei":"STNDUK44ENE8","display_name":"POLYMEDCO - LLC"}},{"uuid":"3ab4321d-76a5-58c2-aaf6-85321ccd1bc1","key":"CONT_IDV_36C24821A0018_3600","piid":"36C24821A0018","award_date":"2021-03-17","title":"STERNAL - PLATES AND SCREWS","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"JB1EA2TU8V88","display_name":"BIOMET - MICROFIXATION, LLC"}},{"uuid":"141b0632-e12b-5ac8-a862-869bc1bd9f87","key":"CONT_IDV_36C24821A0016_3600","piid":"36C24821A0016","award_date":"2021-02-15","title":"CONSIGNMENT - AGREEMENT - PERIPHERAL VASCULAR EMBOLIZATION PRODUCTS","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"QLC9LYKADGX5","display_name":"PENUMBRA, - INC."}}]}' + string: '{"count":2,"next":null,"previous":null,"results":[{"award_date":"2006-06-01","idv_contracts_value":1183372.8,"idv_obligations":1455203.52,"key":"CONT_IDV_AG6395C060018_12K3","order_count":10,"piid":"AG6395C060018","recipient":{"uei":"GDVZPLNAKLL9","display_name":"STEVEN + INDUSTRIES INC"},"title":"CATTLE AND SWINE BACKTAG CEMENT","uuid":"af5f3527-b51b-55d8-9c6f-2d6aad936d31"},{"award_date":"2006-06-01","idv_contracts_value":3933361.17,"idv_obligations":3933361.17,"key":"CONT_IDV_AG6395C060019_12K3","order_count":15,"piid":"AG6395C060019","recipient":{"uei":"C1D7UA31LAK8","display_name":"M + J RUSCOE"},"title":"CATTLE AND SWINE BACKTAG CEMENT TO BE DELIVERED ON AN + IDIQ BASIS TO KS, MO.","uuid":"55cdcfa5-9b94-5aa5-8a8b-7a291d675e20"}]}' headers: CF-RAY: - - 9d71a7a00eb0a1c9-MSP + - 9f73b820ba5d2733-JAX Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 04 Mar 2026 14:43:42 GMT + - Wed, 06 May 2026 00:02:59 GMT Nel: - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' Report-To: - - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=PA0MWV3V4YvOgApvlK%2B30Y0fxb8uTbbVuL9VqVukOJLOvEMUylvJhadJX%2BuzJ2P7b5Nd8l6H5tYqYB%2FRfPaJao1Je5g32SIOLm0i2Ohjsm7hv1lGzYI6qyl2"}]}' + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=Hhdv2%2B3QcWBaFBmrjpPppmB%2BWUA7Qs%2FTsc05%2BmgouFx8U5esHFgTLVxRQMHjK7htLjAT4aI7s2cu43j1IjRqN8P%2FhFHLKq4yMnqrZ%2B3zionLGPhJFQ%2FVbIiaNsVRBsuCRRHzED8Mq%2F%2FohxH5q62o"}]}' Server: - cloudflare Transfer-Encoding: @@ -132,7 +124,7 @@ interactions: cf-cache-status: - DYNAMIC content-length: - - '3418' + - '739' cross-origin-opener-policy: - same-origin referrer-policy: @@ -142,27 +134,27 @@ interactions: x-content-type-options: - nosniff x-execution-time: - - 0.087s + - 0.036s x-frame-options: - DENY x-ratelimit-burst-limit: - '1000' x-ratelimit-burst-remaining: - - '907' + - '983' x-ratelimit-burst-reset: - - '6' + - '29' x-ratelimit-daily-limit: - '2000000' x-ratelimit-daily-remaining: - - '1999667' + - '1999983' x-ratelimit-daily-reset: - - '84966' + - '86220' x-ratelimit-limit: - '1000' x-ratelimit-remaining: - - '907' + - '983' x-ratelimit-reset: - - '6' + - '29' status: code: 200 message: OK diff --git a/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_orders_uses_default_shape b/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_orders_uses_default_shape new file mode 100644 index 0000000..5bda308 --- /dev/null +++ b/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_orders_uses_default_shape @@ -0,0 +1,184 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - tango.makegov.com + user-agent: + - python-httpx/0.28.1 + method: GET + uri: https://tango.makegov.com/api/vehicles/?page=1&limit=1&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Corganization%2Cvehicle_type%2Cdescription%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Clatest_award_date%2Csolicitation_title%2Csolicitation_date + response: + body: + string: '{"count":20452,"next":"https://tango.makegov.com/api/vehicles/?limit=1&page=2&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Corganization%2Cvehicle_type%2Cdescription%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Clatest_award_date%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"awardee_count":2,"description":["CATTLE + AND SWINE BACKTAG CEMENT","CATTLE AND SWINE BACKTAG CEMENT TO BE DELIVERED + ON AN IDIQ BASIS TO KS, MO."],"idv_count":2,"is_synthetic_solicitation":false,"latest_award_date":"2006-06-01","order_count":25,"organization":{"organization_id":"767bb2b0-239a-5bb4-8f3c-16d0a418fb71","office_code":"126395","office_name":"MRPBS + MINNEAPOLIS MN","agency_code":"12K3","agency_name":"ANIMAL AND PLANT HEALTH + INSPECTION SERVICE","department_code":"012","department_name":"AGRICULTURE, + DEPARTMENT OF"},"organization_id":"767bb2b0-239a-5bb4-8f3c-16d0a418fb71","program_acronym":"APHIS + VS","solicitation_date":null,"solicitation_identifier":"002-M-APHIS-06","solicitation_title":null,"total_obligated":5388564.69,"uuid":"8597845d-2a4c-5acd-99ff-1f5fe348501e","vehicle_contracts_value":5116733.97,"vehicle_obligations":5388564.6899999995,"vehicle_type":{"code":"B","description":"IDC"}}]}' + headers: + CF-RAY: + - 9f73b824ba669934-JAX + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 06 May 2026 00:03:00 GMT + Nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + Report-To: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=AUn0qs38WwHOJZc2XMWR%2Bht6rOF8JGHTzt7H7Onn63UyHktmeiTCsvGECYxGJXUDVPZvvj%2FJkaol61cuC3dERnJJPBnV9ZzOkYSZzTB9cUsxcgF4Zbtq2gGEq0BiVAVqMTEb%2BC6U5WlFbqjvDxHC"}]}' + Server: + - cloudflare + Transfer-Encoding: + - chunked + allow: + - GET, HEAD, OPTIONS + cf-cache-status: + - DYNAMIC + content-length: + - '1333' + cross-origin-opener-policy: + - same-origin + referrer-policy: + - same-origin + vary: + - Accept, Cookie + x-content-type-options: + - nosniff + x-execution-time: + - 0.040s + x-frame-options: + - DENY + x-ratelimit-burst-limit: + - '1000' + x-ratelimit-burst-remaining: + - '979' + x-ratelimit-burst-reset: + - '28' + x-ratelimit-daily-limit: + - '2000000' + x-ratelimit-daily-remaining: + - '1999979' + x-ratelimit-daily-reset: + - '86219' + x-ratelimit-limit: + - '1000' + x-ratelimit-remaining: + - '979' + x-ratelimit-reset: + - '28' + x-results-counttype: + - exact + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - tango.makegov.com + user-agent: + - python-httpx/0.28.1 + method: GET + uri: https://tango.makegov.com/api/vehicles/8597845d-2a4c-5acd-99ff-1f5fe348501e/orders/?page=1&limit=10&shape=key%2Cpiid%2Caward_date%2Cobligated%2Ctotal_contract_value%2Cdescription%2Crecipient%28display_name%2Cuei%29 + response: + body: + string: '{"count":25,"next":"https://tango.makegov.com/api/vehicles/8597845d-2a4c-5acd-99ff-1f5fe348501e/orders/?limit=10&page=2&shape=key%2Cpiid%2Caward_date%2Cobligated%2Ctotal_contract_value%2Cdescription%2Crecipient%28display_name%2Cuei%29","previous":null,"results":[{"award_date":"2010-09-16","description":"DELIVERY + ORDER FOR BACKTAG CEMENT.","key":"CONT_AWD_AG6395D100682_12K3_AG6395C060019_12K3","obligated":399827.2,"piid":"AG6395D100682","recipient":{"uei":"C1D7UA31LAK8","display_name":"M + J RUSCOE"},"total_contract_value":399827.2},{"award_date":"2010-08-03","description":"BACKTAG + CEMENT IN TUBES","key":"CONT_AWD_AG6395D100569_12K3_AG6395C060019_12K3","obligated":236160.0,"piid":"AG6395D100569","recipient":{"uei":"C1D7UA31LAK8","display_name":"M + J RUSCOE"},"total_contract_value":236160.0},{"award_date":"2010-03-17","description":"DELIVERY + ORDER FOR BACKTAG CEMENT.","key":"CONT_AWD_AG6395D100331_12K3_AG6395C060019_12K3","obligated":602601.6,"piid":"AG6395D100331","recipient":{"uei":"C1D7UA31LAK8","display_name":"M + J RUSCOE"},"total_contract_value":602601.6},{"award_date":"2009-12-16","description":"DELIVERY + ORDER FOR BACKTAG CEMENT.","key":"CONT_AWD_AG6395D100186_12K3_AG6395C060019_12K3","obligated":101683.2,"piid":"AG6395D100186","recipient":{"uei":"C1D7UA31LAK8","display_name":"M + J RUSCOE"},"total_contract_value":101683.2},{"award_date":"2009-08-04","description":"DELIVERY + ORDER FOR CLN#10, #11, #12 BACKTAG CEMENT IN TUBE, PINT, QUART SIZE CONTAINERS","key":"CONT_AWD_AG6395D090553_12K3_AG6395C060019_12K3","obligated":431080.0,"piid":"AG6395D090553","recipient":{"uei":"C1D7UA31LAK8","display_name":"M + J RUSCOE"},"total_contract_value":431080.0},{"award_date":"2009-04-23","description":"RUSCOE + MFG CTR#AG6395C060019, DELIVERY ORDER FOR CLN#10, #11, #12 BACKTAG CEMENT + IN TUBE, PINT, QUART SIZE CONTAINERS","key":"CONT_AWD_AG6395D090363_12K3_AG6395C060019_12K3","obligated":431080.0,"piid":"AG6395D090363","recipient":{"uei":"C1D7UA31LAK8","display_name":"M + J RUSCOE"},"total_contract_value":431080.0},{"award_date":"2008-10-23","description":"RUSCOE + MFG CTR#AG6395C060019, DELIVERY ORDER FOR CLN#10, #11, #12 BACKTAG CEMENT + IN TUBE, PINT, QUART SIZE CONTAINERS","key":"CONT_AWD_AG6395D090056_12K3_AG6395C060019_12K3","obligated":431082.24,"piid":"AG6395D090056","recipient":{"uei":"C1D7UA31LAK8","display_name":"M + J RUSCOE"},"total_contract_value":431082.24},{"award_date":"2008-05-13","description":"BACKTAG + CEMENT.","key":"CONT_AWD_AG6395D080395_12K3_AG6395C060018_12K3","obligated":61409.28,"piid":"AG6395D080395","recipient":{"uei":"GDVZPLNAKLL9","display_name":"STEVEN + INDUSTRIES INC"},"total_contract_value":61409.28},{"award_date":"2008-03-05","description":"RUSCOE + MFG CTR#AG6395C060019, DELIVERY ORDER FOR CLN#7 CEMENT IN TUBE SIZE CONTAINERS + PACKED 24/BX TOTAL 11,000 BXS INCLUDED 10 % OVERAGE. (1,320,000 OZ) WHSE STOCK + #Z888-00-000-0253","key":"CONT_AWD_AG6395D080271_12K3_AG6395C060019_12K3","obligated":329820.0,"piid":"AG6395D080271","recipient":{"uei":"C1D7UA31LAK8","display_name":"M + J RUSCOE"},"total_contract_value":329820.0},{"award_date":"2008-01-25","description":"BACKTAG + CEMENT.","key":"CONT_AWD_AG6395D080197_12K3_AG6395C060018_12K3","obligated":288748.8,"piid":"AG6395D080197","recipient":{"uei":"GDVZPLNAKLL9","display_name":"STEVEN + INDUSTRIES INC"},"total_contract_value":288748.8}],"meta":{"vehicle_uuid":"8597845d-2a4c-5acd-99ff-1f5fe348501e","solicitation_identifier":"002-M-APHIS-06"}}' + headers: + CF-RAY: + - 9f73b8255b6f9934-JAX + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 06 May 2026 00:03:00 GMT + Nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + Report-To: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=HMF3PWMAqeXFo%2BwKj7LE49%2Fa5L0Xv8ItISm5Zfcn%2FKLUJZQlFzfYk1kIrU6EAyojStWx7tR02%2FGF83GVXSZ1kIRzeXR1FXLTIDkdPx%2BpA2kqnbtNw4Tmtg1A%2BHZvsu7ueiPRypEZlTSvYpKhzgz0"}]}' + Server: + - cloudflare + Transfer-Encoding: + - chunked + allow: + - GET, HEAD, OPTIONS + cf-cache-status: + - DYNAMIC + content-length: + - '3442' + cross-origin-opener-policy: + - same-origin + referrer-policy: + - same-origin + vary: + - Accept, Cookie + x-content-type-options: + - nosniff + x-execution-time: + - 0.090s + x-frame-options: + - DENY + x-ratelimit-burst-limit: + - '1000' + x-ratelimit-burst-remaining: + - '978' + x-ratelimit-burst-reset: + - '28' + x-ratelimit-daily-limit: + - '2000000' + x-ratelimit-daily-remaining: + - '1999978' + x-ratelimit-daily-reset: + - '86219' + x-ratelimit-limit: + - '1000' + x-ratelimit-remaining: + - '978' + x-ratelimit-reset: + - '28' + x-results-counttype: + - exact + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_uses_default_shape_and_search b/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_uses_default_shape_and_search index 12ed35c..5133056 100644 --- a/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_uses_default_shape_and_search +++ b/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_uses_default_shape_and_search @@ -13,33 +13,95 @@ interactions: user-agent: - python-httpx/0.28.1 method: GET - uri: https://tango.makegov.com/api/vehicles/?page=1&limit=10&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date&search=GSA + uri: https://tango.makegov.com/api/vehicles/?page=1&limit=10&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Corganization%2Cvehicle_type%2Cdescription%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Clatest_award_date%2Csolicitation_title%2Csolicitation_date&search=GSA response: body: - string: '{"count":73,"next":"https://tango.makegov.com/api/vehicles/?limit=10&page=2&search=GSA&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"uuid":"6741a166-ae8e-57ba-b156-76f9137febad","solicitation_identifier":"15F06725Q0000150","organization_id":"08ba1358-7dad-5228-9540-0a3718bc621d","awardee_count":4,"order_count":5,"vehicle_obligations":3104682.7,"vehicle_contracts_value":29044858.96,"solicitation_title":"Curriculum - Development Synopsis/Solicitation - Updated GSA site","solicitation_date":"2025-04-10"},{"uuid":"1e03d563-afa5-565f-be04-ba64b0ec053c","solicitation_identifier":"2FYB-BJ-030001-B","organization_id":null,"awardee_count":46,"order_count":7458,"vehicle_obligations":139267557.14,"vehicle_contracts_value":607178722.14,"solicitation_title":"Cameras, - Photographic Printers and Related Supplies and Services","solicitation_date":"2001-03-01"},{"uuid":"6fea329b-c906-5857-8384-6451a4c93430","solicitation_identifier":"36C10X23Q0022","organization_id":"f6c88e61-3d34-5685-a5ab-670858289883","awardee_count":4,"order_count":14,"vehicle_obligations":2356056.0,"vehicle_contracts_value":2356056.0,"solicitation_title":"U008--Acquisition + string: '{"count":103,"next":"https://tango.makegov.com/api/vehicles/?limit=10&page=2&search=GSA&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Corganization%2Cvehicle_type%2Cdescription%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Clatest_award_date%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"awardee_count":0,"description":["EMDX + RESEARCH AND CONSULTING ASSOCIATES, LLC, NATIONAL ANIMAL CARE BLANKET PURCHASE + AGREEMENT FOR THE AGRICULTURAL RESEARCH SERVICE","SCITECH SERVICES INC NATIONAL + ANIMAL CARE BLANKET PURCHASE AGREEMENT FOR THE AGRICULTURAL RESEARCH SERVICE","THE + MCCONNELL GROUP, INC., NATIONAL ANIMAL CARE BLANKET PURCHASE AGREEMENT FOR + THE AGRICULTURAL RESEARCH SERVICE","ABANTEARE LLC NATIONAL ANIMAL CARE BLANKET + PURCHASE AGREEMENT FOR THE AGRICULTURAL RESEARCH SERVICE","BCA FEDERAL, LLC, + NATIONAL ANIMAL CARE BLANKET PURCHASE AGREEMENT FOR THE AGRICULTURAL RESEARCH + SERVICE","FEFA, LLC NATIONAL ANIMAL CARE BLANKET PURCHASE AGREEMENT FOR THE + AGRICULTURAL RESEARCH SERVICE","SOBRAN, INC., NATIONAL ANIMAL CARE BLANKET + PURCHASE AGREEMENT FOR THE AGRICULTURAL RESEARCH SERVICE"],"idv_count":7,"is_synthetic_solicitation":false,"latest_award_date":"2026-05-01","order_count":0,"organization":{"organization_id":"0ef2831b-d699-50c7-8326-63541a75fa11","office_code":"1232SA","office_name":"USDA + ARS AFM APD","agency_code":"12H2","agency_name":"AGRICULTURAL RESEARCH SERVICE","department_code":"012","department_name":"AGRICULTURE, + DEPARTMENT OF"},"organization_id":"0ef2831b-d699-50c7-8326-63541a75fa11","program_acronym":"GSA","solicitation_date":null,"solicitation_identifier":"1232SA26Q0019","solicitation_title":null,"total_obligated":0.0,"uuid":"3fdd49c6-bf14-57b5-9b56-05a5d3adb35a","vehicle_contracts_value":0.0,"vehicle_obligations":0.0,"vehicle_type":{"code":"E","description":"BPA"}},{"awardee_count":2,"description":["VARIOUS + DIVISIONS WITHIN THE AGENCY REQUIRE CURRICULUM DEVELOPMENT SUPPORT. EACH DEPARTMENT/DIVISION + WILL BE REQUIRED TO USE THE ANALYZE, DESIGN, DEVELOP, IMPLEMENT AND EVALUATION + (ADDIE) MODULE.","DIVISIONS WITHIN THE AGENCY REQUIRE CURRICULUM DEVELOPMENT + SUPPORT. EACH DEPT/DIV WILL REQUIRE THE USE OF ANALYZE, DESIGN, DEVELOP, IMPLEMENT + AND EVALUATION (ADDIE) MODULE. EACH DEPT/DIV SPECIFIC REQUIREMENTS WILL BE + PROVIDED ON SEPARATE BPA CALL."],"idv_count":4,"is_synthetic_solicitation":false,"latest_award_date":"2025-09-04","order_count":5,"organization":{"organization_id":"ea1c7a92-5838-5641-99ba-1527f4524306","office_code":"15F067","office_name":"FBI-JEH","agency_code":"1549","agency_name":"FEDERAL + BUREAU OF INVESTIGATION","department_code":"015","department_name":"JUSTICE, + DEPARTMENT OF"},"organization_id":"ea1c7a92-5838-5641-99ba-1527f4524306","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"15F06725Q0000150","solicitation_title":"Curriculum + Development Synopsis/Solicitation - Updated GSA site","total_obligated":3104682.7,"uuid":"6741a166-ae8e-57ba-b156-76f9137febad","vehicle_contracts_value":29044858.959999997,"vehicle_obligations":3104682.7,"vehicle_type":{"code":"E","description":"BPA"}},{"awardee_count":1,"description":["MULTI-AWARD + BLANKET PURCHASE AGREEMENT (BPA) FOR EDUCATION, OUTREACH & ANALYSIS"],"idv_count":4,"is_synthetic_solicitation":false,"latest_award_date":"2025-02-13","order_count":1,"organization":{"organization_id":"5b904ace-8e3f-5909-a991-3e7297c5ffeb","office_code":"1605C2","office_name":"DOL + - CAS DIVISION 2 PROCUREMENT","agency_code":"1605","agency_name":"OFFICE OF + THE ASSISTANT SECRETARY FOR ADMINISTRATION AND MANAGEMENT","department_code":"016","department_name":"LABOR, + DEPARTMENT OF"},"organization_id":"5b904ace-8e3f-5909-a991-3e7297c5ffeb","program_acronym":"GSA","solicitation_date":null,"solicitation_identifier":"1605C2-24-Q-00026","solicitation_title":null,"total_obligated":50000.0,"uuid":"9f042142-a09b-5244-9b3d-a638a4e2f219","vehicle_contracts_value":739950.6,"vehicle_obligations":50000.0,"vehicle_type":{"code":"E","description":"BPA"}},{"awardee_count":3,"description":["VAAA + ACQUISITION WORKFORCE TRAINING, MULTIPLE AWARD BLANKET PURCHASE AGREEMENT + (BPA)"],"idv_count":4,"is_synthetic_solicitation":false,"latest_award_date":"2024-02-12","order_count":14,"organization":{"organization_id":"d8247025-812e-5403-954c-09577eab4a91","office_code":"36C10X","office_name":"SAC + FREDERICK (36C10X)","agency_code":"3600","agency_name":"VETERANS AFFAIRS, + DEPARTMENT OF","department_code":"036","department_name":"VETERANS AFFAIRS, + DEPARTMENT OF"},"organization_id":"d8247025-812e-5403-954c-09577eab4a91","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"36C10X23Q0022","solicitation_title":"U008--Acquisition Workforce Training Multiple Award BPA - NEW GSA Contract Number Business Management - Research Associates, Inc","solicitation_date":"2024-04-05"},{"uuid":"e99312ce-b377-5fac-81ce-7f3f19b548c4","solicitation_identifier":"36C26120Q0012","organization_id":"f6c88e61-3d34-5685-a5ab-670858289883","awardee_count":3,"order_count":92,"vehicle_obligations":51415076.89,"vehicle_contracts_value":51498956.84,"solicitation_title":"Q301--Quest - FSS-BPA GSA Schedule 621 II","solicitation_date":"2020-09-28"},{"uuid":"26090608-dfc4-5245-8f1d-043f40d3cedd","solicitation_identifier":"3FNG-RG-020001-B","organization_id":"edfcdf18-d5d1-5e9a-8563-d8ed1f2844ce","awardee_count":111,"order_count":3766,"vehicle_obligations":231871038.22,"vehicle_contracts_value":235312118.92,"solicitation_title":"Professional - Audio/Video, Telemetry/Tracking, Recording, Reproducing and Signal Data Solutions","solicitation_date":"2010-11-30"},{"uuid":"1293190d-b755-5f61-a484-211f4425aad2","solicitation_identifier":"3FNJ-C1-000001-B","organization_id":null,"awardee_count":275,"order_count":51247,"vehicle_obligations":3570831390.91,"vehicle_contracts_value":11123777947.32,"solicitation_title":"Office, - Imaging and Document","solicitation_date":"1999-04-01"},{"uuid":"74ef741b-f0bc-506a-94cc-8252264a0237","solicitation_identifier":"3FNJ-C1-000001-B","organization_id":"edfcdf18-d5d1-5e9a-8563-d8ed1f2844ce","awardee_count":294,"order_count":10410,"vehicle_obligations":3351983917.63,"vehicle_contracts_value":7967591185.76,"solicitation_title":"Office, - Imaging and Document","solicitation_date":"2010-10-01"},{"uuid":"1f8a3ce7-a58c-5d36-907d-bf67f96e2cd4","solicitation_identifier":"3QSA-JB-100001-B","organization_id":"edfcdf18-d5d1-5e9a-8563-d8ed1f2844ce","awardee_count":497,"order_count":24312,"vehicle_obligations":2067723956.69,"vehicle_contracts_value":2149663259.46,"solicitation_title":"Furniture","solicitation_date":"2010-10-04"},{"uuid":"4b755976-75c2-573a-bd3a-92147261df2f","solicitation_identifier":"47PB0023R0012","organization_id":"17417361-31f1-5514-9623-5ef7b0ae02db","awardee_count":24,"order_count":47,"vehicle_obligations":22210407.27,"vehicle_contracts_value":22210407.27,"solicitation_title":"GSA - Region 1 Construction IDIQ - North/Boston/South Zones","solicitation_date":"2023-06-30"},{"uuid":"59cee065-88e2-5a98-be93-004df7e23d18","solicitation_identifier":"47PB0023R0059","organization_id":"17417361-31f1-5514-9623-5ef7b0ae02db","awardee_count":7,"order_count":18,"vehicle_obligations":1171435.78,"vehicle_contracts_value":1171435.78,"solicitation_title":"Architectural - and Engineering Multiple Award IDIQ New England","solicitation_date":"2023-09-13"}]}' + Research Associates, Inc","total_obligated":2359268.78,"uuid":"6fea329b-c906-5857-8384-6451a4c93430","vehicle_contracts_value":2359268.78,"vehicle_obligations":2359268.78,"vehicle_type":{"code":"E","description":"BPA"}},{"awardee_count":3,"description":["REFERENCE + LABORATORY SERVICES FOR VISN 21","REFERENCE LABORATORY SERVICES FOR VISN 21."],"idv_count":3,"is_synthetic_solicitation":false,"latest_award_date":"2020-09-25","order_count":93,"organization":{"organization_id":"2c45fd53-3590-5589-a188-1f34917b3de5","office_code":"36C261","office_name":"261-NETWORK + CONTRACT OFFICE 21 (36C261)","agency_code":"3600","agency_name":"VETERANS + AFFAIRS, DEPARTMENT OF","department_code":"036","department_name":"VETERANS + AFFAIRS, DEPARTMENT OF"},"organization_id":"2c45fd53-3590-5589-a188-1f34917b3de5","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"36C26120Q0012","solicitation_title":"Q301--Quest + FSS-BPA GSA Schedule 621 II","total_obligated":52052266.47,"uuid":"e99312ce-b377-5fac-81ce-7f3f19b548c4","vehicle_contracts_value":53523209.29,"vehicle_obligations":52052266.47,"vehicle_type":{"code":"E","description":"BPA"}},{"awardee_count":6,"description":["GSA + LEASING SUPPORT SERVICES"],"idv_count":9,"is_synthetic_solicitation":false,"latest_award_date":"2020-05-13","order_count":911,"organization":{"organization_id":"6f5ca983-9b09-55b6-9ad0-f0ce1c2d06c1","office_code":"47PA05","office_name":"PBS + R00 CENTER FOR BROKER SERVICES","agency_code":"4740","agency_name":"PUBLIC + BUILDINGS SERVICE","department_code":"047","department_name":"GENERAL SERVICES + ADMINISTRATION"},"organization_id":"6f5ca983-9b09-55b6-9ad0-f0ce1c2d06c1","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"47PA0519R0001","solicitation_title":"GSA + Leasing Support Services Plus","total_obligated":0.0,"uuid":"5dbdd43a-d8aa-56eb-a11f-78e0ded35642","vehicle_contracts_value":907.02,"vehicle_obligations":0.0,"vehicle_type":{"code":"B","description":"IDC"}},{"awardee_count":7,"description":["ELECTRIC + SUPPLY","PROVIDE ELECTRIC UTILITY SUPPLY IN ERCOT FOR GROUPS 9 FOR AGENCIES + LISTED IN EXHIBIT 1 OF THIS CONTRACT.","PROVIDE ELECTRIC UTILITY SUPPLY IN + PJM FOR GROUPS 3 5 7 FOR AGENCIES LISTED IN EXHIBIT 1 OF THIS CONTRACT.","PROVIDE + ELECTRIC UTILITY SUPPLY IN PJM FOR GROUPS 2 4 6 8 FOR AGENCIES LISTED IN EXHIBIT + 1 OF THIS CONTRACT."],"idv_count":9,"is_synthetic_solicitation":false,"latest_award_date":"2025-08-18","order_count":88,"organization":{"organization_id":"949facff-0fda-5062-972b-01ad17491bc6","office_code":"47PA08","office_name":"PBS + RETAIL UTILITY PROCUREMENTS","agency_code":"4740","agency_name":"PUBLIC BUILDINGS + SERVICE","department_code":"047","department_name":"GENERAL SERVICES ADMINISTRATION"},"organization_id":"949facff-0fda-5062-972b-01ad17491bc6","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"47PA0825R0002","solicitation_title":"FY25 + GSA Streamlined Retail Electric Solicitation","total_obligated":44802108.86,"uuid":"6aa9d616-4d60-52b3-bfb7-a4f4ba332a3c","vehicle_contracts_value":105773964.96,"vehicle_obligations":44802108.86,"vehicle_type":{"code":"B","description":"IDC"}},{"awardee_count":0,"description":["ELECTRIC + SUPPLY FOR FIXED PRICES, PRICING GROUP 3 IN THE PJM BALANCING AUTHORITY","ELECTRIC + SUPPLY FOR FIXED PRICES, PRICING GROUPS 5 AND 6 IN THE PJM BALANCING AUTHORITY","ELECTRIC + SUPPLY FOR FIXED PRICES, PRICING GROUPS 1 AND 2 IN THE PJM BALANCING AUTHORITY","ELECTRIC + SUPPLY FOR FIXED PRICES, PRICING GROUP 4 IN THE PJM BALANCING AUTHORITY"],"idv_count":4,"is_synthetic_solicitation":false,"latest_award_date":"2026-03-23","order_count":0,"organization":{"organization_id":"949facff-0fda-5062-972b-01ad17491bc6","office_code":"47PA08","office_name":"PBS + RETAIL UTILITY PROCUREMENTS","agency_code":"4740","agency_name":"PUBLIC BUILDINGS + SERVICE","department_code":"047","department_name":"GENERAL SERVICES ADMINISTRATION"},"organization_id":"949facff-0fda-5062-972b-01ad17491bc6","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"47PA0826R0001","solicitation_title":"FY26 + GSA Streamlined Retail Electric Solicitation","total_obligated":0.0,"uuid":"7c370689-aad8-5ba3-9498-c018bc5df121","vehicle_contracts_value":0.0,"vehicle_obligations":0.0,"vehicle_type":{"code":"B","description":"IDC"}},{"awardee_count":8,"description":["MULTIPLE + AWARD, INDEFINITE DELIVERY/INDEFINITE QUANTITY (IDIQ) WITH MINIMUM GUARANTEE + FOR CONSTRUCTION SERVICES FOR GSA-OWNED AND LEASED FEDERAL FACILITIES IN REGION + 1 SOUTH ZONE.","MULTIPLE AWARD, INDEFINITE DELIVERY/INDEFINITE QUANTITY (IDIQ) + WITH MINIMUM GUARANTEE FOR CONSTRUCTION SERVICES FOR GSA-OWNED AND LEASED + FEDERAL FACILITIES IN REGION 1 NORTH ZONE.","MULTIPLE AWARD, INDEFINITE DELIVERY/INDEFINITE + QUANTITY (IDIQ) WITH MINIMUM GUARANTEE FOR CONSTRUCTION SERVICES FOR GSA-OWNED + AND LEASED FEDERAL FACILITIES IN REGION 1 BOSTON ZONE."],"idv_count":24,"is_synthetic_solicitation":false,"latest_award_date":"2025-02-24","order_count":51,"organization":{"organization_id":"2fd4b124-5131-5ab9-9091-e2ee27274d0e","office_code":"47PB00","office_name":"PBS + R1 ACQ MANAGEMENT DIVISION","agency_code":"4740","agency_name":"PUBLIC BUILDINGS + SERVICE","department_code":"047","department_name":"GENERAL SERVICES ADMINISTRATION"},"organization_id":"2fd4b124-5131-5ab9-9091-e2ee27274d0e","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"47PB0023R0012","solicitation_title":"GSA + Region 1 Construction IDIQ - North/Boston/South Zones","total_obligated":24000257.07,"uuid":"4b755976-75c2-573a-bd3a-92147261df2f","vehicle_contracts_value":24000257.07,"vehicle_obligations":24000257.07,"vehicle_type":{"code":"B","description":"IDC"}},{"awardee_count":0,"description":["CARIBBEAN + CONSTRUCTION & DESIGN-BUILD CONSTRUCTION SERVICES"],"idv_count":7,"is_synthetic_solicitation":false,"latest_award_date":"2025-12-16","order_count":0,"organization":{"organization_id":"54839cf5-aa68-5304-8f46-9aa63eaf2ba5","office_code":"47PC55","office_name":"PBS + PROJECT DELIVERY EAST - BRANCH E","agency_code":"4740","agency_name":"PUBLIC + BUILDINGS SERVICE","department_code":"047","department_name":"GENERAL SERVICES + ADMINISTRATION"},"organization_id":"54839cf5-aa68-5304-8f46-9aa63eaf2ba5","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"47PC0222R0019","solicitation_title":"GSA + R2 - Caribbean Construction IDIQ","total_obligated":0.0,"uuid":"1a1704af-7d47-5293-b189-cf6a8c7ca35f","vehicle_contracts_value":0.0,"vehicle_obligations":0.0,"vehicle_type":{"code":"B","description":"IDC"}}]}' headers: CF-RAY: - - 9d71a7968b13fc89-MSP + - 9f73b81bf9a460c7-JAX Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 04 Mar 2026 14:43:42 GMT + - Wed, 06 May 2026 00:02:58 GMT Nel: - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' Report-To: - - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=5%2BHepsdgjcU87kM%2BNnTgEm5bAgw8L9ZsZHeSXeBCTE%2BWnksTynGg8TJ56KoKVetk3pFnm%2Bz6OTI%2BHQ3lraKGlHtfXJrFZtMZU6d5rEzcXciLRMAIthAZ1V8Z"}]}' + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=mvbE0znCQMx6U%2BgaEc5wluqJwJD6ucje1qKsBbZ%2F7ZWCVecTQHyBrlSBGPO44JvVemBkaSui4vuB6wXTaW1QuBpGD9oaRlxmkXUigS2jf%2BDoqMKs2mlfmejcjhk6xq8MNUy%2B0sfLHo8NYz%2FHVLiQ"}]}' Server: - cloudflare Transfer-Encoding: @@ -49,7 +111,7 @@ interactions: cf-cache-status: - DYNAMIC content-length: - - '3950' + - '11445' cross-origin-opener-policy: - same-origin referrer-policy: @@ -59,27 +121,29 @@ interactions: x-content-type-options: - nosniff x-execution-time: - - 0.578s + - 0.304s x-frame-options: - DENY x-ratelimit-burst-limit: - '1000' x-ratelimit-burst-remaining: - - '911' + - '987' x-ratelimit-burst-reset: - - '7' + - '29' x-ratelimit-daily-limit: - '2000000' x-ratelimit-daily-remaining: - - '1999671' + - '1999987' x-ratelimit-daily-reset: - - '84967' + - '86221' x-ratelimit-limit: - '1000' x-ratelimit-remaining: - - '911' + - '987' x-ratelimit-reset: - - '7' + - '29' + x-results-counttype: + - exact status: code: 200 message: OK diff --git a/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_with_ordering b/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_with_ordering new file mode 100644 index 0000000..991fabb --- /dev/null +++ b/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_with_ordering @@ -0,0 +1,78 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - tango.makegov.com + user-agent: + - python-httpx/0.28.1 + method: GET + uri: https://tango.makegov.com/api/vehicles/?page=1&limit=10&shape=uuid%2Cvehicle_obligations&ordering=-vehicle_obligations + response: + body: + string: '{"count":20452,"next":"https://tango.makegov.com/api/vehicles/?limit=10&ordering=-vehicle_obligations&page=2&shape=uuid%2Cvehicle_obligations","previous":null,"results":[{"uuid":"d44a2444-0e5f-5dda-ac83-a950dd91beb3","vehicle_obligations":88745333134.01},{"uuid":"054a30f0-c047-5627-8a3f-814f4bdfa497","vehicle_obligations":76190247502.85},{"uuid":"3adb66a1-c2cb-5b0f-a16f-03fed8e47dff","vehicle_obligations":75840315414.76},{"uuid":"55395c6f-2158-5426-ba95-48fc151e513e","vehicle_obligations":55000151945.38},{"uuid":"045b7843-cbf0-5b53-896b-090001fd3e07","vehicle_obligations":53557899297.46},{"uuid":"87b8f04a-a351-592b-acff-158228b8ef8e","vehicle_obligations":46132104694.13},{"uuid":"d0d2a5f7-8396-5b6b-8ccf-708a4e004271","vehicle_obligations":40688569449.2},{"uuid":"84a7bd7f-dca6-5fa8-ac45-a66fc8abfe39","vehicle_obligations":36297028262.71},{"uuid":"afe27373-bd31-52e7-b513-a8a46f770bdf","vehicle_obligations":31965503650.4},{"uuid":"16506c41-d5ac-5359-8223-3680e1dfbb1b","vehicle_obligations":30876687828.34}]}' + headers: + CF-RAY: + - 9f73b821b997ae89-JAX + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 06 May 2026 00:02:59 GMT + Nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + Report-To: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=JRSGPXnHWe%2BJHnFlM9CPNQmPYaiea8E9NxE7EGsxyeoBsplHQSA71Sh86DZN6aMt4S188K3XPesAlQO9qTVHHhTa79Me0D8NYFtbgE6brma68SxKyKV0EEYdE2SdNXt1Bra9G6woqYqD8Sbfd4aP"}]}' + Server: + - cloudflare + Transfer-Encoding: + - chunked + allow: + - GET, HEAD, OPTIONS + cf-cache-status: + - DYNAMIC + content-length: + - '1019' + cross-origin-opener-policy: + - same-origin + referrer-policy: + - same-origin + vary: + - Accept, Cookie + x-content-type-options: + - nosniff + x-execution-time: + - 0.141s + x-frame-options: + - DENY + x-ratelimit-burst-limit: + - '1000' + x-ratelimit-burst-remaining: + - '982' + x-ratelimit-burst-reset: + - '28' + x-ratelimit-daily-limit: + - '2000000' + x-ratelimit-daily-remaining: + - '1999982' + x-ratelimit-daily-reset: + - '86220' + x-ratelimit-limit: + - '1000' + x-ratelimit-remaining: + - '982' + x-ratelimit-reset: + - '28' + x-results-counttype: + - exact + status: + code: 200 + message: OK +version: 1 diff --git a/tests/integration/test_itdashboard_integration.py b/tests/integration/test_itdashboard_integration.py index b160564..cd4929a 100644 --- a/tests/integration/test_itdashboard_integration.py +++ b/tests/integration/test_itdashboard_integration.py @@ -62,9 +62,7 @@ def validate_investment_fields(investment, comprehensive: bool = False) -> None: ): value = _get(investment, field) if value is not None: - assert isinstance(value, str), ( - f"Investment '{field}' must be string, got {type(value)}" - ) + assert isinstance(value, str), f"Investment '{field}' must be string, got {type(value)}" updated_time = _get(investment, "updated_time") if updated_time is not None: @@ -94,9 +92,7 @@ class TestITDashboardIntegration: ("custom", "uii,agency_name,investment_title,updated_time"), ], ) - def test_list_itdashboard_investments_with_shapes( - self, tango_client, shape_name, shape_value - ): + def test_list_itdashboard_investments_with_shapes(self, tango_client, shape_name, shape_value): """Test listing investments with different shapes.""" kwargs: dict = {"limit": 5} if shape_value is not None: @@ -211,9 +207,7 @@ def test_filter_by_updated_time_range(self, tango_client): validate_no_parsing_errors(investment) updated = _get(investment, "updated_time") if updated is not None: - assert updated.year == 2026, ( - f"Expected updated_time in 2026, got {updated!r}" - ) + assert updated.year == 2026, f"Expected updated_time in 2026, got {updated!r}" # ------------------------------------------------------------------ # Business+ filters @@ -222,9 +216,7 @@ def test_filter_by_updated_time_range(self, tango_client): @handle_api_exceptions("itdashboard") def test_filter_by_agency_name_text(self, tango_client): """Business+: text search across agency name (icontains).""" - response = tango_client.list_itdashboard_investments( - limit=5, agency_name="defense" - ) + response = tango_client.list_itdashboard_investments(limit=5, agency_name="defense") validate_pagination(response) if response.results: @@ -260,9 +252,7 @@ def test_filter_by_cio_rating_max(self, tango_client): @handle_api_exceptions("itdashboard") def test_filter_by_performance_risk(self, tango_client): """Business+: investments with at least one NOT MET performance metric.""" - response = tango_client.list_itdashboard_investments( - limit=5, performance_risk=True - ) + response = tango_client.list_itdashboard_investments(limit=5, performance_risk=True) validate_pagination(response) for investment in response.results: validate_investment_fields(investment) @@ -288,9 +278,7 @@ def test_funding_and_cio_evaluation_expansions(self, tango_client): assert _get(investment, "uii") is not None funding = _get(investment, "funding") if funding is not None: - assert isinstance(funding, dict), ( - f"funding must be dict, got {type(funding)}" - ) + assert isinstance(funding, dict), f"funding must be dict, got {type(funding)}" cio_eval = _get(investment, "cio_evaluation") if cio_eval is not None: assert isinstance(cio_eval, list), ( diff --git a/tests/integration/test_vehicles_idvs_integration.py b/tests/integration/test_vehicles_idvs_integration.py index 6f7cada..2d7b435 100644 --- a/tests/integration/test_vehicles_idvs_integration.py +++ b/tests/integration/test_vehicles_idvs_integration.py @@ -21,6 +21,7 @@ TANGO_REFRESH_CASSETTES=true TANGO_API_KEY=xxx pytest tests/integration/test_vehicles_idvs_integration.py """ +import warnings from datetime import date from decimal import Decimal @@ -30,6 +31,13 @@ from tests.integration.validation import validate_no_parsing_errors, validate_pagination +def _field(obj, name): + """Read a field by name from a dict-or-attr-style shaped instance.""" + if isinstance(obj, dict): + return obj.get(name) + return getattr(obj, name, None) + + @pytest.mark.vcr() @pytest.mark.integration class TestVehiclesIntegration: @@ -83,6 +91,39 @@ def test_list_vehicles_uses_default_shape_and_search(self, tango_client): ) assert isinstance(solicitation_date, date), "solicitation_date should be date" + # Post-cutover (May 2026) lakehouse fields. All optional — only verify type when present. + is_synth = _field(vehicle, "is_synthetic_solicitation") + if is_synth is not None: + assert isinstance(is_synth, bool), "is_synthetic_solicitation should be bool" + + idv_count = _field(vehicle, "idv_count") + if idv_count is not None: + assert isinstance(idv_count, int), "idv_count should be int" + + total_obligated = _field(vehicle, "total_obligated") + if total_obligated is not None: + assert isinstance(total_obligated, Decimal), "total_obligated should be Decimal" + + latest_award_date = _field(vehicle, "latest_award_date") + if latest_award_date is not None: + assert isinstance(latest_award_date, date), "latest_award_date should be date" + + organization = _field(vehicle, "organization") + if organization is not None: + assert isinstance(organization, dict), "organization should be a dict" + allowed = { + "organization_id", + "office_code", + "office_name", + "agency_code", + "agency_name", + "department_code", + "department_name", + } + assert set(organization).issubset(allowed), ( + f"organization keys outside allowed set: {set(organization) - allowed}" + ) + @handle_api_exceptions("vehicles") def test_get_vehicle_supports_joiner_and_flat_lists(self, tango_client): """Test getting a single vehicle with joiner and flat_lists parameters @@ -104,10 +145,11 @@ def test_get_vehicle_supports_joiner_and_flat_lists(self, tango_client): ) assert vehicle_uuid is not None, "Vehicle UUID should be present" - # Test with flat, flat_lists, and joiner + # Test with flat, flat_lists, and joiner. Uses the post-cutover `organization` + # leaf field (the prior `opportunity(...)` expansion is now deprecated). vehicle = tango_client.get_vehicle( vehicle_uuid, - shape="uuid,opportunity(title)", + shape="uuid,organization", flat=True, flat_lists=True, joiner="__", @@ -118,15 +160,15 @@ def test_get_vehicle_supports_joiner_and_flat_lists(self, tango_client): "Vehicle uuid should be present" ) - # If opportunity is present, verify it's accessible - if vehicle.get("opportunity") is not None or ( - hasattr(vehicle, "opportunity") and vehicle.opportunity is not None - ): - opportunity = ( - vehicle.get("opportunity") if isinstance(vehicle, dict) else vehicle.opportunity - ) - if isinstance(opportunity, dict): - assert "title" in opportunity or hasattr(opportunity, "title") + # When flattened with joiner="__", organization fields surface as + # `organization__office_code` / `organization__office_name`. Assert no + # dotted keys leaked through (organization may be null on this UUID, in + # which case flattening produces no organization-prefixed keys at all). + keys = list(vehicle) if isinstance(vehicle, dict) else [] + org_keys = [k for k in keys if k.startswith("organization")] + assert all("." not in k for k in org_keys), ( + f"flattened keys should use joiner='__', not '.': {org_keys}" + ) @handle_api_exceptions("vehicles") def test_list_vehicle_awardees_uses_default_shape(self, tango_client): @@ -192,6 +234,109 @@ def test_list_vehicle_awardees_uses_default_shape(self, tango_client): if isinstance(recipient, dict): assert "display_name" in recipient or hasattr(recipient, "display_name") + @handle_api_exceptions("vehicles") + def test_list_vehicles_with_ordering(self, tango_client): + """`ordering=-vehicle_obligations` returns vehicles sorted descending.""" + response = tango_client.list_vehicles( + limit=10, + ordering="-vehicle_obligations", + shape="uuid,vehicle_obligations", + ) + validate_pagination(response) + + obligations = [ + v for v in (_field(r, "vehicle_obligations") for r in response.results) if v is not None + ] + if len(obligations) >= 2: + assert obligations == sorted(obligations, reverse=True), ( + f"Expected descending sort by vehicle_obligations, got {obligations}" + ) + + @handle_api_exceptions("vehicles") + def test_get_vehicle_with_metrics_expansion(self, tango_client): + """`metrics(*)` expansion returns the 12 lakehouse metric fields with correct types.""" + list_response = tango_client.list_vehicles(limit=1) + if not list_response.results: + pytest.skip("No vehicles available to test metrics expansion") + vehicle_uuid = _field(list_response.results[0], "uuid") + assert vehicle_uuid, "Vehicle UUID should be present" + + vehicle = tango_client.get_vehicle(vehicle_uuid, shape="uuid,metrics(*)") + validate_no_parsing_errors(vehicle) + + metrics = _field(vehicle, "metrics") + if metrics is None: + pytest.skip("Vehicle has no metrics row yet (lakehouse sync may be pending)") + + assert isinstance(metrics, dict), "metrics expansion should be a dict" + # Float-typed metrics + for fname in ( + "avg_offers_received", + "award_concentration_hhi", + "order_concentration_hhi", + "competed_rate", + "avg_order_value", + "max_order_value", + "top_recipient_share", + "recent_obligations_24mo", + "obligation_to_ceiling_ratio", + ): + value = metrics.get(fname) + if value is not None: + assert isinstance(value, float), f"{fname} should be float" + # Int-typed metrics + for fname in ("using_agency_count", "recent_orders_24mo", "days_since_last_order"): + value = metrics.get(fname) + if value is not None: + assert isinstance(value, int), f"{fname} should be int" + + @handle_api_exceptions("vehicles") + def test_list_vehicle_orders_uses_default_shape(self, tango_client): + """`/api/vehicles/{uuid}/orders/` returns task orders with the default shape applied.""" + list_response = tango_client.list_vehicles(limit=1) + if not list_response.results: + pytest.skip("No vehicles available to test list_vehicle_orders") + vehicle_uuid = _field(list_response.results[0], "uuid") + assert vehicle_uuid, "Vehicle UUID should be present" + + response = tango_client.list_vehicle_orders(vehicle_uuid, limit=10) + validate_pagination(response) + + if response.results: + order = response.results[0] + validate_no_parsing_errors(order) + assert _field(order, "key") is not None, "Order key should be present" + + award_date = _field(order, "award_date") + if award_date is not None: + assert isinstance(award_date, date), "award_date should be date" + + obligated = _field(order, "obligated") + if obligated is not None: + assert isinstance(obligated, Decimal), "obligated should be Decimal" + + def test_deprecated_shape_field_warns(self, tango_client): + """Explicitly requesting a deprecated shape field emits a DeprecationWarning.""" + # Pure unit-style assertion on the helper — no HTTP call needed, so no + # cassette / @vcr / @handle_api_exceptions decoration. Cheap to run. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + tango_client._warn_deprecated_vehicle_shape( + "uuid,solicitation_identifier,agency_details(*)" + ) + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert deprecations, "Expected a DeprecationWarning for `agency_details`" + message = str(deprecations[0].message) + assert "agency_details" in message + + # Sanity check: the default shape (no deprecated tokens) does NOT warn. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + tango_client._warn_deprecated_vehicle_shape("uuid,solicitation_identifier,metrics(*)") + assert not [w for w in caught if issubclass(w.category, DeprecationWarning)], ( + "Non-deprecated shape should not emit DeprecationWarning" + ) + @pytest.mark.vcr() @pytest.mark.integration diff --git a/tests/production/test_production_smoke.py b/tests/production/test_production_smoke.py index 15be48d..83ccce4 100644 --- a/tests/production/test_production_smoke.py +++ b/tests/production/test_production_smoke.py @@ -313,6 +313,25 @@ def test_get_vehicle(self, production_client): "Vehicle uuid should be present" ) + @handle_rate_limit + @handle_auth_error + def test_list_vehicle_orders_basic(self, production_client): + """Smoke test for `/api/vehicles/{uuid}/orders/` (post-cutover endpoint).""" + list_response = production_client.list_vehicles(limit=1) + if not list_response.results: + pytest.skip("No vehicles available to test list_vehicle_orders") + + vehicle_uuid = ( + list_response.results[0].get("uuid") + if isinstance(list_response.results[0], dict) + else list_response.results[0].uuid + ) + assert vehicle_uuid is not None, "Vehicle UUID should be present" + + response = production_client.list_vehicle_orders(vehicle_uuid, limit=5) + validate_pagination(response) + assert response.count >= 0, "Count should be non-negative" + # ============================================================================ # OTA Endpoints # ============================================================================ diff --git a/tests/test_client.py b/tests/test_client.py index 2bc9004..df948cf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -435,10 +435,7 @@ def test_get_itdashboard_investment_by_uii(self, mock_request): call_args = mock_request.call_args assert call_args[1]["url"].endswith("/api/itdashboard/021-000000001/") - assert ( - call_args[1]["params"]["shape"] - == ShapeConfig.ITDASHBOARD_INVESTMENTS_COMPREHENSIVE - ) + assert call_args[1]["params"]["shape"] == ShapeConfig.ITDASHBOARD_INVESTMENTS_COMPREHENSIVE assert investment["uii"] == "021-000000001" assert investment["agency_code"] == 21 @@ -459,9 +456,7 @@ def test_list_itdashboard_investments_funding_expansion(self, mock_request): "fy2024_internal_funding": "1000000.00", "fy2024_contribution": "50000.00", }, - "cio_evaluation": [ - {"cioRating": "3 - Medium Risk", "latestIndicator": "Y"} - ], + "cio_evaluation": [{"cioRating": "3 - Medium Risk", "latestIndicator": "Y"}], } ], } diff --git a/uv.lock b/uv.lock index 0edfd6b..7436a2e 100644 --- a/uv.lock +++ b/uv.lock @@ -1831,7 +1831,7 @@ wheels = [ [[package]] name = "tango-python" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From c9d5328c347bbe9bb39048ffee9200e4206909be Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Tue, 5 May 2026 19:19:11 -0500 Subject: [PATCH 02/14] docs: scrub internal-implementation language from public-facing files Replace references to the upstream "lakehouse" with neutral language ("computed metrics", "upstream sync") in CHANGELOG, API_REFERENCE, SHAPES, source comments, and test docstrings. Internal storage details (e.g. "denormalized lakehouse table") replaced with capability framing ("optimized for fast pagination"). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++-- docs/API_REFERENCE.md | 4 ++-- docs/SHAPES.md | 2 +- tango/shapes/explicit_schemas.py | 2 +- tests/integration/test_vehicles_idvs_integration.py | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f1209e..9c143b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Vehicles: new top-level fields `program_acronym`, `idv_count`, `total_obligated`, `is_synthetic_solicitation`, `latest_award_date`, `description`, `opportunity_id`, and a live nested `organization` object (office hierarchy) — reflecting the upstream tango lakehouse cutover (May 2026). -- Vehicles: new `metrics(*)` shape expansion bundling 12 lakehouse 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. +- Vehicles: new top-level fields `program_acronym`, `idv_count`, `total_obligated`, `is_synthetic_solicitation`, `latest_award_date`, `description`, `opportunity_id`, and a live nested `organization` object (awarding-org snapshot) — tracking the upstream tango vehicles surface. +- 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. - `ordering` parameter on `list_vehicles` (whitelist: `vehicle_obligations`, `latest_award_date`; prefix `-` for descending) and on `list_vehicle_orders` (whitelist: `award_date`, `obligated`, `total_contract_value`). - `ShapeConfig.VEHICLE_ORDERS_MINIMAL` default for the new orders endpoint. diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index e1eee2b..c4122b2 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -556,7 +556,7 @@ awardees = client.list_vehicle_awardees( ### list_vehicle_orders() -List task orders under a vehicle's IDVs (`/api/vehicles/{uuid}/orders/`). Backed by a denormalized lakehouse table for fast pagination over large vehicles. +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( @@ -602,7 +602,7 @@ The post-cutover (May 2026) vehicle response includes these top-level fields, al ### Vehicle shape expansions - `awardees(...)` — underlying IDV awards. Supports nested `orders(...)`. -- `metrics(*)` — bundled lakehouse 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`. +- `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 diff --git a/docs/SHAPES.md b/docs/SHAPES.md index 8e6cb01..2334170 100644 --- a/docs/SHAPES.md +++ b/docs/SHAPES.md @@ -59,7 +59,7 @@ grants = client.list_grants(shape=ShapeConfig.GRANTS_MINIMAL) **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 post-cutover (May 2026) vehicles surface bundles 12 lakehouse 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. +> **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 diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index 829928c..3efe9d1 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -838,7 +838,7 @@ } -# Vehicles expose a "metrics(...)" expansion bundling lakehouse-computed metrics. +# Vehicles expose a "metrics(...)" expansion bundling computed metrics. VEHICLE_METRICS_SCHEMA: dict[str, FieldSchema] = { "avg_offers_received": FieldSchema( name="avg_offers_received", type=float, is_optional=True, is_list=False diff --git a/tests/integration/test_vehicles_idvs_integration.py b/tests/integration/test_vehicles_idvs_integration.py index 2d7b435..5802bd5 100644 --- a/tests/integration/test_vehicles_idvs_integration.py +++ b/tests/integration/test_vehicles_idvs_integration.py @@ -91,7 +91,7 @@ def test_list_vehicles_uses_default_shape_and_search(self, tango_client): ) assert isinstance(solicitation_date, date), "solicitation_date should be date" - # Post-cutover (May 2026) lakehouse fields. All optional — only verify type when present. + # New vehicle fields. All optional — only verify type when present. is_synth = _field(vehicle, "is_synthetic_solicitation") if is_synth is not None: assert isinstance(is_synth, bool), "is_synthetic_solicitation should be bool" @@ -254,7 +254,7 @@ def test_list_vehicles_with_ordering(self, tango_client): @handle_api_exceptions("vehicles") def test_get_vehicle_with_metrics_expansion(self, tango_client): - """`metrics(*)` expansion returns the 12 lakehouse metric fields with correct types.""" + """`metrics(*)` expansion returns the 12 metric fields with correct types.""" list_response = tango_client.list_vehicles(limit=1) if not list_response.results: pytest.skip("No vehicles available to test metrics expansion") @@ -266,7 +266,7 @@ def test_get_vehicle_with_metrics_expansion(self, tango_client): metrics = _field(vehicle, "metrics") if metrics is None: - pytest.skip("Vehicle has no metrics row yet (lakehouse sync may be pending)") + pytest.skip("Vehicle has no metrics yet (upstream sync may be pending)") assert isinstance(metrics, dict), "metrics expansion should be a dict" # Float-typed metrics From 96b43d0896049e6390bbb7988bcff94936a43778 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 12:10:32 -0500 Subject: [PATCH 03/14] Add Vehicle filters/ordering and 4.2.x SDK catch-up list_vehicles now exposes 21 explicit filter parameters (vehicle_type, type_of_idc, contract_type, set_aside, 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) plus an ordering parameter, matching API v4.3.0. list_vehicle_awardees gains a search parameter for entity-aware full-text search across IDV and recipient fields. Schema additions catch the SDK up through API 4.2.x: new top-level Vehicle fields (is_synthetic_solicitation, program_acronym, idv_count, total_obligated, latest_award_date), a bundled metrics expansion (12 lakehouse rollups), an organization(*) expand on Vehicle/Forecast/Grant/ITDashboard/Protest returning the canonical 7-key office payload, and a vehicle(*) expand on Contract. VEHICLES_MINIMAL/COMPREHENSIVE defaults updated to surface the new fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 ++ tango/client.py | 79 +++++++++++++++- tango/models.py | 11 ++- tango/shapes/explicit_schemas.py | 153 ++++++++++++++++++++++++++----- 4 files changed, 224 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fbc51b..85af65c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Vehicles: `list_vehicles` gained 21 explicit filter parameters and `ordering` 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`, `ordering` (whitelist: `vehicle_obligations`, `latest_award_date`, `total_obligated`, `award_date`, `last_date_to_order`, `fiscal_year`, `idv_count`, `order_count`). +- Vehicles: `list_vehicle_awardees` gained a `search` parameter for entity-aware full-text search across IDV fields and recipient entity details (API 4.3.0). +- Vehicles: New top-level fields on `Vehicle` shape — `is_synthetic_solicitation`, `program_acronym`, `idv_count`, `total_obligated`, `latest_award_date` — plus a bundled `metrics` expansion (12 lakehouse rollups) on detail (API 4.2.1). +- 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`). +- Shaping: New `vehicle(*)` expand on `Contract` — request the parent vehicle inline from `/api/contracts/` (API 4.2.0). + +### Changed +- Default vehicle shapes (`ShapeConfig.VEHICLES_MINIMAL` / `VEHICLES_COMPREHENSIVE`) updated to surface the new top-level fields; comprehensive default now includes `organization(*)` and `metrics(*)`. + ## [0.5.0] - 2026-04-08 ### Added diff --git a/tango/client.py b/tango/client.py index 2d29c36..dc1fc1c 100644 --- a/tango/client.py +++ b/tango/client.py @@ -123,6 +123,7 @@ def last_response_headers(self) -> httpx.Headers | None: @staticmethod def _parse_rate_limit_headers(headers: httpx.Headers) -> RateLimitInfo: """Extract rate limit info from response headers.""" + def _int_or_none(val: str | None) -> int | None: if val is None: return None @@ -1456,8 +1457,40 @@ def list_vehicles( flat_lists: bool = False, joiner: str = ".", search: str | None = None, + vehicle_type: str | None = None, + type_of_idc: str | None = None, + contract_type: str | None = None, + set_aside: str | None = None, + who_can_use: str | None = None, + naics_code: int | None = None, + psc_code: str | None = None, + program_acronym: str | None = None, + agency: str | None = None, + organization_id: str | None = None, + total_obligated_min: float | int | Decimal | None = None, + total_obligated_max: float | int | Decimal | None = None, + idv_count_min: int | None = None, + idv_count_max: int | None = None, + order_count_min: int | None = None, + order_count_max: int | None = None, + fiscal_year: int | None = None, + award_date_after: str | date | datetime | None = None, + award_date_before: str | date | datetime | None = None, + last_date_to_order_after: str | date | datetime | None = None, + last_date_to_order_before: str | date | datetime | None = None, + ordering: str | None = None, ) -> PaginatedResponse: - """List Vehicles (solicitation-centric groupings of IDVs).""" + """List Vehicles (solicitation-centric groupings of IDVs). + + Multi-value filters (``vehicle_type``, ``type_of_idc``, ``contract_type``, + ``set_aside``) accept pipe-separated values for OR semantics, e.g. + ``vehicle_type="A|B|C"``. + + ``ordering`` accepts: ``vehicle_obligations``, ``latest_award_date``, + ``total_obligated``, ``award_date``, ``last_date_to_order``, + ``fiscal_year``, ``idv_count``, ``order_count``. Prefix with ``-`` for + descending. + """ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} if shape is None: @@ -1471,8 +1504,37 @@ def list_vehicles( if flat_lists: params["flat_lists"] = "true" - if search: - params["search"] = search + for k, val in ( + ("search", search), + ("vehicle_type", vehicle_type), + ("type_of_idc", type_of_idc), + ("contract_type", contract_type), + ("set_aside", set_aside), + ("who_can_use", who_can_use), + ("naics_code", naics_code), + ("psc_code", psc_code), + ("program_acronym", program_acronym), + ("agency", agency), + ("organization_id", organization_id), + ("total_obligated_min", total_obligated_min), + ("total_obligated_max", total_obligated_max), + ("idv_count_min", idv_count_min), + ("idv_count_max", idv_count_max), + ("order_count_min", order_count_min), + ("order_count_max", order_count_max), + ("fiscal_year", fiscal_year), + ("award_date_after", award_date_after), + ("award_date_before", award_date_before), + ("last_date_to_order_after", last_date_to_order_after), + ("last_date_to_order_before", last_date_to_order_before), + ("ordering", ordering), + ): + if val is None: + continue + if isinstance(val, (date, datetime)): + params[k] = val.isoformat() + else: + params[k] = val data = self._get("/api/vehicles/", params) @@ -1531,8 +1593,14 @@ def list_vehicle_awardees( flat: bool = False, flat_lists: bool = False, joiner: str = ".", + search: str | None = None, ) -> PaginatedResponse: - """List the IDV awardees for a Vehicle (`/api/vehicles/{uuid}/awardees/`).""" + """List the IDV awardees for a Vehicle (`/api/vehicles/{uuid}/awardees/`). + + ``search`` runs entity-aware full-text search across IDV fields + (PIID, key, solicitation_identifier, NAICS, PSC, idv_type, + fiscal_year) and recipient entity details (name, address). + """ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} if shape is None: @@ -1546,6 +1614,9 @@ def list_vehicle_awardees( if flat_lists: params["flat_lists"] = "true" + if search: + params["search"] = search + data = self._get(f"/api/vehicles/{uuid}/awardees/", params) results = [ diff --git a/tango/models.py b/tango/models.py index 3cab2c0..26d508c 100644 --- a/tango/models.py +++ b/tango/models.py @@ -690,16 +690,19 @@ class ShapeConfig: # Default for list_vehicles() VEHICLES_MINIMAL: Final = ( - "uuid,solicitation_identifier,organization_id,awardee_count,order_count," + "uuid,solicitation_identifier,is_synthetic_solicitation,program_acronym," + "organization_id,idv_count,awardee_count,order_count,total_obligated," "vehicle_obligations,vehicle_contracts_value,solicitation_title,solicitation_date" ) # Default for get_vehicle() VEHICLES_COMPREHENSIVE: Final = ( - "uuid,solicitation_identifier,agency_id,organization_id,vehicle_type,who_can_use," + "uuid,solicitation_identifier,is_synthetic_solicitation,program_acronym," + "agency_id,organization_id,vehicle_type,who_can_use," "solicitation_title,solicitation_description,solicitation_date,naics_code,psc_code,set_aside," - "fiscal_year,award_date,last_date_to_order,awardee_count,order_count,vehicle_obligations,vehicle_contracts_value," - "type_of_idc,contract_type,competition_details(*)" + "fiscal_year,award_date,last_date_to_order,idv_count,awardee_count,order_count," + "total_obligated,vehicle_obligations,vehicle_contracts_value," + "type_of_idc,contract_type,competition_details(*),organization(*),metrics(*)" ) # Default for list_vehicle_awardees() diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index 3833cb6..5457808 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -37,6 +37,25 @@ ), } +# Canonical 7-key office payload returned by the `organization(...)` shape +# expand on awards, vehicles, forecasts, grants, IT Dashboard, and protests. +# Resolved deterministically from the resource's organization_id. +ORGANIZATION_OFFICE_SCHEMA: dict[str, FieldSchema] = { + "organization_id": FieldSchema( + name="organization_id", type=str, is_optional=True, is_list=False + ), + "office_code": FieldSchema(name="office_code", type=str, is_optional=True, is_list=False), + "office_name": FieldSchema(name="office_name", type=str, is_optional=True, is_list=False), + "agency_code": FieldSchema(name="agency_code", type=str, is_optional=True, is_list=False), + "agency_name": FieldSchema(name="agency_name", type=str, is_optional=True, is_list=False), + "department_code": FieldSchema( + name="department_code", type=str, is_optional=True, is_list=False + ), + "department_name": FieldSchema( + name="department_name", type=str, is_optional=True, is_list=False + ), +} + PERIOD_OF_PERFORMANCE_IDV_SCHEMA: dict[str, FieldSchema] = { "start_date": FieldSchema(name="start_date", type=date, is_optional=True, is_list=False), "last_date_to_order": FieldSchema( @@ -396,6 +415,13 @@ "undefinitized_action": FieldSchema( name="undefinitized_action", type=str, is_optional=True, is_list=False ), + "vehicle": FieldSchema( + name="vehicle", + type=dict, + is_optional=True, + is_list=False, + nested_model="Vehicle", + ), } @@ -556,6 +582,13 @@ "source_system": FieldSchema(name="source_system", type=str, is_optional=False, is_list=False), "status": FieldSchema(name="status", type=str, is_optional=True, is_list=False), "title": FieldSchema(name="title", type=str, is_optional=False, is_list=False), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="OrganizationOffice", + ), } @@ -685,6 +718,13 @@ "dockets": FieldSchema( name="dockets", type=dict, is_optional=True, is_list=True, nested_model="ProtestDocket" ), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="OrganizationOffice", + ), } @@ -785,6 +825,13 @@ is_list=True, nested_model="GrantAttachment", ), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="OrganizationOffice", + ), } @@ -965,11 +1012,59 @@ } +# Vehicle detail's bundled `metrics` object (12 lakehouse rollups from +# awards_vehicle_stats). All fields nullable because the stats companion +# is sparse during the bootstrap window between migrate and the first +# sync_vehicle_stats run. +VEHICLE_METRICS_SCHEMA: dict[str, FieldSchema] = { + "avg_offers_received": FieldSchema( + name="avg_offers_received", type=float, is_optional=True, is_list=False + ), + "award_concentration_hhi": FieldSchema( + name="award_concentration_hhi", type=float, is_optional=True, is_list=False + ), + "order_concentration_hhi": FieldSchema( + name="order_concentration_hhi", type=float, is_optional=True, is_list=False + ), + "competed_rate": FieldSchema(name="competed_rate", type=float, is_optional=True, is_list=False), + "using_agency_count": FieldSchema( + name="using_agency_count", type=int, is_optional=True, is_list=False + ), + "avg_order_value": FieldSchema( + name="avg_order_value", type=float, is_optional=True, is_list=False + ), + "max_order_value": FieldSchema( + name="max_order_value", type=float, is_optional=True, is_list=False + ), + "top_recipient_share": FieldSchema( + name="top_recipient_share", type=float, is_optional=True, is_list=False + ), + "recent_obligations_24mo": FieldSchema( + name="recent_obligations_24mo", type=float, is_optional=True, is_list=False + ), + "recent_orders_24mo": FieldSchema( + name="recent_orders_24mo", type=int, is_optional=True, is_list=False + ), + "days_since_last_order": FieldSchema( + name="days_since_last_order", type=int, is_optional=True, is_list=False + ), + "obligation_to_ceiling_ratio": FieldSchema( + name="obligation_to_ceiling_ratio", type=float, is_optional=True, is_list=False + ), +} + + VEHICLE_SCHEMA: dict[str, FieldSchema] = { "uuid": FieldSchema(name="uuid", type=str, is_optional=False, is_list=False), "solicitation_identifier": FieldSchema( name="solicitation_identifier", type=str, is_optional=False, is_list=False ), + "is_synthetic_solicitation": FieldSchema( + name="is_synthetic_solicitation", type=bool, is_optional=True, is_list=False + ), + "program_acronym": FieldSchema( + name="program_acronym", type=str, is_optional=True, is_list=False + ), "agency_id": FieldSchema(name="agency_id", type=str, is_optional=False, is_list=False), "organization_id": FieldSchema( name="organization_id", type=str, is_optional=True, is_list=False @@ -985,11 +1080,18 @@ "descriptions": FieldSchema(name="descriptions", type=str, is_optional=True, is_list=True), "fiscal_year": FieldSchema(name="fiscal_year", type=int, is_optional=True, is_list=False), "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False), + "latest_award_date": FieldSchema( + name="latest_award_date", type=date, is_optional=True, is_list=False + ), "last_date_to_order": FieldSchema( name="last_date_to_order", type=date, is_optional=True, is_list=False ), "awardee_count": FieldSchema(name="awardee_count", type=int, is_optional=True, is_list=False), + "idv_count": FieldSchema(name="idv_count", type=int, is_optional=True, is_list=False), "order_count": FieldSchema(name="order_count", type=int, is_optional=True, is_list=False), + "total_obligated": FieldSchema( + name="total_obligated", type=Decimal, is_optional=True, is_list=False + ), "vehicle_obligations": FieldSchema( name="vehicle_obligations", type=Decimal, is_optional=True, is_list=False ), @@ -1016,6 +1118,20 @@ "opportunity": FieldSchema( name="opportunity", type=dict, is_optional=True, is_list=False, nested_model="Opportunity" ), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="OrganizationOffice", + ), + "metrics": FieldSchema( + name="metrics", + type=dict, + is_optional=True, + is_list=False, + nested_model="VehicleMetrics", + ), "competition_details": FieldSchema( name="competition_details", type=dict, @@ -1135,18 +1251,10 @@ # IT Dashboard Investment ITDASHBOARD_INVESTMENT_SCHEMA: dict[str, FieldSchema] = { "uii": FieldSchema(name="uii", type=str, is_optional=False, is_list=False), - "agency_code": FieldSchema( - name="agency_code", type=int, is_optional=True, is_list=False - ), - "agency_name": FieldSchema( - name="agency_name", type=str, is_optional=True, is_list=False - ), - "bureau_code": FieldSchema( - name="bureau_code", type=int, is_optional=True, is_list=False - ), - "bureau_name": FieldSchema( - name="bureau_name", type=str, is_optional=True, is_list=False - ), + "agency_code": FieldSchema(name="agency_code", type=int, is_optional=True, is_list=False), + "agency_name": FieldSchema(name="agency_name", type=str, is_optional=True, is_list=False), + "bureau_code": FieldSchema(name="bureau_code", type=int, is_optional=True, is_list=False), + "bureau_name": FieldSchema(name="bureau_name", type=str, is_optional=True, is_list=False), "investment_title": FieldSchema( name="investment_title", type=str, is_optional=True, is_list=False ), @@ -1167,15 +1275,9 @@ # Modeled as opaque dict/list since their inner shapes are dynamic. "funding": FieldSchema(name="funding", type=dict, is_optional=True, is_list=False), "details": FieldSchema(name="details", type=dict, is_optional=True, is_list=False), - "cio_evaluation": FieldSchema( - name="cio_evaluation", type=list, is_optional=True, is_list=True - ), - "contracts": FieldSchema( - name="contracts", type=list, is_optional=True, is_list=True - ), - "projects": FieldSchema( - name="projects", type=list, is_optional=True, is_list=True - ), + "cio_evaluation": FieldSchema(name="cio_evaluation", type=list, is_optional=True, is_list=True), + "contracts": FieldSchema(name="contracts", type=list, is_optional=True, is_list=True), + "projects": FieldSchema(name="projects", type=list, is_optional=True, is_list=True), "cost_pools_towers": FieldSchema( name="cost_pools_towers", type=list, is_optional=True, is_list=True ), @@ -1191,6 +1293,13 @@ "operational_analysis": FieldSchema( name="operational_analysis", type=list, is_optional=True, is_list=True ), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="OrganizationOffice", + ), } # ============================================================================ @@ -1200,6 +1309,7 @@ EXPLICIT_SCHEMAS: dict[str, dict[str, FieldSchema]] = { "Office": OFFICE_SCHEMA, "AwardOffice": AWARD_OFFICE_SCHEMA, + "OrganizationOffice": ORGANIZATION_OFFICE_SCHEMA, "Location": LOCATION_SCHEMA, "PlaceOfPerformance": PLACE_OF_PERFORMANCE_SCHEMA, "Competition": COMPETITION_SCHEMA, @@ -1225,6 +1335,7 @@ "Vehicle": VEHICLE_SCHEMA, "IDV": IDV_SCHEMA, "VehicleCompetitionDetails": VEHICLE_COMPETITION_DETAILS_SCHEMA, + "VehicleMetrics": VEHICLE_METRICS_SCHEMA, # Nested schemas for Grant fields "CFDANumber": CFDA_NUMBER_SCHEMA, "CodeDescription": CODE_DESCRIPTION_SCHEMA, From 1e049bd6807842321c81e91ab20cbee05bbad2dd Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 12:22:53 -0500 Subject: [PATCH 04/14] Add webhooks signing helpers, local receiver, and tango CLI Tier 1+2 of the Stripe-CLI-style webhook DX migration from tango/tools/ webhook_lab into the SDK. - tango.webhooks: HMAC-SHA256 signing helpers (verify_signature, generate_signature, parse_signature_header). Pure stdlib; importable from a default install. - WebhookReceiver: stdlib-based local listener with optional forwarding, delivery history, and on_delivery callback. Usable as a context manager inside integration tests. - simulate.deliver: offline sign+POST helper for driving a receiver without provisioning a real subscription. - New tango[webhooks] extra installs click and a tango console script with `webhooks listen|trigger|simulate` subcommands. - Top-level re-exports: WebhookReceiver, Delivery, verify_signature, generate_signature, parse_signature_header. The script name `tango` is flagged in CHANGELOG as revisitable before release if it conflicts with sibling tooling (e.g. tango-scripts). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 ++ pyproject.toml | 10 ++ tango/__init__.py | 11 ++ tango/webhooks/__init__.py | 24 ++++ tango/webhooks/cli.py | 241 ++++++++++++++++++++++++++++++++ tango/webhooks/receiver.py | 227 ++++++++++++++++++++++++++++++ tango/webhooks/signing.py | 50 +++++++ tango/webhooks/simulate.py | 79 +++++++++++ tests/test_webhooks_cli.py | 93 ++++++++++++ tests/test_webhooks_receiver.py | 121 ++++++++++++++++ tests/test_webhooks_signing.py | 73 ++++++++++ tests/test_webhooks_simulate.py | 43 ++++++ uv.lock | 18 ++- 13 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 tango/webhooks/__init__.py create mode 100644 tango/webhooks/cli.py create mode 100644 tango/webhooks/receiver.py create mode 100644 tango/webhooks/signing.py create mode 100644 tango/webhooks/simulate.py create mode 100644 tests/test_webhooks_cli.py create mode 100644 tests/test_webhooks_receiver.py create mode 100644 tests/test_webhooks_signing.py create mode 100644 tests/test_webhooks_simulate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fbc51b..3490944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `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 with `webhooks listen|trigger|simulate` subcommands. Stripe-CLI-style developer ergonomics for testing webhook integrations. + +### Notes +- Console script name `tango` may be revisited before the next release if it conflicts with sibling tooling (`tango-scripts` reuses the bare name). + ## [0.5.0] - 2026-04-08 ### Added diff --git a/pyproject.toml b/pyproject.toml index 640b5c2..e99dd68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,12 @@ notebooks = [ "jupyter>=1.0.0", "ipykernel>=6.25.0", ] +webhooks = [ + "click>=8.1", +] + +[project.scripts] +tango = "tango.webhooks.cli:main" [project.urls] Homepage = "https://github.com/makegov/tango-python" @@ -116,6 +122,10 @@ exclude_lines = [ [tool.hatch.build.targets.wheel] packages = ["tango"] +[[tool.mypy.overrides]] +module = "tango.webhooks.cli" +disallow_untyped_decorators = false + [dependency-groups] dev = [ "python-dotenv>=1.2.1", diff --git a/tango/__init__.py b/tango/__init__.py index ff0d6f5..60ecd49 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -28,6 +28,12 @@ ShapeParser, TypeGenerator, ) +from .webhooks import ( + generate_signature, + parse_signature_header, + verify_signature, +) +from .webhooks.receiver import Delivery, WebhookReceiver __version__ = "0.5.0" __all__ = [ @@ -53,4 +59,9 @@ "ModelFactory", "TypeGenerator", "SchemaRegistry", + "Delivery", + "WebhookReceiver", + "generate_signature", + "parse_signature_header", + "verify_signature", ] diff --git a/tango/webhooks/__init__.py b/tango/webhooks/__init__.py new file mode 100644 index 0000000..2af3853 --- /dev/null +++ b/tango/webhooks/__init__.py @@ -0,0 +1,24 @@ +"""Tango webhooks: signature helpers and developer tooling. + +The signing helpers (:func:`verify_signature`, :func:`generate_signature`, +:func:`parse_signature_header`) are pure stdlib and importable from a default +``pip install tango``. The CLI (``tango webhooks ...``) and the in-process +:class:`~tango.webhooks.receiver.WebhookReceiver` ship with the +``tango[webhooks]`` extra. +""" + +from tango.webhooks.signing import ( + SIGNATURE_HEADER, + SIGNATURE_PREFIX, + generate_signature, + parse_signature_header, + verify_signature, +) + +__all__ = [ + "SIGNATURE_HEADER", + "SIGNATURE_PREFIX", + "generate_signature", + "parse_signature_header", + "verify_signature", +] diff --git a/tango/webhooks/cli.py b/tango/webhooks/cli.py new file mode 100644 index 0000000..0f5f8b3 --- /dev/null +++ b/tango/webhooks/cli.py @@ -0,0 +1,241 @@ +"""Command-line interface for Tango webhook tooling. + +This module is the entry point for the ``tango`` console script. Click is an +optional dependency installed via the ``tango[webhooks]`` extra; importing +this module without it raises a friendly error that points users at the +right install command. +""" + +from __future__ import annotations + +import json +import sys +import threading +from pathlib import Path +from typing import Any + +try: + import click +except ImportError as _import_error: # pragma: no cover - tested via subprocess + sys.stderr.write( + "tango CLI requires the 'webhooks' extra. Install it with:\n" + " pip install 'tango-python[webhooks]'\n" + ) + raise SystemExit(1) from _import_error + +from tango.webhooks import simulate +from tango.webhooks.receiver import Delivery, WebhookReceiver +from tango.webhooks.signing import SIGNATURE_HEADER + + +@click.group() +@click.version_option(package_name="tango-python", prog_name="tango") +def main() -> None: + """Tango developer tooling.""" + + +@main.group() +def webhooks() -> None: + """Receive, trigger, and simulate Tango webhook deliveries.""" + + +@webhooks.command("listen") +@click.option("--port", type=int, default=8011, show_default=True, help="TCP port to bind.") +@click.option("--host", default="127.0.0.1", show_default=True, help="Bind address.") +@click.option( + "--path", + default="/tango/webhooks", + show_default=True, + help="URL path to accept deliveries on.", +) +@click.option( + "--secret", + envvar="TANGO_WEBHOOK_SECRET", + default="", + help="Shared secret. Reads TANGO_WEBHOOK_SECRET if unset. " + "If empty, deliveries are accepted without signature verification.", +) +@click.option( + "--forward-to", + default=None, + help="Optional URL to mirror each delivery to (preserves body and signature).", +) +@click.option( + "--require-signature/--allow-unsigned", + default=None, + help="Override default policy. Default: require when --secret is set.", +) +def listen_cmd( + port: int, + host: str, + path: str, + secret: str, + forward_to: str | None, + require_signature: bool | None, +) -> None: + """Run a local receiver and stream deliveries to stdout.""" + receiver = WebhookReceiver( + secret=secret, + path=path, + host=host, + port=port, + forward_to=forward_to, + require_signature=require_signature, + on_delivery=_print_delivery, + ) + receiver.start() + try: + click.echo(f"Listening on {receiver.url}") + if not secret: + click.echo( + " WARNING: no --secret provided; signatures will not be verified.", + err=True, + ) + if forward_to: + click.echo(f" Forwarding to {forward_to}") + click.echo(" Press Ctrl+C to stop.") + threading.Event().wait() # block until interrupted + except KeyboardInterrupt: + click.echo("\nStopping...") + finally: + receiver.stop() + + +@webhooks.command("trigger") +@click.option( + "--endpoint-id", + default=None, + help="Endpoint UUID. If omitted, the server's default endpoint is used.", +) +@click.option( + "--api-key", + envvar="TANGO_API_KEY", + help="Tango API key (or set TANGO_API_KEY).", +) +@click.option( + "--base-url", + envvar="TANGO_BASE_URL", + default="https://tango.makegov.com", + show_default=True, + help="Tango base URL (or set TANGO_BASE_URL).", +) +def trigger_cmd(endpoint_id: str | None, api_key: str | None, base_url: str) -> None: + """Ask Tango to send a real test delivery to your configured endpoint.""" + from tango import TangoClient + + client = TangoClient(api_key=api_key, base_url=base_url) + result = client.test_webhook_delivery(endpoint_id=endpoint_id) + click.echo( + json.dumps( + { + "success": result.success, + "status_code": result.status_code, + "response_time_ms": result.response_time_ms, + "endpoint_url": result.endpoint_url, + "message": result.message, + "error": result.error, + }, + indent=2, + ) + ) + if not result.success: + raise SystemExit(1) + + +@webhooks.command("simulate") +@click.option("--to", "target_url", required=True, help="Receiver URL to POST to.") +@click.option( + "--secret", + envvar="TANGO_WEBHOOK_SECRET", + required=True, + help="Shared secret used to sign the payload (or TANGO_WEBHOOK_SECRET).", +) +@click.option( + "--payload-file", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + default=None, + help="Path to a JSON file with the body to send. Mutually exclusive with --event-type.", +) +@click.option( + "--event-type", + default=None, + help="Fetch a canonical sample for this event type from Tango and send that.", +) +@click.option( + "--api-key", + envvar="TANGO_API_KEY", + help="Tango API key, only needed with --event-type (or TANGO_API_KEY).", +) +@click.option( + "--base-url", + envvar="TANGO_BASE_URL", + default="https://tango.makegov.com", + show_default=True, + help="Tango base URL, only needed with --event-type.", +) +def simulate_cmd( + target_url: str, + secret: str, + payload_file: Path | None, + event_type: str | None, + api_key: str | None, + base_url: str, +) -> None: + """Sign a payload locally and POST it to a receiver. No Tango call unless --event-type.""" + if payload_file and event_type: + raise click.UsageError("Use either --payload-file or --event-type, not both.") + + payload: dict[str, Any] | list[Any] + if payload_file: + payload = json.loads(payload_file.read_text(encoding="utf-8")) + elif event_type: + from tango import TangoClient + + 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": []}]} + + result = simulate.deliver(target_url=target_url, payload=payload, secret=secret) + click.echo( + json.dumps( + { + "status_code": result.status_code, + "signature": f"sha256={result.signature}", + "sent_bytes": len(result.sent_bytes), + "response_body": result.response_body[:500], + }, + indent=2, + ) + ) + if result.status_code >= 400: + raise SystemExit(1) + + +def _print_delivery(delivery: Delivery) -> None: + """Default ``listen`` callback: write a one-line summary plus body.""" + summary = _summarize(delivery.body_json) + status = "verified" if delivery.verified else "UNVERIFIED" + parts = [delivery.received_at, status, summary] + if delivery.forward_status is not None: + parts.append(f"forwarded={delivery.forward_status}") + if delivery.forward_error: + parts.append(f"forward_error={delivery.forward_error}") + click.echo(" | ".join(parts)) + if delivery.body_json is not None: + click.echo(json.dumps(delivery.body_json, indent=2, sort_keys=True)) + click.echo("") + + +def _summarize(body: Any) -> str: + if isinstance(body, dict): + events = body.get("events") + if isinstance(events, list) and events and isinstance(events[0], dict): + event_type = events[0].get("event_type", "?") + count = len(events) + return f"{event_type} (n={count})" + return "(no events)" + + +# Make ``X-Tango-Signature`` accessible from this module for IDE completion. +__all__ = ["main", "SIGNATURE_HEADER"] diff --git a/tango/webhooks/receiver.py b/tango/webhooks/receiver.py new file mode 100644 index 0000000..ab51e02 --- /dev/null +++ b/tango/webhooks/receiver.py @@ -0,0 +1,227 @@ +"""Local webhook receiver for development and integration testing. + +A small stdlib-based HTTP server that accepts Tango-style POSTs, verifies +the ``X-Tango-Signature`` header against a shared secret, optionally +forwards the request to a downstream URL (e.g. your real handler running on +another port), and records each delivery in memory for later inspection. + +Typical use from the CLI:: + + tango webhooks listen --port 8011 --secret $TANGO_WEBHOOK_SECRET \\ + --forward-to http://localhost:4242/webhooks + +Or programmatically inside an integration test:: + + from tango.webhooks import WebhookReceiver + + with WebhookReceiver(secret="dev_secret").run() as rx: + # ... cause a webhook to fire at rx.url ... + deliveries = rx.deliveries +""" + +from __future__ import annotations + +import contextlib +import json +import threading +from collections import deque +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from datetime import UTC, datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +from tango.webhooks.signing import SIGNATURE_HEADER, verify_signature + +DEFAULT_PATH = "/tango/webhooks" +DEFAULT_MAX_HISTORY = 256 + + +@dataclass +class Delivery: + """A recorded webhook delivery.""" + + received_at: str + path: str + signature_header: str | None + body_bytes: bytes + body_json: Any + verified: bool + remote_addr: str | None = None + forward_status: int | None = None + forward_error: str | None = None + + +@dataclass +class WebhookReceiver: + """A configurable local receiver for Tango webhook deliveries. + + Args: + secret: Shared secret. If empty, signatures are not verified and + every delivery is recorded with ``verified=False`` — useful for + inspecting payloads without a configured endpoint. + path: URL path to accept deliveries on. Defaults to ``/tango/webhooks``. + host: Bind address. Defaults to ``127.0.0.1`` (loopback only). + port: TCP port. ``0`` lets the OS choose a free port. + forward_to: Optional URL to mirror each delivery to, preserving body + bytes and the signature header. + max_history: Cap on the in-memory ``deliveries`` deque. + on_delivery: Optional callback invoked for each recorded delivery. + require_signature: If True (the default when a secret is set), + unsigned or invalid deliveries get a 401 response. + """ + + secret: str = "" + path: str = DEFAULT_PATH + host: str = "127.0.0.1" + port: int = 0 + forward_to: str | None = None + max_history: int = DEFAULT_MAX_HISTORY + on_delivery: Callable[[Delivery], None] | None = None + require_signature: bool | None = None + + _server: ThreadingHTTPServer | None = field(default=None, init=False, repr=False) + _thread: threading.Thread | None = field(default=None, init=False, repr=False) + _deliveries: deque[Delivery] = field(default_factory=deque, init=False, repr=False) + + @property + def deliveries(self) -> list[Delivery]: + """Snapshot of recorded deliveries, oldest first.""" + return list(self._deliveries) + + @property + def url(self) -> str: + """Full URL the receiver is bound to (only valid while running).""" + if self._server is None: + raise RuntimeError("Receiver is not running") + host_addr, port = self._server.server_address[:2] + host = host_addr.decode() if isinstance(host_addr, bytes) else str(host_addr) + return f"http://{host}:{port}{self.path}" + + def start(self) -> None: + """Bind the socket and start serving in a background thread.""" + if self._server is not None: + raise RuntimeError("Receiver already started") + receiver = self + deliveries = self._deliveries + max_history = self.max_history + + class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + # Suppress stderr access logging; users see deliveries through + # the on_delivery callback or the deliveries list instead. + return + + def do_POST(self) -> None: # noqa: N802 (stdlib API) + if self.path != receiver.path: + self.send_error(404, "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 + ) + + require = ( + receiver.require_signature + if receiver.require_signature is not None + else bool(receiver.secret) + ) + if require and not verified: + self._record(body, signature, verified=False) + self.send_error(401, "Invalid signature") + return + + forward_status: int | None = None + forward_error: str | None = None + if receiver.forward_to: + forward_status, forward_error = _forward(receiver.forward_to, body, signature) + + self._record( + body, + signature, + verified=verified, + forward_status=forward_status, + forward_error=forward_error, + ) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"ok": true}') + + def _record( + self, + body: bytes, + signature: str | None, + *, + verified: bool, + forward_status: int | None = None, + forward_error: str | None = None, + ) -> None: + try: + parsed: Any = json.loads(body.decode("utf-8")) if body else None + except (UnicodeDecodeError, json.JSONDecodeError): + parsed = None + delivery = Delivery( + received_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"), + path=self.path, + signature_header=signature, + body_bytes=body, + body_json=parsed, + verified=verified, + remote_addr=self.client_address[0] if self.client_address else None, + forward_status=forward_status, + forward_error=forward_error, + ) + while len(deliveries) >= max_history: + deliveries.popleft() + deliveries.append(delivery) + if receiver.on_delivery is not None: + receiver.on_delivery(delivery) + + self._server = ThreadingHTTPServer((self.host, self.port), Handler) + self._thread = threading.Thread( + target=self._server.serve_forever, + name="tango-webhook-receiver", + daemon=True, + ) + self._thread.start() + + def stop(self) -> None: + """Stop the server and join the background thread.""" + if self._server is None: + return + self._server.shutdown() + self._server.server_close() + if self._thread is not None: + self._thread.join(timeout=5) + self._server = None + self._thread = None + + @contextlib.contextmanager + def run(self) -> Iterator[WebhookReceiver]: + """Context manager that starts the receiver and stops it on exit.""" + self.start() + try: + yield self + finally: + self.stop() + + +def _forward(url: str, body: bytes, signature: str | None) -> tuple[int | None, str | None]: + """POST ``body`` to ``url`` preserving the signature header. + + Returns ``(status, error_message)``. httpx is imported lazily so unit + tests that don't exercise forwarding don't pay the import cost. + """ + import httpx + + headers = {"Content-Type": "application/json"} + if signature: + headers[SIGNATURE_HEADER] = signature + try: + resp = httpx.post(url, content=body, headers=headers, timeout=10.0) + except httpx.HTTPError as exc: + return None, str(exc) + return resp.status_code, None diff --git a/tango/webhooks/signing.py b/tango/webhooks/signing.py new file mode 100644 index 0000000..37f9338 --- /dev/null +++ b/tango/webhooks/signing.py @@ -0,0 +1,50 @@ +"""HMAC-SHA256 signing for Tango webhook deliveries. + +Tango signs each delivery with:: + + X-Tango-Signature: sha256= + +These helpers mirror the canonical implementation in the tango server +(``webhooks/utils.py``). Verifiers must operate on the **raw request body +bytes** — re-serializing parsed JSON will produce a different signature. +""" + +from __future__ import annotations + +import hashlib +import hmac + +SIGNATURE_HEADER = "X-Tango-Signature" +SIGNATURE_PREFIX = "sha256=" + + +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() + + +def parse_signature_header(value: str | None) -> str | None: + """Strip the ``sha256=`` prefix from a header value; return ``None`` if empty. + + Accepts the bare hex form too, for forward compatibility. + """ + if not value: + return None + stripped = value.strip() + if stripped.startswith(SIGNATURE_PREFIX): + return stripped[len(SIGNATURE_PREFIX) :] + return stripped + + +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). + """ + received = parse_signature_header(signature_header) + if not received: + return False + expected = generate_signature(body, secret) + return hmac.compare_digest(expected, received) diff --git a/tango/webhooks/simulate.py b/tango/webhooks/simulate.py new file mode 100644 index 0000000..3b97eb8 --- /dev/null +++ b/tango/webhooks/simulate.py @@ -0,0 +1,79 @@ +"""Locally sign and POST a webhook payload to a URL. + +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 +fuzz event shapes that Tango wouldn't naturally emit. + +Example:: + + from tango.webhooks import simulate + + result = simulate.deliver( + target_url="http://localhost:4242/webhooks", + payload={"events": [{"event_type": "entities.updated", "uei": "ABC123"}]}, + secret="dev_secret", + ) + assert result.status_code == 200 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from tango.webhooks.signing import SIGNATURE_HEADER, SIGNATURE_PREFIX, generate_signature + + +@dataclass(frozen=True) +class SimulationResult: + """Outcome of a simulated delivery.""" + + status_code: int + response_body: str + signature: str + sent_bytes: bytes + + +def deliver( + *, + target_url: str, + payload: dict[str, Any] | list[Any] | bytes | str, + secret: str, + extra_headers: dict[str, str] | None = None, + timeout: float = 10.0, +) -> SimulationResult: + """Sign ``payload`` with ``secret`` and POST it to ``target_url``. + + ``payload`` may be a ``dict``/``list`` (serialized via :func:`json.dumps` + with ``sort_keys=True`` to keep signatures reproducible across runs), + a pre-serialized ``str``, or raw ``bytes``. Signing is computed over the + exact bytes that go on the wire. + """ + import httpx + + body = _to_bytes(payload) + signature_hex = generate_signature(body, secret) + headers = { + "Content-Type": "application/json", + SIGNATURE_HEADER: f"{SIGNATURE_PREFIX}{signature_hex}", + } + if extra_headers: + headers.update(extra_headers) + + resp = httpx.post(target_url, content=body, headers=headers, timeout=timeout) + return SimulationResult( + status_code=resp.status_code, + response_body=resp.text, + signature=signature_hex, + sent_bytes=body, + ) + + +def _to_bytes(payload: dict[str, Any] | list[Any] | bytes | str) -> bytes: + if isinstance(payload, bytes): + return payload + if isinstance(payload, str): + return payload.encode("utf-8") + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") diff --git a/tests/test_webhooks_cli.py b/tests/test_webhooks_cli.py new file mode 100644 index 0000000..fd174d9 --- /dev/null +++ b/tests/test_webhooks_cli.py @@ -0,0 +1,93 @@ +"""Smoke tests for the `tango webhooks` CLI.""" + +from __future__ import annotations + +import json + +from click.testing import CliRunner + +from tango.webhooks.cli import main +from tango.webhooks.receiver import WebhookReceiver + + +def test_cli_help() -> None: + runner = CliRunner() + result = runner.invoke(main, ["webhooks", "--help"]) + assert result.exit_code == 0 + assert "listen" in result.output + assert "trigger" in result.output + assert "simulate" in result.output + + +def test_cli_simulate_signs_and_posts(tmp_path: object) -> None: + runner = CliRunner() + secret = "cli-secret" + payload = {"events": [{"event_type": "cli.smoke"}]} + with WebhookReceiver(secret=secret).run() as rx: + result = runner.invoke( + main, + [ + "webhooks", + "simulate", + "--to", + rx.url, + "--secret", + secret, + ], + input=json.dumps(payload), # ignored by current command, harmless + ) + assert result.exit_code == 0, result.output + # The default body is the built-in placeholder envelope. + assert len(rx.deliveries) == 1 + assert rx.deliveries[0].verified is True + body = json.loads(result.output) + assert body["status_code"] == 200 + assert body["signature"].startswith("sha256=") + + +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"]}]} + p.write_text(json.dumps(payload), encoding="utf-8") + + runner = CliRunner() + secret = "file-secret" + with WebhookReceiver(secret=secret).run() as rx: + result = runner.invoke( + main, + [ + "webhooks", + "simulate", + "--to", + rx.url, + "--secret", + secret, + "--payload-file", + str(p), + ], + ) + assert result.exit_code == 0, result.output + assert rx.deliveries[0].body_json == payload + + +def test_cli_simulate_rejects_both_modes() -> None: + runner = CliRunner() + result = runner.invoke( + main, + [ + "webhooks", + "simulate", + "--to", + "http://example.invalid/", + "--secret", + "x", + "--payload-file", + "/dev/null", + "--event-type", + "entities.updated", + ], + ) + assert result.exit_code != 0 + assert "either --payload-file or --event-type" in result.output diff --git a/tests/test_webhooks_receiver.py b/tests/test_webhooks_receiver.py new file mode 100644 index 0000000..0da755d --- /dev/null +++ b/tests/test_webhooks_receiver.py @@ -0,0 +1,121 @@ +"""Tests for tango.webhooks.receiver.WebhookReceiver.""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from tango.webhooks import generate_signature +from tango.webhooks.receiver import WebhookReceiver + +SECRET = "test_secret" +PAYLOAD = {"events": [{"event_type": "entities.updated", "uei": "TEST123"}]} + + +def _post_signed(url: str, body: bytes, secret: str) -> httpx.Response: + sig = generate_signature(body, secret) + return httpx.post( + url, + content=body, + headers={ + "Content-Type": "application/json", + "X-Tango-Signature": f"sha256={sig}", + }, + timeout=5.0, + ) + + +def test_receiver_records_verified_delivery() -> None: + body = json.dumps(PAYLOAD).encode("utf-8") + with WebhookReceiver(secret=SECRET).run() as rx: + resp = _post_signed(rx.url, body, SECRET) + assert resp.status_code == 200 + assert rx.deliveries[0].verified is True + assert rx.deliveries[0].body_bytes == body + assert rx.deliveries[0].body_json == PAYLOAD + + +def test_receiver_rejects_bad_signature_with_401() -> None: + body = json.dumps(PAYLOAD).encode("utf-8") + with WebhookReceiver(secret=SECRET).run() as rx: + resp = httpx.post( + rx.url, + content=body, + headers={"X-Tango-Signature": "sha256=deadbeef"}, + timeout=5.0, + ) + assert resp.status_code == 401 + # The bad delivery is still recorded, marked unverified, so devs + # can debug what arrived. + assert len(rx.deliveries) == 1 + assert rx.deliveries[0].verified is False + + +def test_receiver_rejects_missing_signature_with_401() -> None: + body = json.dumps(PAYLOAD).encode("utf-8") + with WebhookReceiver(secret=SECRET).run() as rx: + resp = httpx.post(rx.url, content=body, timeout=5.0) + assert resp.status_code == 401 + + +def test_receiver_with_no_secret_accepts_unsigned() -> None: + body = json.dumps(PAYLOAD).encode("utf-8") + with WebhookReceiver(secret="").run() as rx: + resp = httpx.post(rx.url, content=body, timeout=5.0) + assert resp.status_code == 200 + assert rx.deliveries[0].verified is False + + +def test_receiver_404s_on_unknown_path() -> None: + with WebhookReceiver(secret=SECRET, path="/tango/webhooks").run() as rx: + wrong = rx.url.replace("/tango/webhooks", "/elsewhere") + resp = httpx.post(wrong, content=b"{}", timeout=5.0) + assert resp.status_code == 404 + + +def test_receiver_invokes_on_delivery_callback() -> None: + seen: list[str] = [] + body = json.dumps(PAYLOAD).encode("utf-8") + with WebhookReceiver( + secret=SECRET, on_delivery=lambda d: seen.append(d.body_bytes.decode()) + ).run() as rx: + _post_signed(rx.url, body, SECRET) + assert seen == [body.decode()] + + +def test_receiver_max_history_caps_deliveries() -> None: + body = json.dumps(PAYLOAD).encode("utf-8") + with WebhookReceiver(secret=SECRET, max_history=3).run() as rx: + for _ in range(5): + _post_signed(rx.url, body, SECRET) + assert len(rx.deliveries) == 3 + + +def test_receiver_forwards_to_downstream() -> None: + body = json.dumps(PAYLOAD).encode("utf-8") + with WebhookReceiver(secret=SECRET, max_history=10).run() as downstream: + with WebhookReceiver(secret=SECRET, forward_to=downstream.url, port=0).run() as upstream: + resp = _post_signed(upstream.url, body, SECRET) + assert resp.status_code == 200 + # Downstream should have received the same bytes with the same signature. + assert len(downstream.deliveries) == 1 + assert downstream.deliveries[0].body_bytes == body + assert downstream.deliveries[0].verified is True + + +def test_url_property_raises_before_start() -> None: + rx = WebhookReceiver(secret=SECRET) + with pytest.raises(RuntimeError): + _ = rx.url + + +def test_double_start_raises() -> None: + rx = WebhookReceiver(secret=SECRET) + rx.start() + try: + with pytest.raises(RuntimeError): + rx.start() + finally: + rx.stop() diff --git a/tests/test_webhooks_signing.py b/tests/test_webhooks_signing.py new file mode 100644 index 0000000..a136324 --- /dev/null +++ b/tests/test_webhooks_signing.py @@ -0,0 +1,73 @@ +"""Tests for tango.webhooks.signing. + +The signing scheme has to match the tango server byte-for-byte. The +``KNOWN_VECTORS`` constants pin a few payload/secret/signature triples that +were computed independently against tango's reference implementation +(``webhooks/utils.py::generate_signature``). Drift in either direction +should fail this test. +""" + +from __future__ import annotations + +import hashlib +import hmac + +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()), + ( + b'{"events":[{"event_type":"entities.updated","uei":"ABC123"}]}', + "shh", + hmac.new( + b"shh", + b'{"events":[{"event_type":"entities.updated","uei":"ABC123"}]}', + hashlib.sha256, + ).hexdigest(), + ), +] + + +def test_generate_signature_matches_reference_algorithm() -> None: + for body, secret, expected in KNOWN_VECTORS: + assert generate_signature(body, secret) == expected + + +def test_generate_signature_is_lowercase_hex() -> None: + sig = generate_signature(b"payload", "secret") + assert sig == sig.lower() + int(sig, 16) # must parse as hex + + +def test_verify_signature_round_trip() -> None: + body = b'{"events":[{"event_type":"awards.created"}]}' + 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 + + +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 + + +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 + + +def test_verify_signature_handles_missing_or_empty_header() -> None: + assert verify_signature(b"body", "secret", None) is False + assert verify_signature(b"body", "secret", "") is False + assert verify_signature(b"body", "secret", "sha256=") is False + + +def test_parse_signature_header_strips_prefix() -> None: + assert parse_signature_header("sha256=abc123") == "abc123" + assert parse_signature_header(" sha256=abc ") == "abc" + assert parse_signature_header("abc123") == "abc123" + assert parse_signature_header(None) is None + assert parse_signature_header("") is None diff --git a/tests/test_webhooks_simulate.py b/tests/test_webhooks_simulate.py new file mode 100644 index 0000000..8ee1065 --- /dev/null +++ b/tests/test_webhooks_simulate.py @@ -0,0 +1,43 @@ +"""Tests for tango.webhooks.simulate.deliver.""" + +from __future__ import annotations + +from tango.webhooks import simulate +from tango.webhooks.receiver import WebhookReceiver + +SECRET = "shared" + + +def test_deliver_signs_and_posts_dict_payload() -> None: + payload = {"events": [{"event_type": "awards.created", "award_key": "X"}]} + with WebhookReceiver(secret=SECRET).run() as rx: + result = simulate.deliver(target_url=rx.url, payload=payload, secret=SECRET) + assert result.status_code == 200 + assert len(result.signature) == 64 # hex sha256 + # Receiver verified the signature, so the bytes round-tripped intact. + assert rx.deliveries[0].verified is True + assert rx.deliveries[0].body_json == payload + + +def test_deliver_accepts_raw_bytes() -> None: + raw = b'{"events":[{"event_type":"x"}]}' + with WebhookReceiver(secret=SECRET).run() as rx: + result = simulate.deliver(target_url=rx.url, payload=raw, secret=SECRET) + assert result.status_code == 200 + assert result.sent_bytes == raw + + +def test_deliver_dict_serialization_is_deterministic() -> None: + """Same dict in two calls produces the same signature (sort_keys + compact).""" + payload = {"b": 2, "a": 1} + with WebhookReceiver(secret=SECRET).run() as rx: + first = simulate.deliver(target_url=rx.url, payload=payload, secret=SECRET) + second = simulate.deliver(target_url=rx.url, payload=payload, secret=SECRET) + assert first.signature == second.signature + assert first.sent_bytes == second.sent_bytes + + +def test_deliver_wrong_secret_yields_401() -> None: + with WebhookReceiver(secret=SECRET).run() as rx: + result = simulate.deliver(target_url=rx.url, payload={"x": 1}, secret="not-the-secret") + assert result.status_code == 401 diff --git a/uv.lock b/uv.lock index 0edfd6b..218b45d 100644 --- a/uv.lock +++ b/uv.lock @@ -276,6 +276,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -1852,6 +1864,9 @@ notebooks = [ { name = "ipykernel" }, { name = "jupyter" }, ] +webhooks = [ + { name = "click" }, +] [package.dev-dependencies] dev = [ @@ -1860,6 +1875,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "click", marker = "extra == 'webhooks'", specifier = ">=8.1" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "ipykernel", marker = "extra == 'notebooks'", specifier = ">=6.25.0" }, { name = "jupyter", marker = "extra == 'notebooks'", specifier = ">=1.0.0" }, @@ -1872,7 +1888,7 @@ requires-dist = [ { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, ] -provides-extras = ["dev", "notebooks"] +provides-extras = ["dev", "notebooks", "webhooks"] [package.metadata.requires-dev] dev = [{ name = "python-dotenv", specifier = ">=1.2.1" }] From cf221e9abe94bf2eb854b9cefef11b966c542b37 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 12:31:59 -0500 Subject: [PATCH 05/14] Receiver: return JSON for 401/404 instead of stdlib HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default BaseHTTPRequestHandler error page is HTML, which makes the receiver's rejection responses inconsistent with tango's own JSON-shaped API and harder to inspect programmatically (e.g. via simulate's response_body field). Now responds with `{"ok": false, "error": ""}` and Content-Type application/json on 401 (invalid_signature) and 404 (not_found), and `{"ok": true}` on 200 — explicit Content-Length on all paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- tango/webhooks/receiver.py | 13 +++++++++---- tests/test_webhooks_receiver.py | 6 ++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/tango/webhooks/receiver.py b/tango/webhooks/receiver.py index ab51e02..8679252 100644 --- a/tango/webhooks/receiver.py +++ b/tango/webhooks/receiver.py @@ -114,7 +114,7 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 def do_POST(self) -> None: # noqa: N802 (stdlib API) if self.path != receiver.path: - self.send_error(404, "Not Found") + 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"" @@ -130,7 +130,7 @@ def do_POST(self) -> None: # noqa: N802 (stdlib API) ) if require and not verified: self._record(body, signature, verified=False) - self.send_error(401, "Invalid signature") + self._write_json(401, {"ok": False, "error": "invalid_signature"}) return forward_status: int | None = None @@ -145,10 +145,15 @@ def do_POST(self) -> None: # noqa: N802 (stdlib API) forward_status=forward_status, forward_error=forward_error, ) - self.send_response(200) + self._write_json(200, {"ok": True}) + + def _write_json(self, status: int, body: dict[str, Any]) -> None: + payload = json.dumps(body).encode("utf-8") + self.send_response(status) self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) self.end_headers() - self.wfile.write(b'{"ok": true}') + self.wfile.write(payload) def _record( self, diff --git a/tests/test_webhooks_receiver.py b/tests/test_webhooks_receiver.py index 0da755d..63c389c 100644 --- a/tests/test_webhooks_receiver.py +++ b/tests/test_webhooks_receiver.py @@ -32,6 +32,8 @@ def test_receiver_records_verified_delivery() -> None: with WebhookReceiver(secret=SECRET).run() as rx: resp = _post_signed(rx.url, body, SECRET) assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + assert resp.json() == {"ok": True} assert rx.deliveries[0].verified is True assert rx.deliveries[0].body_bytes == body assert rx.deliveries[0].body_json == PAYLOAD @@ -47,6 +49,8 @@ def test_receiver_rejects_bad_signature_with_401() -> None: timeout=5.0, ) assert resp.status_code == 401 + assert resp.headers["content-type"] == "application/json" + assert resp.json() == {"ok": False, "error": "invalid_signature"} # The bad delivery is still recorded, marked unverified, so devs # can debug what arrived. assert len(rx.deliveries) == 1 @@ -73,6 +77,8 @@ def test_receiver_404s_on_unknown_path() -> None: wrong = rx.url.replace("/tango/webhooks", "/elsewhere") resp = httpx.post(wrong, content=b"{}", timeout=5.0) assert resp.status_code == 404 + assert resp.headers["content-type"] == "application/json" + assert resp.json() == {"ok": False, "error": "not_found"} def test_receiver_invokes_on_delivery_callback() -> None: From 1e6e04313d7e80dee9a61bc41c76b64fa3a632ad Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 14:59:14 -0500 Subject: [PATCH 06/14] CLI: clarify simulate output; add fetch-sample and list-event-types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The simulate output's `sent_bytes` (length only) and `response_body` (the receiver's pong) were both confusing for devs who really want to see the canonical Tango shape they're driving their handler with: - `sent_bytes` (int) → `sent_payload` (the parsed JSON dict) - `response_body` → `receiver_response` (clarifies whose body it is) Two new subcommands close the discovery loop for devs building against the Tango API: - `tango webhooks fetch-sample [--event-type X]` — print the canonical sample payload Tango emits (read-only, no POST). Wraps the SDK's `get_webhook_sample_payload`. - `tango webhooks list-event-types` — list every event type Tango supports with descriptions. Wraps `list_webhook_event_types`. Together: `list-event-types` → pick one → `fetch-sample` to see the shape → `simulate --event-type X` to drive the handler with that shape, all without leaving the shell. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- tango/webhooks/cli.py | 57 ++++++++++++++++++++++++++++-- tests/test_webhooks_cli.py | 71 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3490944..42535cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 with `webhooks listen|trigger|simulate` subcommands. Stripe-CLI-style developer ergonomics for testing webhook integrations. +- New `tango[webhooks]` extra (adds `click`) ships a `tango` console script with `webhooks listen|trigger|simulate|fetch-sample|list-event-types` subcommands. Stripe-CLI-style developer ergonomics for testing webhook integrations: `fetch-sample` and `list-event-types` let devs discover canonical payload shapes without dropping into Python. ### Notes - Console script name `tango` may be revisited before the next release if it conflicts with sibling tooling (`tango-scripts` reuses the bare name). diff --git a/tango/webhooks/cli.py b/tango/webhooks/cli.py index 0f5f8b3..dc6fa32 100644 --- a/tango/webhooks/cli.py +++ b/tango/webhooks/cli.py @@ -202,16 +202,69 @@ def simulate_cmd( { "status_code": result.status_code, "signature": f"sha256={result.signature}", - "sent_bytes": len(result.sent_bytes), - "response_body": result.response_body[:500], + "sent_payload": payload, + "receiver_response": result.response_body[:500], }, indent=2, + sort_keys=True, ) ) if result.status_code >= 400: raise SystemExit(1) +@webhooks.command("fetch-sample") +@click.option( + "--event-type", + default=None, + help="If set, fetch the canonical sample for that event type. " + "Otherwise return the full samples mapping for every event type.", +) +@click.option( + "--api-key", + envvar="TANGO_API_KEY", + help="Tango API key (or TANGO_API_KEY).", +) +@click.option( + "--base-url", + envvar="TANGO_BASE_URL", + default="https://tango.makegov.com", + show_default=True, + help="Tango base URL (or TANGO_BASE_URL).", +) +def fetch_sample_cmd(event_type: str | None, api_key: str | None, base_url: str) -> None: + """Print the canonical sample payload Tango emits for a given event type.""" + from tango import TangoClient + + client = TangoClient(api_key=api_key, base_url=base_url) + payload = client.get_webhook_sample_payload(event_type=event_type) + click.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +@webhooks.command("list-event-types") +@click.option( + "--api-key", + envvar="TANGO_API_KEY", + help="Tango API key (or TANGO_API_KEY).", +) +@click.option( + "--base-url", + envvar="TANGO_BASE_URL", + default="https://tango.makegov.com", + show_default=True, + help="Tango base URL (or TANGO_BASE_URL).", +) +def list_event_types_cmd(api_key: str | None, base_url: str) -> None: + """List webhook event types Tango supports, with descriptions.""" + from tango import TangoClient + + client = TangoClient(api_key=api_key, base_url=base_url) + response = client.list_webhook_event_types() + width = max((len(et.event_type) for et in response.event_types), default=0) + for et in response.event_types: + click.echo(f"{et.event_type:<{width}} {et.description}") + + def _print_delivery(delivery: Delivery) -> None: """Default ``listen`` callback: write a one-line summary plus body.""" summary = _summarize(delivery.body_json) diff --git a/tests/test_webhooks_cli.py b/tests/test_webhooks_cli.py index fd174d9..e066a3e 100644 --- a/tests/test_webhooks_cli.py +++ b/tests/test_webhooks_cli.py @@ -17,6 +17,8 @@ def test_cli_help() -> None: assert "listen" in result.output assert "trigger" in result.output assert "simulate" in result.output + assert "fetch-sample" in result.output + assert "list-event-types" in result.output def test_cli_simulate_signs_and_posts(tmp_path: object) -> None: @@ -43,6 +45,11 @@ def test_cli_simulate_signs_and_posts(tmp_path: object) -> None: body = json.loads(result.output) assert body["status_code"] == 200 assert body["signature"].startswith("sha256=") + # Output now includes the actual payload that was sent (the dev's + # main artifact of interest), not just its byte length. + assert isinstance(body["sent_payload"], dict) + assert "events" in body["sent_payload"] + assert body["receiver_response"] == '{"ok": true}' def test_cli_simulate_with_payload_file(tmp_path: object) -> None: @@ -72,6 +79,70 @@ def test_cli_simulate_with_payload_file(tmp_path: object) -> None: assert rx.deliveries[0].body_json == payload +def test_cli_fetch_sample_prints_payload() -> None: + """fetch-sample hits the SDK's get_webhook_sample_payload and pretty-prints.""" + from unittest.mock import Mock, patch + + sample = {"events": [{"event_type": "entities.updated", "uei": "ABC"}]} + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.json.return_value = sample + mock_response.raise_for_status = Mock() + + runner = CliRunner() + with patch("tango.client.httpx.Client.request", return_value=mock_response): + result = runner.invoke( + main, + [ + "webhooks", + "fetch-sample", + "--event-type", + "entities.updated", + "--api-key", + "k", + ], + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == sample + + +def test_cli_list_event_types_prints_table() -> None: + from unittest.mock import Mock, patch + + api_response = { + "event_types": [ + { + "event_type": "entities.updated", + "default_subject_type": "entity", + "description": "Entity updated", + "schema_version": 1, + }, + { + "event_type": "awards.created", + "default_subject_type": "award", + "description": "New award", + "schema_version": 1, + }, + ], + "subject_types": [], + "subject_type_definitions": [], + } + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.json.return_value = api_response + mock_response.raise_for_status = Mock() + + runner = CliRunner() + 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 + + def test_cli_simulate_rejects_both_modes() -> None: runner = CliRunner() result = runner.invoke( From 96e16f5c5524f38f86c3e7be4e0f30499be23b1b Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 15:03:02 -0500 Subject: [PATCH 07/14] CLI: simulate --to is now optional; expose simulate.sign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dev exploring "what would Tango send my handler" shouldn't have to stand up a listener first. Now: - `simulate --event-type X` (no --to) signs the canonical payload and prints the headers + body — no POST. Output includes the literal X-Tango-Signature value the handler would see. - `simulate --event-type X --to URL` keeps the previous behavior: signs, POSTs, prints the receiver's response. Output gained `delivered: bool` so callers can disambiguate the two modes from the JSON itself. Refactored simulate to expose a public `simulate.sign(payload, secret) -> SignedRequest`. Useful in pytest fixtures that want the wire bytes without spinning a process. `simulate.deliver` now goes through it. Top-level re-exports: `from tango.webhooks import sign, SignedRequest`. Co-Authored-By: Claude Opus 4.7 (1M context) --- tango/webhooks/__init__.py | 3 +++ tango/webhooks/cli.py | 28 +++++++++++++++++++--- tango/webhooks/simulate.py | 41 +++++++++++++++++++++++++-------- tests/test_webhooks_cli.py | 17 ++++++++++++++ tests/test_webhooks_simulate.py | 23 ++++++++++++++++-- 5 files changed, 98 insertions(+), 14 deletions(-) diff --git a/tango/webhooks/__init__.py b/tango/webhooks/__init__.py index 2af3853..e0b8d18 100644 --- a/tango/webhooks/__init__.py +++ b/tango/webhooks/__init__.py @@ -14,11 +14,14 @@ parse_signature_header, verify_signature, ) +from tango.webhooks.simulate import SignedRequest, sign __all__ = [ "SIGNATURE_HEADER", "SIGNATURE_PREFIX", + "SignedRequest", "generate_signature", "parse_signature_header", + "sign", "verify_signature", ] diff --git a/tango/webhooks/cli.py b/tango/webhooks/cli.py index dc6fa32..8988ea3 100644 --- a/tango/webhooks/cli.py +++ b/tango/webhooks/cli.py @@ -143,7 +143,12 @@ def trigger_cmd(endpoint_id: str | None, api_key: str | None, base_url: str) -> @webhooks.command("simulate") -@click.option("--to", "target_url", required=True, help="Receiver URL to POST to.") +@click.option( + "--to", + "target_url", + default=None, + help="Receiver URL to POST to. If omitted, the signed request is printed but not sent.", +) @click.option( "--secret", envvar="TANGO_WEBHOOK_SECRET", @@ -174,14 +179,14 @@ def trigger_cmd(endpoint_id: str | None, api_key: str | None, base_url: str) -> help="Tango base URL, only needed with --event-type.", ) def simulate_cmd( - target_url: str, + target_url: str | None, secret: str, payload_file: Path | None, event_type: str | None, api_key: str | None, base_url: str, ) -> None: - """Sign a payload locally and POST it to a receiver. No Tango call unless --event-type.""" + """Sign a payload like Tango would. With --to, also POST it to a receiver.""" if payload_file and event_type: raise click.UsageError("Use either --payload-file or --event-type, not both.") @@ -196,10 +201,27 @@ def simulate_cmd( else: payload = {"events": [{"event_type": "tango.cli.simulated", "subject_ids": []}]} + if target_url is None: + signed = simulate.sign(payload, secret) + click.echo( + json.dumps( + { + "delivered": False, + "headers": signed.headers, + "sent_payload": payload, + }, + indent=2, + sort_keys=True, + ) + ) + return + result = simulate.deliver(target_url=target_url, payload=payload, secret=secret) click.echo( json.dumps( { + "delivered": True, + "target_url": target_url, "status_code": result.status_code, "signature": f"sha256={result.signature}", "sent_payload": payload, diff --git a/tango/webhooks/simulate.py b/tango/webhooks/simulate.py index 3b97eb8..064a78e 100644 --- a/tango/webhooks/simulate.py +++ b/tango/webhooks/simulate.py @@ -26,6 +26,15 @@ from tango.webhooks.signing import SIGNATURE_HEADER, SIGNATURE_PREFIX, generate_signature +@dataclass(frozen=True) +class SignedRequest: + """A Tango-shaped signed request, ready to be POSTed.""" + + body: bytes + signature: str # bare lowercase hex + headers: dict[str, str] # includes Content-Type and X-Tango-Signature + + @dataclass(frozen=True) class SimulationResult: """Outcome of a simulated delivery.""" @@ -36,6 +45,24 @@ class SimulationResult: sent_bytes: bytes +def sign(payload: dict[str, Any] | list[Any] | bytes | str, secret: str) -> SignedRequest: + """Serialize and sign ``payload`` without sending it. + + Useful for showing devs the exact wire form their handler would + receive, or for hand-rolling deliveries with a custom HTTP client. + """ + body = _to_bytes(payload) + signature_hex = generate_signature(body, secret) + return SignedRequest( + body=body, + signature=signature_hex, + headers={ + "Content-Type": "application/json", + SIGNATURE_HEADER: f"{SIGNATURE_PREFIX}{signature_hex}", + }, + ) + + def deliver( *, target_url: str, @@ -53,21 +80,17 @@ def deliver( """ import httpx - body = _to_bytes(payload) - signature_hex = generate_signature(body, secret) - headers = { - "Content-Type": "application/json", - SIGNATURE_HEADER: f"{SIGNATURE_PREFIX}{signature_hex}", - } + signed = sign(payload, secret) + headers = dict(signed.headers) if extra_headers: headers.update(extra_headers) - resp = httpx.post(target_url, content=body, headers=headers, timeout=timeout) + resp = httpx.post(target_url, content=signed.body, headers=headers, timeout=timeout) return SimulationResult( status_code=resp.status_code, response_body=resp.text, - signature=signature_hex, - sent_bytes=body, + signature=signed.signature, + sent_bytes=signed.body, ) diff --git a/tests/test_webhooks_cli.py b/tests/test_webhooks_cli.py index e066a3e..dcbda9c 100644 --- a/tests/test_webhooks_cli.py +++ b/tests/test_webhooks_cli.py @@ -21,6 +21,21 @@ def test_cli_help() -> None: assert "list-event-types" in result.output +def test_cli_simulate_without_to_prints_signed_request() -> None: + """Without --to, simulate signs and prints — no POST, no listener required.""" + runner = CliRunner() + result = runner.invoke( + main, + ["webhooks", "simulate", "--secret", "dev"], + ) + assert result.exit_code == 0, result.output + body = json.loads(result.output) + assert body["delivered"] is False + assert body["headers"]["Content-Type"] == "application/json" + assert body["headers"]["X-Tango-Signature"].startswith("sha256=") + assert "events" in body["sent_payload"] + + def test_cli_simulate_signs_and_posts(tmp_path: object) -> None: runner = CliRunner() secret = "cli-secret" @@ -43,8 +58,10 @@ def test_cli_simulate_signs_and_posts(tmp_path: object) -> None: assert len(rx.deliveries) == 1 assert rx.deliveries[0].verified is True body = json.loads(result.output) + assert body["delivered"] is True assert body["status_code"] == 200 assert body["signature"].startswith("sha256=") + assert body["target_url"] == rx.url # Output now includes the actual payload that was sent (the dev's # main artifact of interest), not just its byte length. assert isinstance(body["sent_payload"], dict) diff --git a/tests/test_webhooks_simulate.py b/tests/test_webhooks_simulate.py index 8ee1065..cbbcaee 100644 --- a/tests/test_webhooks_simulate.py +++ b/tests/test_webhooks_simulate.py @@ -1,13 +1,32 @@ -"""Tests for tango.webhooks.simulate.deliver.""" +"""Tests for tango.webhooks.simulate.""" from __future__ import annotations -from tango.webhooks import simulate +from tango.webhooks import simulate, verify_signature from tango.webhooks.receiver import WebhookReceiver SECRET = "shared" +def test_sign_returns_ready_to_post_request() -> None: + """`sign` produces a SignedRequest that a downstream verifier accepts.""" + payload = {"events": [{"event_type": "entities.updated"}]} + signed = simulate.sign(payload, SECRET) + assert signed.headers["Content-Type"] == "application/json" + assert signed.headers["X-Tango-Signature"] == f"sha256={signed.signature}" + # Round-trip: verify the produced signature against the produced body. + assert verify_signature(signed.body, SECRET, signed.headers["X-Tango-Signature"]) + + +def test_sign_does_not_make_http_request(monkeypatch: object) -> None: + """`sign` is purely local; importing httpx isn't required.""" + # If anyone tries to call httpx.post here we'd want to know — but the + # easier signal is that sign() returns synchronously with no target_url + # parameter at all. This test just documents that contract. + signed = simulate.sign({"x": 1}, "s") + assert isinstance(signed.body, bytes) + + def test_deliver_signs_and_posts_dict_payload() -> None: payload = {"events": [{"event_type": "awards.created", "award_key": "X"}]} with WebhookReceiver(secret=SECRET).run() as rx: From 1ab7ebabd87c8046c8ab59399c1d2221e05cc1bd Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 15:09:27 -0500 Subject: [PATCH 08/14] CLI: add endpoints/subscriptions CRUD subgroups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the dev-against-Tango workflow loop — a dev with TANGO_API_KEY can now go from zero to receiving real deliveries entirely from the shell. Previously they had to drop into Python to call create_webhook_endpoint / create_webhook_subscription. Added: - `tango webhooks endpoints list|get|create|delete` - `tango webhooks subscriptions list|get|create|delete` Notes: - `subscriptions create` accepts simple flags (--name, --event-type, --subject-type, --subject-id) and assembles them into the payload.records[] shape Tango expects. For multi-record subscriptions, use the SDK's create_webhook_subscription directly. - `delete` requires confirmation; pass --yes to skip. - update commands deferred — endpoints/subscriptions are usually delete-and-recreate during dev, and partial-update semantics are awkward to express via flags. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 +- tango/webhooks/cli.py | 203 +++++++++++++++++++++++++++++++++++++ tests/test_webhooks_cli.py | 160 +++++++++++++++++++++++++++++ 3 files changed, 372 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42535cf..fe53e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 with `webhooks listen|trigger|simulate|fetch-sample|list-event-types` subcommands. Stripe-CLI-style developer ergonomics for testing webhook integrations: `fetch-sample` and `list-event-types` let devs discover canonical payload shapes without dropping into Python. +- New `tango[webhooks]` extra (adds `click`) ships a `tango` console script with full Stripe-CLI-style developer ergonomics for testing webhook 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. ### Notes - Console script name `tango` may be revisited before the next release if it conflicts with sibling tooling (`tango-scripts` reuses the bare name). diff --git a/tango/webhooks/cli.py b/tango/webhooks/cli.py index 8988ea3..6c20d1c 100644 --- a/tango/webhooks/cli.py +++ b/tango/webhooks/cli.py @@ -287,6 +287,209 @@ def list_event_types_cmd(api_key: str | None, base_url: str) -> None: click.echo(f"{et.event_type:<{width}} {et.description}") +# --------------------------------------------------------------------------- +# Endpoint and subscription management +# --------------------------------------------------------------------------- +# +# These commands wrap the SDK's CRUD methods. Common --api-key / --base-url +# options are repeated per command (rather than factored into a parent group) +# so envvars resolve correctly per click's normal precedence and `--help` +# output stays self-contained. + + +def _tango_client(api_key: str | None, base_url: str) -> Any: + from tango import TangoClient + + return TangoClient(api_key=api_key, base_url=base_url) + + +def _common_api_options(fn: Any) -> Any: + """Stack the --api-key / --base-url options used by every API command.""" + fn = click.option( + "--base-url", + envvar="TANGO_BASE_URL", + default="https://tango.makegov.com", + show_default=True, + help="Tango base URL (or TANGO_BASE_URL).", + )(fn) + fn = click.option( + "--api-key", + envvar="TANGO_API_KEY", + help="Tango API key (or TANGO_API_KEY).", + )(fn) + return fn + + +@webhooks.group("endpoints") +def endpoints_group() -> None: + """Manage webhook endpoints (where Tango delivers).""" + + +@endpoints_group.command("list") +@click.option("--page", type=int, default=1, show_default=True) +@click.option("--limit", type=int, default=25, show_default=True, help="Max per page (cap 100).") +@_common_api_options +def endpoints_list_cmd(page: int, limit: int, api_key: str | None, base_url: str) -> None: + """List webhook endpoints configured for your account.""" + from dataclasses import asdict + + client = _tango_client(api_key, base_url) + resp = client.list_webhook_endpoints(page=page, limit=limit) + click.echo( + json.dumps( + { + "count": resp.count, + "results": [asdict(e) for e in resp.results], + }, + indent=2, + sort_keys=True, + ) + ) + + +@endpoints_group.command("get") +@click.argument("endpoint_id") +@_common_api_options +def endpoints_get_cmd(endpoint_id: str, api_key: str | None, base_url: str) -> None: + """Show one endpoint by id.""" + from dataclasses import asdict + + client = _tango_client(api_key, base_url) + endpoint = client.get_webhook_endpoint(endpoint_id) + click.echo(json.dumps(asdict(endpoint), indent=2, sort_keys=True)) + + +@endpoints_group.command("create") +@click.option("--url", "callback_url", required=True, help="Receiver URL Tango will POST to.") +@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 +) -> 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) + click.echo(json.dumps(asdict(endpoint), indent=2, sort_keys=True)) + + +@endpoints_group.command("delete") +@click.argument("endpoint_id") +@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.") +@_common_api_options +def endpoints_delete_cmd(endpoint_id: str, yes: bool, api_key: str | None, base_url: str) -> None: + """Delete a webhook endpoint.""" + if not yes: + click.confirm(f"Delete endpoint {endpoint_id}?", abort=True) + client = _tango_client(api_key, base_url) + client.delete_webhook_endpoint(endpoint_id) + 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/tests/test_webhooks_cli.py b/tests/test_webhooks_cli.py index dcbda9c..a18bbef 100644 --- a/tests/test_webhooks_cli.py +++ b/tests/test_webhooks_cli.py @@ -160,6 +160,166 @@ def test_cli_list_event_types_prints_table() -> None: assert "awards.created" in result.output +def _mock_response(api_response: dict[str, object]) -> object: + from unittest.mock import Mock + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.json.return_value = api_response + mock_response.raise_for_status = Mock() + return mock_response + + +def test_cli_endpoints_list() -> None: + from unittest.mock import patch + + api = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": "ep-1", + "name": "default", + "callback_url": "https://example/webhooks", + "is_active": True, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "secret": None, + } + ], + } + runner = CliRunner() + with patch("tango.client.httpx.Client.request", return_value=_mock_response(api)): + result = runner.invoke(main, ["webhooks", "endpoints", "list", "--api-key", "k"]) + assert result.exit_code == 0, result.output + body = json.loads(result.output) + assert body["count"] == 1 + assert body["results"][0]["callback_url"] == "https://example/webhooks" + + +def test_cli_endpoints_create_returns_secret() -> None: + from unittest.mock import patch + + api = { + "id": "ep-2", + "name": "default", + "callback_url": "https://example/wh", + "is_active": True, + "created_at": "2026-05-07T00:00:00Z", + "updated_at": "2026-05-07T00:00:00Z", + "secret": "whsec_redacted_in_test", + } + runner = CliRunner() + with patch("tango.client.httpx.Client.request", return_value=_mock_response(api)): + result = runner.invoke( + main, + [ + "webhooks", + "endpoints", + "create", + "--url", + "https://example/wh", + "--api-key", + "k", + ], + ) + assert result.exit_code == 0, result.output + body = json.loads(result.output) + assert body["id"] == "ep-2" + assert body["secret"] == "whsec_redacted_in_test" + + +def test_cli_endpoints_delete_requires_confirmation() -> None: + from unittest.mock import patch + + api: dict[str, object] = {} + runner = CliRunner() + with patch("tango.client.httpx.Client.request", return_value=_mock_response(api)): + # Without --yes, abort if user says n. + result = runner.invoke( + main, + ["webhooks", "endpoints", "delete", "ep-1", "--api-key", "k"], + input="n\n", + ) + assert result.exit_code != 0 # aborted + # With --yes, proceeds. + result = runner.invoke( + main, + ["webhooks", "endpoints", "delete", "ep-1", "--yes", "--api-key", "k"], + ) + assert result.exit_code == 0, result.output + 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() -> None: runner = CliRunner() result = runner.invoke( From 54634baeb866663ee14ee92b98e109acfadac0d0 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 15:12:43 -0500 Subject: [PATCH 09/14] Fix Windows CI: use a real temp file for the mutex test `test_cli_simulate_rejects_both_modes` was passing /dev/null as a placeholder file. Click validates --payload-file with exists=True before the command body runs, so on Windows CI the test bailed with "File '/dev/null' does not exist" before ever reaching the mutual- exclusion error it was checking for. Use tmp_path to get a real existing file. macOS/Linux already passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_webhooks_cli.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_webhooks_cli.py b/tests/test_webhooks_cli.py index a18bbef..bacd84f 100644 --- a/tests/test_webhooks_cli.py +++ b/tests/test_webhooks_cli.py @@ -320,7 +320,14 @@ def test_cli_subscriptions_list() -> None: assert json.loads(result.output) == {"count": 0, "results": []} -def test_cli_simulate_rejects_both_modes() -> None: +def test_cli_simulate_rejects_both_modes(tmp_path: object) -> None: + import pathlib + + # Click validates --payload-file exists before the command body runs, so + # we need a real path here. /dev/null worked on POSIX but not Windows CI. + p = pathlib.Path(str(tmp_path)) / "p.json" + p.write_text("{}", encoding="utf-8") + runner = CliRunner() result = runner.invoke( main, @@ -332,7 +339,7 @@ def test_cli_simulate_rejects_both_modes() -> None: "--secret", "x", "--payload-file", - "/dev/null", + str(p), "--event-type", "entities.updated", ], From 1f7539e053ce8d61e0f02dce9aac8809a2c3ba42 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 15:21:42 -0500 Subject: [PATCH 10/14] Docs: add webhook tooling guide; extend README and API reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `docs/WEBHOOKS.md` (~370 lines) — single comprehensive guide for developers building against the Tango API: install, concepts, a zero-to-receiving quickstart, full CLI reference (every subcommand with copy-pasteable examples), and programmatic patterns for `WebhookReceiver`, `simulate.sign`, and `simulate.deliver` in pytest fixtures and offline development. Includes a troubleshooting appendix for the common gotchas (signature drift, missing extra, one-endpoint-per-user, etc.). - `README.md` — new "Webhook Tooling" section under Advanced Features with a one-paragraph overview, the install one-liner, the seven CLI subcommands at a glance, and the bare-minimum receiver pattern. WEBHOOKS.md added to the Documentation index. - `docs/API_REFERENCE.md` — filled in `get_webhook_subscription` (previously missing); replaced the hand-rolled signature snippet with a pointer to `tango.webhooks.verify_signature`; added a new "Webhook tooling (`tango.webhooks`)" section that catalogs every importable from the new subpackage (signing helpers, WebhookReceiver, Delivery, sign, SignedRequest, simulate.deliver, CLI entry point) with constructor tables and short examples. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 + README.md | 41 ++++ docs/API_REFERENCE.md | 96 +++++++++- docs/WEBHOOKS.md | 430 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 562 insertions(+), 10 deletions(-) create mode 100644 docs/WEBHOOKS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fe53e8a..b4ef87b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Notes - Console script name `tango` may be revisited before the next 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 ### Added diff --git a/README.md b/README.md index d154abf..3367b48 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,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 Stripe-CLI-style command line: + +```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: @@ -476,6 +516,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 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index e1d58ee..e41e039 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -1227,6 +1227,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. @@ -1246,6 +1248,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 @@ -1335,21 +1343,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. -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) +--- + +## 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) + +```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 diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md new file mode 100644 index 0000000..0713e85 --- /dev/null +++ b/docs/WEBHOOKS.md @@ -0,0 +1,430 @@ +# 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 Stripe-CLI-style command-line tool, and management commands for the underlying endpoints and subscriptions. + +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/). + +--- + +## Contents + +- [Install](#install) +- [Concepts in 60 seconds](#concepts-in-60-seconds) +- [Quickstart: zero to receiving](#quickstart-zero-to-receiving) +- [CLI reference](#cli-reference) + - [`tango webhooks listen`](#tango-webhooks-listen) + - [`tango webhooks simulate`](#tango-webhooks-simulate) + - [`tango webhooks trigger`](#tango-webhooks-trigger) + - [`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) + - [`simulate.sign` and `simulate.deliver`](#simulatesign-and-simulatedeliver) +- [Common workflows](#common-workflows) +- [Troubleshooting](#troubleshooting) + +--- + +## Install + +The signing helpers ship with the default install: + +```bash +pip install tango-python +``` + +The CLI (`tango webhooks ...`) and the local receiver class are gated behind an optional extra: + +```bash +pip install 'tango-python[webhooks]' +``` + +This adds [`click`](https://palletsprojects.com/projects/click) as a runtime dependency. The base SDK install stays unchanged. + +After installing the extra, the `tango` console script is on your `PATH`: + +```bash +tango webhooks --help +``` + +--- + +## Concepts in 60 seconds + +Tango webhooks have three 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` | +| **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). +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. + +--- + +## Quickstart: zero to receiving + +Assumes you have a `TANGO_API_KEY` and want to receive entity-update webhooks for a specific UEI. + +### 1. See what you can subscribe to + +```bash +export TANGO_API_KEY=... +tango webhooks list-event-types +# entities.updated An entity record was updated +# awards.created A new award was published +# ... +``` + +### 2. See what a payload looks like + +```bash +tango webhooks fetch-sample --event-type entities.updated +``` + +Prints the canonical JSON shape Tango will deliver. No POST, no signature — just the body. + +### 3. Run a local receiver + +In one shell, start a listener with a chosen secret: + +```bash +export TANGO_WEBHOOK_SECRET=dev_secret +tango webhooks listen --port 8011 +``` + +In another shell, drive it with the canonical sample, signed locally: + +```bash +tango webhooks simulate \ + --secret $TANGO_WEBHOOK_SECRET \ + --event-type entities.updated \ + --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. + +### 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: + +```bash +# Use the public URL the tunnel gave you. +tango webhooks endpoints create --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 +``` + +To force a real test delivery from Tango (without waiting for an actual event): + +```bash +tango webhooks trigger +``` + +You should see a `verified` delivery in your local listener with the signature value generated by Tango — not by `simulate`. + +--- + +## CLI reference + +All commands live under `tango webhooks`. Options that talk to Tango's API (`--api-key`, `--base-url`) read `TANGO_API_KEY` and `TANGO_BASE_URL` if not passed explicitly. + +### `tango webhooks listen` + +Run a local HTTP receiver. Verifies signatures, optionally forwards each delivery downstream, prints a one-line summary plus the JSON body for each delivery. + +```bash +tango webhooks listen \ + --port 8011 \ + --host 127.0.0.1 \ + --path /tango/webhooks \ + --secret $TANGO_WEBHOOK_SECRET \ + --forward-to http://127.0.0.1:4242/wh +``` + +Options: + +- `--port` (default `8011`) +- `--host` (default `127.0.0.1` — loopback only, by design) +- `--path` (default `/tango/webhooks`) +- `--secret` / `TANGO_WEBHOOK_SECRET` — if empty, signatures are not verified (the listener accepts everything; useful for inspecting payloads when you don't have the right secret yet) +- `--forward-to URL` — mirror each delivery to a downstream URL, preserving body bytes and the `X-Tango-Signature` header +- `--require-signature / --allow-unsigned` — override the default policy (default: require when `--secret` is set) + +Press Ctrl+C to stop. Rejected (signature-mismatch) deliveries are still printed with the label `UNVERIFIED` so you can debug what arrived. + +### `tango webhooks simulate` + +Sign a payload locally with the same scheme Tango uses, then either print the signed request or POST it to a receiver. + +**Without `--to`** — just print the headers + body a real Tango delivery would have: + +```bash +tango webhooks simulate --secret dev_secret --event-type entities.updated +``` + +Output includes `delivered: false`, the headers (`Content-Type`, `X-Tango-Signature`), and the JSON payload. + +**With `--to`** — also POST the signed body to a receiver: + +```bash +tango webhooks simulate \ + --secret dev_secret \ + --event-type entities.updated \ + --to http://127.0.0.1:8011/tango/webhooks +``` + +Output includes `delivered: true`, the receiver's status code, and the receiver's response body. + +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 | +| `--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 | + +### `tango webhooks trigger` + +Ask Tango to send a real test delivery to your configured endpoint. Wraps `POST /api/webhooks/endpoints/test-delivery/`. Requires `--api-key`. + +```bash +tango webhooks trigger +tango webhooks trigger --endpoint-id +``` + +Output is JSON: `success`, `status_code` (the HTTP code Tango got from your endpoint), `response_time_ms`, `endpoint_url`, `message`, `error`. Exit code is non-zero if delivery failed. + +### `tango webhooks fetch-sample` + +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 # all event types +``` + +### `tango webhooks list-event-types` + +List every event type Tango supports with a one-line description. + +```bash +tango webhooks list-event-types +``` + +### `tango webhooks endpoints` + +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 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). + +### `tango webhooks subscriptions` + +Manage **what Tango delivers**. + +```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] +``` + +`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. + +--- + +## Programmatic use + +The CLI is built on top of small importable pieces. You can use them directly in your own code — most usefully, in tests. + +### Signature verification in your handler + +`verify_signature` is pure stdlib (no SDK dependencies, no `click`). Call it on the raw request body, not on a re-serialized parsed body — the HMAC is computed over exact bytes. + +```python +from tango.webhooks import verify_signature + +# In your Flask / FastAPI / Django / Starlette / whatever handler: +def handle_webhook(request): + body = request.body # raw bytes + signature = request.headers.get("X-Tango-Signature") + if not verify_signature(body, secret=ENDPOINT_SECRET, signature_header=signature): + return 401, {"error": "invalid_signature"} + payload = json.loads(body) + # ... act on the events ... + return 200, {"ok": True} +``` + +`verify_signature` returns `False` for missing/empty/malformed headers — it never raises. Comparison is constant-time (`hmac.compare_digest`). + +### `WebhookReceiver` in pytest fixtures + +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 +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 + sig = generate_signature(body, "test_secret") + httpx.post(rx.url, content=body, headers={"X-Tango-Signature": f"sha256={sig}"}) + + assert len(rx.deliveries) == 1 + assert rx.deliveries[0].verified + assert rx.deliveries[0].body_json["events"][0]["uei"] == "ABC" +``` + +`WebhookReceiver` options: + +- `secret: str = ""` — shared secret. Empty means "don't verify." +- `path: str = "/tango/webhooks"` — URL path to accept. +- `host: str = "127.0.0.1"` / `port: int = 0` — bind address. `0` lets the OS pick a free port. +- `forward_to: str | None = None` — mirror each delivery to a downstream URL. +- `max_history: int = 256` — cap on the in-memory `deliveries` deque. +- `on_delivery: Callable[[Delivery], None] | None = None` — fires for every recorded delivery, including signature-failed ones. +- `require_signature: bool | None = None` — override default (require iff `secret` is set). + +Each `Delivery` has: `received_at`, `path`, `signature_header`, `body_bytes`, `body_json`, `verified`, `remote_addr`, `forward_status`, `forward_error`. + +### `simulate.sign` and `simulate.deliver` + +`simulate.sign` is the offline counterpart — it produces the exact wire form a Tango delivery would have, so you can drive your handler from a unit test: + +```python +from tango.webhooks import sign + +signed = sign({"events": [{"event_type": "entities.updated"}]}, secret="s") +assert signed.headers["X-Tango-Signature"].startswith("sha256=") + +# Use `signed.body` as the raw bytes and `signed.headers` directly: +response = my_app.test_client().post( + "/webhooks", data=signed.body, headers=signed.headers +) +``` + +`simulate.deliver` does the same but POSTs the result to a URL — `WebhookReceiver` works as a target: + +```python +from tango.webhooks import simulate, WebhookReceiver + +with WebhookReceiver(secret="s").run() as rx: + result = simulate.deliver(target_url=rx.url, payload={...}, secret="s") + assert result.status_code == 200 +``` + +--- + +## Common workflows + +### "I'm starting fresh — set me up to receive entity updates" + +```bash +export TANGO_API_KEY=... +# 1. Confirm event types +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 +# (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 +tango webhooks listen --port 8011 --secret $TANGO_WEBHOOK_SECRET \ + --forward-to http://localhost:4242/wh +# 5. Force a test delivery +tango webhooks trigger +``` + +### "I want to develop my handler offline" + +You don't need a Tango account or any tunnel: + +```bash +# Run the handler however you normally would on, e.g., :4242 +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 \ + --to http://127.0.0.1:8011/tango/webhooks + +# Or use a custom shape from a file (no API key required): +tango webhooks simulate --secret dev --payload-file ./fixtures/edge.json \ + --to http://127.0.0.1:8011/tango/webhooks +``` + +### "I want to test my handler in CI, no network" + +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 + +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"}]}, + secret="s", + ) + assert result.status_code == 200 + assert rx.deliveries[0].verified +``` + +### "I need to inspect what bytes Tango actually sends" + +```bash +tango webhooks simulate --secret $TANGO_WEBHOOK_SECRET --event-type entities.updated +# Prints { "delivered": false, "headers": {...}, "sent_payload": {...} } +``` + +This is the shape your handler will receive — including the exact `X-Tango-Signature` value it should verify. + +--- + +## Troubleshooting + +**Signature always fails.** Verify on raw bytes, not on a re-serialized parsed body. The HMAC is over exact bytes; reformatting whitespace or reordering keys breaks it. Most web frameworks expose the raw body separately from a parsed JSON shortcut — use the raw one. + +**`tango: command not found`.** Install the extra: `pip install 'tango-python[webhooks]'`. The console script is registered only when `click` is available. + +**Listener prints `WARNING: no --secret provided`.** You started `listen` without `--secret` and without `TANGO_WEBHOOK_SECRET` set. Every delivery will be accepted with `verified=False`. Useful for inspecting payloads when you don't have the secret yet, but unsafe in any shared environment. + +**`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. + +**`simulate --event-type X` fails with HTTP 4xx.** Tango doesn't recognize the event type. Run `list-event-types` to see the current list. + +**`trigger` returns `success: false`.** Tango reached your endpoint but got a non-2xx response. Check `endpoint_url` and `response_body` in the output, then look at your handler's logs. From 957ccc1c01767965443d3c97544d0308b091e88a Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 15:31:32 -0500 Subject: [PATCH 11/14] Docs: drop external-vendor analogy; describe the tool on its own terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public-audience material shouldn't compare the SDK to a third-party tool — describe what the CLI does without the analogy. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- README.md | 2 +- docs/WEBHOOKS.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ef87b..37fabf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 with full Stripe-CLI-style developer ergonomics for testing webhook integrations: +- 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 diff --git a/README.md b/README.md index 3367b48..3cc0d5b 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,7 @@ contracts = client.list_contracts( ### 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 Stripe-CLI-style command line: +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]' diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index 0713e85..817b22c 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 Stripe-CLI-style 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 subscriptions. 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/). From a1f28549f0fdcc6b9c0339589a967cb359f14130 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 15:42:39 -0500 Subject: [PATCH 12/14] Fix vehicles integration tests for 4.3.0 shape/ordering Refresh the three vehicle cassettes against the live API so they match the new default shape (adds is_synthetic_solicitation, program_acronym, idv_count, total_obligated). Make test_get_vehicle_supports_joiner_and_flat_lists robust to the new default ordering: list 20 vehicles with shape=uuid,opportunity(title) and pick one whose opportunity is populated, so the joiner/flat_lists behavior is meaningfully exercised. Skip if none have one. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...get_vehicle_supports_joiner_and_flat_lists | 74 +++++++++++------- ...t_list_vehicle_awardees_uses_default_shape | 77 ++++++++----------- ...ist_vehicles_uses_default_shape_and_search | 45 +++++------ .../test_vehicles_idvs_integration.py | 22 ++++-- 4 files changed, 115 insertions(+), 103 deletions(-) diff --git a/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_supports_joiner_and_flat_lists b/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_supports_joiner_and_flat_lists index 4c516e8..785bceb 100644 --- a/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_supports_joiner_and_flat_lists +++ b/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_supports_joiner_and_flat_lists @@ -13,24 +13,40 @@ interactions: user-agent: - python-httpx/0.28.1 method: GET - uri: https://tango.makegov.com/api/vehicles/?page=1&limit=1&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date + uri: https://tango.makegov.com/api/vehicles/?page=1&limit=20&shape=uuid%2Copportunity%28title%29 response: body: - string: '{"count":5874,"next":"https://tango.makegov.com/api/vehicles/?limit=1&page=2&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"uuid":"84d76669-61d8-5938-83fb-2d6f8a6c85b7","solicitation_identifier":"0","organization_id":"f6c88e61-3d34-5685-a5ab-670858289883","awardee_count":38,"order_count":65,"vehicle_obligations":8876449.66,"vehicle_contracts_value":9688796.56,"solicitation_title":"X1LZ--618-20-2-6190-0007 - - Service - Minneapolis VA CRRC Parking, 33 Parking Spaces - MPLS","solicitation_date":"2020-03-02"}]}' + string: '{"count":20457,"next":"https://tango.makegov.com/api/vehicles/?limit=20&page=2&shape=uuid%2Copportunity%28title%29","previous":null,"results":[{"opportunity":null,"uuid":"8597845d-2a4c-5acd-99ff-1f5fe348501e"},{"opportunity":{"title":"Region + Local Telephony Contract - for Upstate New York, Rochester New York, New Jersey, + and Puerto Rico"},"uuid":"62090454-1126-5f27-8aef-c39e6df5ee7f"},{"opportunity":{"title":"C + -- Indefinite Quantity Contract for A-E Services - Repair/Maintenance, Alteration, + and New Construction"},"uuid":"88f6c85b-b398-5396-808c-316f3f4394e3"},{"opportunity":{"title":"W + -- Aerial dispersal services which shall include aircraft, pilots, vaccine + delivery machinery, and flight engineers"},"uuid":"6b6148c8-8cf2-57f2-b23d-14497f6132e1"},{"opportunity":null,"uuid":"4ba4daee-34fe-5e71-8993-6ce6e564eb48"},{"opportunity":{"title":"C--Professional + Architect and Engineering Services"},"uuid":"664412e7-6833-5ff0-b0c0-5c4de8e9645c"},{"opportunity":{"title":"C--Requirement + for Architectural and Engineering Services."},"uuid":"76d34d0f-6f3d-52c4-a591-ad1830ca8282"},{"opportunity":{"title":"66--66- + Seismometers"},"uuid":"d7a26fbc-76bb-5839-b75b-7fb13f2b5a00"},{"opportunity":{"title":"T--Indefinite + Delivery, Indefinite Quantity contract for furnishign various Aerial Photogrammetry + Services"},"uuid":"dc95647f-ed2a-540f-bd43-2553438eb56c"},{"opportunity":{"title":"F--F--IDIQ + Stockpiling Riprap and Gravel for the Yuma Area Office, Base Year with 2 Option + Years"},"uuid":"f38faccb-d4ab-5886-9490-f7b403469471"},{"opportunity":{"title":"B + -- Nutrient Analysis for the Agricultural Research Service"},"uuid":"0aada776-5b10-51b8-8246-9b84ef8d20ad"},{"opportunity":null,"uuid":"fad73227-c71a-52a1-86dc-afaa19f24d5e"},{"opportunity":null,"uuid":"a8775381-0a49-54aa-954f-5afcda32b0de"},{"opportunity":{"title":"SOLICITATION + - GAO IDIQ CONSTRUCTION SERVICES"},"uuid":"ef62a4f0-080a-503f-adbe-a1705ae410c0"},{"opportunity":null,"uuid":"15a432e6-e692-599b-9815-585eb62adb0e"},{"opportunity":null,"uuid":"6e548ccd-73cd-5aea-be4b-72ca776ece20"},{"opportunity":{"title":"B--Professional + Services IDIQ Contract"},"uuid":"09850593-da36-57c5-abf5-f78d49cf6a22"},{"opportunity":null,"uuid":"508d5921-182f-52a8-aaeb-2afc0432eb50"},{"opportunity":null,"uuid":"b18958ca-9647-57ed-8134-9259f58edd33"},{"opportunity":{"title":"76--Commercial + Satellite Data"},"uuid":"8a4e1e44-9be0-515e-b02b-6ec1db728f32"}]}' headers: CF-RAY: - - 9d71a79d1e69ace2-MSP + - 9f8300911ae0eb68-ORD Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 04 Mar 2026 14:43:42 GMT + - Thu, 07 May 2026 20:33:53 GMT Nel: - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' Report-To: - - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=foISsWd3o4Crwk58iKLLuBVkQFtC11yIJkhkCK4vC%2F0w4I28Kbk4j%2FWdF2Z4gVU3QAwqVjUkR9L3KC6Sa4dIYTFIEYgsuvxpb5FBysskgBHHwX4lRUqueUSX"}]}' + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=cIEJq5J9Gv812rH4c3dzT4m2XnOQsHhZ00XTFnQY8PrjjwAhnIZkmZZuWDNbbC68kD6M6uCIWASlYj4XzQ%2BujVZkfNI8rIKhB3vhzFiRgy6HxkVPeXGWNRcPqw3q1pQ1Xp7O"}]}' Server: - cloudflare Transfer-Encoding: @@ -40,7 +56,7 @@ interactions: cf-cache-status: - DYNAMIC content-length: - - '668' + - '2407' cross-origin-opener-policy: - same-origin referrer-policy: @@ -50,27 +66,29 @@ interactions: x-content-type-options: - nosniff x-execution-time: - - 0.021s + - 0.107s x-frame-options: - DENY x-ratelimit-burst-limit: - '1000' x-ratelimit-burst-remaining: - - '910' + - '998' x-ratelimit-burst-reset: - - '6' + - '59' x-ratelimit-daily-limit: - '2000000' x-ratelimit-daily-remaining: - - '1999670' + - '1999837' x-ratelimit-daily-reset: - - '84966' + - '12367' x-ratelimit-limit: - '1000' x-ratelimit-remaining: - - '910' + - '998' x-ratelimit-reset: - - '6' + - '59' + x-results-counttype: + - exact status: code: 200 message: OK @@ -88,24 +106,24 @@ interactions: user-agent: - python-httpx/0.28.1 method: GET - uri: https://tango.makegov.com/api/vehicles/84d76669-61d8-5938-83fb-2d6f8a6c85b7/?shape=uuid%2Copportunity%28title%29&flat=true&joiner=__&flat_lists=true + uri: https://tango.makegov.com/api/vehicles/62090454-1126-5f27-8aef-c39e6df5ee7f/?shape=uuid%2Copportunity%28title%29&flat=true&joiner=__&flat_lists=true response: body: - string: '{"uuid":"84d76669-61d8-5938-83fb-2d6f8a6c85b7","opportunity__title":"X1LZ--618-20-2-6190-0007 - - Service - Minneapolis VA CRRC Parking, 33 Parking Spaces - MPLS"}' + string: '{"opportunity__title":"Region Local Telephony Contract - for Upstate + New York, Rochester New York, New Jersey, and Puerto Rico","uuid":"62090454-1126-5f27-8aef-c39e6df5ee7f"}' headers: CF-RAY: - - 9d71a79de845ace2-MSP + - 9f830092af71eb68-ORD Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 04 Mar 2026 14:43:42 GMT + - Thu, 07 May 2026 20:33:53 GMT Nel: - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' Report-To: - - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=fTIzuP10eNwQ3RQvXSnqPSyKJ%2F0GkCtQtRiwPTkcg7j62kkVcPa6owX9t2k8dFjcoroz4y6rj8b251WYX0UtASQXH3CIXIV2MxlJ6CzJ9%2FUD%2FcYSU6Ckn1vO"}]}' + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=xtRSfhVoii188PnTYsknN%2BQ8CcYClzGWfplQLUGSyqBtCcxCzCTpeYFQwUlNeaRX0HQFKv2jZxGF8xhFNiGDPCtr0Uv7vj9mQCMILB%2F7QelTyGTy7MEyuHjR6BcndLydmiDd"}]}' Server: - cloudflare Transfer-Encoding: @@ -115,7 +133,7 @@ interactions: cf-cache-status: - DYNAMIC content-length: - - '161' + - '174' cross-origin-opener-policy: - same-origin referrer-policy: @@ -125,27 +143,27 @@ interactions: x-content-type-options: - nosniff x-execution-time: - - 0.032s + - 0.025s x-frame-options: - DENY x-ratelimit-burst-limit: - '1000' x-ratelimit-burst-remaining: - - '909' + - '997' x-ratelimit-burst-reset: - - '6' + - '59' x-ratelimit-daily-limit: - '2000000' x-ratelimit-daily-remaining: - - '1999669' + - '1999836' x-ratelimit-daily-reset: - - '84966' + - '12366' x-ratelimit-limit: - '1000' x-ratelimit-remaining: - - '909' + - '997' x-ratelimit-reset: - - '6' + - '59' status: code: 200 message: OK diff --git a/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_awardees_uses_default_shape b/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_awardees_uses_default_shape index c8e7417..9dd9ddb 100644 --- a/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_awardees_uses_default_shape +++ b/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_awardees_uses_default_shape @@ -13,24 +13,24 @@ interactions: user-agent: - python-httpx/0.28.1 method: GET - uri: https://tango.makegov.com/api/vehicles/?page=1&limit=1&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date + uri: https://tango.makegov.com/api/vehicles/?page=1&limit=1&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date response: body: - string: '{"count":5874,"next":"https://tango.makegov.com/api/vehicles/?limit=1&page=2&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"uuid":"84d76669-61d8-5938-83fb-2d6f8a6c85b7","solicitation_identifier":"0","organization_id":"f6c88e61-3d34-5685-a5ab-670858289883","awardee_count":38,"order_count":65,"vehicle_obligations":8876449.66,"vehicle_contracts_value":9688796.56,"solicitation_title":"X1LZ--618-20-2-6190-0007 - - Service - Minneapolis VA CRRC Parking, 33 Parking Spaces - MPLS","solicitation_date":"2020-03-02"}]}' + string: '{"count":20457,"next":"https://tango.makegov.com/api/vehicles/?limit=1&page=2&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"awardee_count":2,"idv_count":2,"is_synthetic_solicitation":false,"order_count":25,"organization_id":"767bb2b0-239a-5bb4-8f3c-16d0a418fb71","program_acronym":"APHIS + VS","solicitation_date":null,"solicitation_identifier":"002-M-APHIS-06","solicitation_title":null,"total_obligated":5388564.69,"uuid":"8597845d-2a4c-5acd-99ff-1f5fe348501e","vehicle_contracts_value":5116733.97,"vehicle_obligations":5388564.6899999995}]}' headers: CF-RAY: - - 9d71a79f4d4ba1c9-MSP + - 9f8300941801f4ae-ORD Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 04 Mar 2026 14:43:42 GMT + - Thu, 07 May 2026 20:33:53 GMT Nel: - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' Report-To: - - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=g0j44m3AvOYMt1ZDTnMMFZ2MIbXGngySfFBMATk3ynlDhde23D0RJVscwUPb6%2FacawtgKkwd%2Bq2ufXW62HT1tqPmdW1YWEDTq8jkHS7HBqy1vBy1fFH36z7q"}]}' + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=uTPbt8NMccR4JfW1yfvwE9RwkNUgkl5gXUTzGyd4%2FT8Mj%2BtA95KV72jcJo2bUtRonvNdcUJ999fR5k5EBLnLz3oe51beVriLg0JGvCsvqkdMS2YTjXgIXmLir2%2F3K%2FrIOxzo"}]}' Server: - cloudflare Transfer-Encoding: @@ -40,7 +40,7 @@ interactions: cf-cache-status: - DYNAMIC content-length: - - '668' + - '775' cross-origin-opener-policy: - same-origin referrer-policy: @@ -50,27 +50,29 @@ interactions: x-content-type-options: - nosniff x-execution-time: - - 0.022s + - 0.023s x-frame-options: - DENY x-ratelimit-burst-limit: - '1000' x-ratelimit-burst-remaining: - - '908' + - '996' x-ratelimit-burst-reset: - - '6' + - '59' x-ratelimit-daily-limit: - '2000000' x-ratelimit-daily-remaining: - - '1999668' + - '1999835' x-ratelimit-daily-reset: - - '84966' + - '12366' x-ratelimit-limit: - '1000' x-ratelimit-remaining: - - '908' + - '996' x-ratelimit-reset: - - '6' + - '59' + x-results-counttype: + - exact status: code: 200 message: OK @@ -88,41 +90,26 @@ interactions: user-agent: - python-httpx/0.28.1 method: GET - uri: https://tango.makegov.com/api/vehicles/84d76669-61d8-5938-83fb-2d6f8a6c85b7/awardees/?page=1&limit=10&shape=uuid%2Ckey%2Cpiid%2Caward_date%2Ctitle%2Corder_count%2Cidv_obligations%2Cidv_contracts_value%2Crecipient%28display_name%2Cuei%29 + uri: https://tango.makegov.com/api/vehicles/8597845d-2a4c-5acd-99ff-1f5fe348501e/awardees/?page=1&limit=10&shape=uuid%2Ckey%2Cpiid%2Caward_date%2Ctitle%2Corder_count%2Cidv_obligations%2Cidv_contracts_value%2Crecipient%28display_name%2Cuei%29 response: body: - string: '{"count":38,"next":"https://tango.makegov.com/api/vehicles/84d76669-61d8-5938-83fb-2d6f8a6c85b7/awardees/?limit=10&page=2&shape=uuid%2Ckey%2Cpiid%2Caward_date%2Ctitle%2Corder_count%2Cidv_obligations%2Cidv_contracts_value%2Crecipient%28display_name%2Cuei%29","previous":null,"results":[{"uuid":"d3eec2d2-f46d-5f3a-a2b3-da474202982d","key":"CONT_IDV_36C24925A0044_3600","piid":"36C24925A0044","award_date":"2025-02-26","title":null,"order_count":2,"idv_obligations":97177.1,"idv_contracts_value":97177.1,"recipient":{"uei":"HPNGJJKW7NZ3","display_name":"MIM - SOFTWARE INC"}},{"uuid":"6b35e54e-cdd7-547a-9912-4f32889904ee","key":"CONT_IDV_36C24923A0055_3600","piid":"36C24923A0055","award_date":"2023-09-21","title":"LEXINGTON - VAMC AMOS CONSIGNMENT","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"G8XGKTUWPM14","display_name":"AMO - SALES AND SERVICE, INC."}},{"uuid":"4734026e-f902-5e4d-ae05-2625e9ee09b3","key":"CONT_IDV_36C10X23A0024_3600","piid":"36C10X23A0024","award_date":"2023-09-19","title":"HR - SUPPORT SERVICES","order_count":2,"idv_obligations":929714.8,"idv_contracts_value":931419.55,"recipient":{"uei":"WRHHNUK5YBN1","display_name":"RIVIDIUM - INC."}},{"uuid":"a2304733-ca28-5157-b800-ace63deb1f79","key":"CONT_IDV_36C24723A0033_3600","piid":"36C24723A0033","award_date":"2023-06-23","title":"CONSIGNMENT - INVENTORY","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"KP5EVFMHUAN1","display_name":"ABBOTT - LABORATORIES INC."}},{"uuid":"8a721329-40f9-5ca3-858d-ed9a067371f2","key":"CONT_IDV_36C26022A0045_3600","piid":"36C26022A0045","award_date":"2022-09-12","title":"INTRA - OCULAR LENSE CONSIGNMENT AGREEMENT","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"G8XGKTUWPM14","display_name":"AMO - SALES AND SERVICE, INC."}},{"uuid":"113dac46-3903-5889-9fc1-d4a58c01c591","key":"CONT_IDV_36C24122A0070_3600","piid":"36C24122A0070","award_date":"2022-03-17","title":"BLANKET - PURCHASE AGREEMENT FOR SUPPLIES NATIONWIDE VHA","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"L3CLKHB2VE24","display_name":"Sage - Products, LLC"}},{"uuid":"040283f5-ed08-5434-9df4-907c7af62e4f","key":"CONT_IDV_36C25022A0009_3600","piid":"36C25022A0009","award_date":"2021-10-01","title":"COOK - MEDICAL LLC CONSIGNMENT - IMPLANTS","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"GG39AE315NK5","display_name":"COOK - MEDICAL LLC"}},{"uuid":"7fe6ae99-081f-5be6-bd03-6a9bbf354980","key":"CONT_IDV_36C25021A0056_3600","piid":"36C25021A0056","award_date":"2021-03-23","title":null,"order_count":5,"idv_obligations":128048.14,"idv_contracts_value":164888.14,"recipient":{"uei":"STNDUK44ENE8","display_name":"POLYMEDCO - LLC"}},{"uuid":"3ab4321d-76a5-58c2-aaf6-85321ccd1bc1","key":"CONT_IDV_36C24821A0018_3600","piid":"36C24821A0018","award_date":"2021-03-17","title":"STERNAL - PLATES AND SCREWS","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"JB1EA2TU8V88","display_name":"BIOMET - MICROFIXATION, LLC"}},{"uuid":"141b0632-e12b-5ac8-a862-869bc1bd9f87","key":"CONT_IDV_36C24821A0016_3600","piid":"36C24821A0016","award_date":"2021-02-15","title":"CONSIGNMENT - AGREEMENT - PERIPHERAL VASCULAR EMBOLIZATION PRODUCTS","order_count":0,"idv_obligations":0.0,"idv_contracts_value":0.0,"recipient":{"uei":"QLC9LYKADGX5","display_name":"PENUMBRA, - INC."}}]}' + string: '{"count":2,"next":null,"previous":null,"results":[{"award_date":"2006-06-01","idv_contracts_value":1183372.8,"idv_obligations":1455203.52,"key":"CONT_IDV_AG6395C060018_12K3","order_count":10,"piid":"AG6395C060018","recipient":{"uei":"GDVZPLNAKLL9","display_name":"STEVEN + INDUSTRIES INC"},"title":"CATTLE AND SWINE BACKTAG CEMENT","uuid":"af5f3527-b51b-55d8-9c6f-2d6aad936d31"},{"award_date":"2006-06-01","idv_contracts_value":3933361.17,"idv_obligations":3933361.17,"key":"CONT_IDV_AG6395C060019_12K3","order_count":15,"piid":"AG6395C060019","recipient":{"uei":"C1D7UA31LAK8","display_name":"M + J RUSCOE"},"title":"CATTLE AND SWINE BACKTAG CEMENT TO BE DELIVERED ON AN + IDIQ BASIS TO KS, MO.","uuid":"55cdcfa5-9b94-5aa5-8a8b-7a291d675e20"}]}' headers: CF-RAY: - - 9d71a7a00eb0a1c9-MSP + - 9f8300952b6ff4ae-ORD Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 04 Mar 2026 14:43:42 GMT + - Thu, 07 May 2026 20:33:53 GMT Nel: - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' Report-To: - - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=PA0MWV3V4YvOgApvlK%2B30Y0fxb8uTbbVuL9VqVukOJLOvEMUylvJhadJX%2BuzJ2P7b5Nd8l6H5tYqYB%2FRfPaJao1Je5g32SIOLm0i2Ohjsm7hv1lGzYI6qyl2"}]}' + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=PE6q1a8YczuiMmH0spHUNUtnHFOuMZxj6ojehjdW1WVv7Ed8PtJJWn1OsPvuDJW4xOfEadqYE%2B25TyIVe6MUMCz2h7yWDHXHlrFDE1u6spv1%2BuSY9B1o9tuvSAMSU0awObb0"}]}' Server: - cloudflare Transfer-Encoding: @@ -132,7 +119,7 @@ interactions: cf-cache-status: - DYNAMIC content-length: - - '3418' + - '739' cross-origin-opener-policy: - same-origin referrer-policy: @@ -142,27 +129,27 @@ interactions: x-content-type-options: - nosniff x-execution-time: - - 0.087s + - 0.032s x-frame-options: - DENY x-ratelimit-burst-limit: - '1000' x-ratelimit-burst-remaining: - - '907' + - '995' x-ratelimit-burst-reset: - - '6' + - '59' x-ratelimit-daily-limit: - '2000000' x-ratelimit-daily-remaining: - - '1999667' + - '1999834' x-ratelimit-daily-reset: - - '84966' + - '12366' x-ratelimit-limit: - '1000' x-ratelimit-remaining: - - '907' + - '995' x-ratelimit-reset: - - '6' + - '59' status: code: 200 message: OK diff --git a/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_uses_default_shape_and_search b/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_uses_default_shape_and_search index 12ed35c..562effb 100644 --- a/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_uses_default_shape_and_search +++ b/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_uses_default_shape_and_search @@ -13,33 +13,32 @@ interactions: user-agent: - python-httpx/0.28.1 method: GET - uri: https://tango.makegov.com/api/vehicles/?page=1&limit=10&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date&search=GSA + uri: https://tango.makegov.com/api/vehicles/?page=1&limit=10&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date&search=GSA response: body: - string: '{"count":73,"next":"https://tango.makegov.com/api/vehicles/?limit=10&page=2&search=GSA&shape=uuid%2Csolicitation_identifier%2Corganization_id%2Cawardee_count%2Corder_count%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"uuid":"6741a166-ae8e-57ba-b156-76f9137febad","solicitation_identifier":"15F06725Q0000150","organization_id":"08ba1358-7dad-5228-9540-0a3718bc621d","awardee_count":4,"order_count":5,"vehicle_obligations":3104682.7,"vehicle_contracts_value":29044858.96,"solicitation_title":"Curriculum - Development Synopsis/Solicitation - Updated GSA site","solicitation_date":"2025-04-10"},{"uuid":"1e03d563-afa5-565f-be04-ba64b0ec053c","solicitation_identifier":"2FYB-BJ-030001-B","organization_id":null,"awardee_count":46,"order_count":7458,"vehicle_obligations":139267557.14,"vehicle_contracts_value":607178722.14,"solicitation_title":"Cameras, - Photographic Printers and Related Supplies and Services","solicitation_date":"2001-03-01"},{"uuid":"6fea329b-c906-5857-8384-6451a4c93430","solicitation_identifier":"36C10X23Q0022","organization_id":"f6c88e61-3d34-5685-a5ab-670858289883","awardee_count":4,"order_count":14,"vehicle_obligations":2356056.0,"vehicle_contracts_value":2356056.0,"solicitation_title":"U008--Acquisition + string: '{"count":103,"next":"https://tango.makegov.com/api/vehicles/?limit=10&page=2&search=GSA&shape=uuid%2Csolicitation_identifier%2Cis_synthetic_solicitation%2Cprogram_acronym%2Corganization_id%2Cidv_count%2Cawardee_count%2Corder_count%2Ctotal_obligated%2Cvehicle_obligations%2Cvehicle_contracts_value%2Csolicitation_title%2Csolicitation_date","previous":null,"results":[{"awardee_count":0,"idv_count":7,"is_synthetic_solicitation":false,"order_count":0,"organization_id":"0ef2831b-d699-50c7-8326-63541a75fa11","program_acronym":"GSA","solicitation_date":null,"solicitation_identifier":"1232SA26Q0019","solicitation_title":null,"total_obligated":0.0,"uuid":"3fdd49c6-bf14-57b5-9b56-05a5d3adb35a","vehicle_contracts_value":0.0,"vehicle_obligations":0.0},{"awardee_count":2,"idv_count":4,"is_synthetic_solicitation":false,"order_count":5,"organization_id":"ea1c7a92-5838-5641-99ba-1527f4524306","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"15F06725Q0000150","solicitation_title":"Curriculum + Development Synopsis/Solicitation - Updated GSA site","total_obligated":3104682.7,"uuid":"6741a166-ae8e-57ba-b156-76f9137febad","vehicle_contracts_value":29044858.959999997,"vehicle_obligations":3104682.7},{"awardee_count":1,"idv_count":4,"is_synthetic_solicitation":false,"order_count":1,"organization_id":"5b904ace-8e3f-5909-a991-3e7297c5ffeb","program_acronym":"GSA","solicitation_date":null,"solicitation_identifier":"1605C2-24-Q-00026","solicitation_title":null,"total_obligated":50000.0,"uuid":"9f042142-a09b-5244-9b3d-a638a4e2f219","vehicle_contracts_value":739950.6,"vehicle_obligations":50000.0},{"awardee_count":3,"idv_count":4,"is_synthetic_solicitation":false,"order_count":14,"organization_id":"d8247025-812e-5403-954c-09577eab4a91","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"36C10X23Q0022","solicitation_title":"U008--Acquisition Workforce Training Multiple Award BPA - NEW GSA Contract Number Business Management - Research Associates, Inc","solicitation_date":"2024-04-05"},{"uuid":"e99312ce-b377-5fac-81ce-7f3f19b548c4","solicitation_identifier":"36C26120Q0012","organization_id":"f6c88e61-3d34-5685-a5ab-670858289883","awardee_count":3,"order_count":92,"vehicle_obligations":51415076.89,"vehicle_contracts_value":51498956.84,"solicitation_title":"Q301--Quest - FSS-BPA GSA Schedule 621 II","solicitation_date":"2020-09-28"},{"uuid":"26090608-dfc4-5245-8f1d-043f40d3cedd","solicitation_identifier":"3FNG-RG-020001-B","organization_id":"edfcdf18-d5d1-5e9a-8563-d8ed1f2844ce","awardee_count":111,"order_count":3766,"vehicle_obligations":231871038.22,"vehicle_contracts_value":235312118.92,"solicitation_title":"Professional - Audio/Video, Telemetry/Tracking, Recording, Reproducing and Signal Data Solutions","solicitation_date":"2010-11-30"},{"uuid":"1293190d-b755-5f61-a484-211f4425aad2","solicitation_identifier":"3FNJ-C1-000001-B","organization_id":null,"awardee_count":275,"order_count":51247,"vehicle_obligations":3570831390.91,"vehicle_contracts_value":11123777947.32,"solicitation_title":"Office, - Imaging and Document","solicitation_date":"1999-04-01"},{"uuid":"74ef741b-f0bc-506a-94cc-8252264a0237","solicitation_identifier":"3FNJ-C1-000001-B","organization_id":"edfcdf18-d5d1-5e9a-8563-d8ed1f2844ce","awardee_count":294,"order_count":10410,"vehicle_obligations":3351983917.63,"vehicle_contracts_value":7967591185.76,"solicitation_title":"Office, - Imaging and Document","solicitation_date":"2010-10-01"},{"uuid":"1f8a3ce7-a58c-5d36-907d-bf67f96e2cd4","solicitation_identifier":"3QSA-JB-100001-B","organization_id":"edfcdf18-d5d1-5e9a-8563-d8ed1f2844ce","awardee_count":497,"order_count":24312,"vehicle_obligations":2067723956.69,"vehicle_contracts_value":2149663259.46,"solicitation_title":"Furniture","solicitation_date":"2010-10-04"},{"uuid":"4b755976-75c2-573a-bd3a-92147261df2f","solicitation_identifier":"47PB0023R0012","organization_id":"17417361-31f1-5514-9623-5ef7b0ae02db","awardee_count":24,"order_count":47,"vehicle_obligations":22210407.27,"vehicle_contracts_value":22210407.27,"solicitation_title":"GSA - Region 1 Construction IDIQ - North/Boston/South Zones","solicitation_date":"2023-06-30"},{"uuid":"59cee065-88e2-5a98-be93-004df7e23d18","solicitation_identifier":"47PB0023R0059","organization_id":"17417361-31f1-5514-9623-5ef7b0ae02db","awardee_count":7,"order_count":18,"vehicle_obligations":1171435.78,"vehicle_contracts_value":1171435.78,"solicitation_title":"Architectural - and Engineering Multiple Award IDIQ New England","solicitation_date":"2023-09-13"}]}' + Research Associates, Inc","total_obligated":2359268.78,"uuid":"6fea329b-c906-5857-8384-6451a4c93430","vehicle_contracts_value":2359268.78,"vehicle_obligations":2359268.78},{"awardee_count":3,"idv_count":3,"is_synthetic_solicitation":false,"order_count":93,"organization_id":"2c45fd53-3590-5589-a188-1f34917b3de5","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"36C26120Q0012","solicitation_title":"Q301--Quest + FSS-BPA GSA Schedule 621 II","total_obligated":52052266.47,"uuid":"e99312ce-b377-5fac-81ce-7f3f19b548c4","vehicle_contracts_value":53523209.29,"vehicle_obligations":52052266.47},{"awardee_count":6,"idv_count":9,"is_synthetic_solicitation":false,"order_count":911,"organization_id":"6f5ca983-9b09-55b6-9ad0-f0ce1c2d06c1","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"47PA0519R0001","solicitation_title":"GSA + Leasing Support Services Plus","total_obligated":0.0,"uuid":"5dbdd43a-d8aa-56eb-a11f-78e0ded35642","vehicle_contracts_value":907.02,"vehicle_obligations":0.0},{"awardee_count":7,"idv_count":9,"is_synthetic_solicitation":false,"order_count":88,"organization_id":"949facff-0fda-5062-972b-01ad17491bc6","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"47PA0825R0002","solicitation_title":"FY25 + GSA Streamlined Retail Electric Solicitation","total_obligated":44802108.86,"uuid":"6aa9d616-4d60-52b3-bfb7-a4f4ba332a3c","vehicle_contracts_value":105773964.96,"vehicle_obligations":44802108.86},{"awardee_count":0,"idv_count":4,"is_synthetic_solicitation":false,"order_count":0,"organization_id":"949facff-0fda-5062-972b-01ad17491bc6","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"47PA0826R0001","solicitation_title":"FY26 + GSA Streamlined Retail Electric Solicitation","total_obligated":0.0,"uuid":"7c370689-aad8-5ba3-9498-c018bc5df121","vehicle_contracts_value":0.0,"vehicle_obligations":0.0},{"awardee_count":8,"idv_count":24,"is_synthetic_solicitation":false,"order_count":51,"organization_id":"2fd4b124-5131-5ab9-9091-e2ee27274d0e","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"47PB0023R0012","solicitation_title":"GSA + Region 1 Construction IDIQ - North/Boston/South Zones","total_obligated":24000257.07,"uuid":"4b755976-75c2-573a-bd3a-92147261df2f","vehicle_contracts_value":24000257.07,"vehicle_obligations":24000257.07},{"awardee_count":0,"idv_count":7,"is_synthetic_solicitation":false,"order_count":0,"organization_id":"54839cf5-aa68-5304-8f46-9aa63eaf2ba5","program_acronym":null,"solicitation_date":null,"solicitation_identifier":"47PC0222R0019","solicitation_title":"GSA + R2 - Caribbean Construction IDIQ","total_obligated":0.0,"uuid":"1a1704af-7d47-5293-b189-cf6a8c7ca35f","vehicle_contracts_value":0.0,"vehicle_obligations":0.0}]}' headers: CF-RAY: - - 9d71a7968b13fc89-MSP + - 9f83008f3b161070-ORD Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 04 Mar 2026 14:43:42 GMT + - Thu, 07 May 2026 20:33:52 GMT Nel: - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' Report-To: - - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=5%2BHepsdgjcU87kM%2BNnTgEm5bAgw8L9ZsZHeSXeBCTE%2BWnksTynGg8TJ56KoKVetk3pFnm%2Bz6OTI%2BHQ3lraKGlHtfXJrFZtMZU6d5rEzcXciLRMAIthAZ1V8Z"}]}' + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=rihwkPFkiAbPCEQx6pjfsn1vgHnLuQTlArCWUmyU9d6wt1zaCgpC4VF5MzXyMx7xiTtZtiUJTdG12U38zbbGGrgSSjK%2FJaIgNgIEKewQcq0POO1iQ4H8yvNkX35XMBKCZYWu"}]}' Server: - cloudflare Transfer-Encoding: @@ -49,7 +48,7 @@ interactions: cf-cache-status: - DYNAMIC content-length: - - '3950' + - '4766' cross-origin-opener-policy: - same-origin referrer-policy: @@ -59,27 +58,29 @@ interactions: x-content-type-options: - nosniff x-execution-time: - - 0.578s + - 0.024s x-frame-options: - DENY x-ratelimit-burst-limit: - '1000' x-ratelimit-burst-remaining: - - '911' + - '999' x-ratelimit-burst-reset: - - '7' + - '59' x-ratelimit-daily-limit: - '2000000' x-ratelimit-daily-remaining: - - '1999671' + - '1999838' x-ratelimit-daily-reset: - - '84967' + - '12367' x-ratelimit-limit: - '1000' x-ratelimit-remaining: - - '911' + - '999' x-ratelimit-reset: - - '7' + - '59' + x-results-counttype: + - exact status: code: 200 message: OK diff --git a/tests/integration/test_vehicles_idvs_integration.py b/tests/integration/test_vehicles_idvs_integration.py index 6f7cada..693ecb6 100644 --- a/tests/integration/test_vehicles_idvs_integration.py +++ b/tests/integration/test_vehicles_idvs_integration.py @@ -92,17 +92,23 @@ def test_get_vehicle_supports_joiner_and_flat_lists(self, tango_client): - flat, flat_lists, and joiner parameters work correctly - Vehicle is parsed correctly """ - # First, get a vehicle UUID from listing - list_response = tango_client.list_vehicles(limit=1) + # Find a vehicle that has an opportunity so the joiner/flat_lists + # behavior is meaningfully exercised below. Vehicle ordering is + # not guaranteed, and some vehicles legitimately have no opportunity. + list_response = tango_client.list_vehicles(limit=20, shape="uuid,opportunity(title)") if not list_response.results: pytest.skip("No vehicles available to test get_vehicle") - vehicle_uuid = ( - list_response.results[0].get("uuid") - if isinstance(list_response.results[0], dict) - else list_response.results[0].uuid - ) - assert vehicle_uuid is not None, "Vehicle UUID should be present" + vehicle_uuid = None + for result in list_response.results: + opp = result.get("opportunity") if isinstance(result, dict) else result.opportunity + if opp is None: + continue + vehicle_uuid = result.get("uuid") if isinstance(result, dict) else result.uuid + break + + if vehicle_uuid is None: + pytest.skip("No vehicle with an opportunity available to test joiner") # Test with flat, flat_lists, and joiner vehicle = tango_client.get_vehicle( From beb01e0e80ec8259aa52961808d576474703c53d Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 16:19:19 -0500 Subject: [PATCH 13/14] Release v0.6.0: dedupe merge artifacts and date the changelog - Promote CHANGELOG [Unreleased] -> [0.6.0] - 2026-05-07. - Drop duplicate VEHICLE_METRICS_SCHEMA introduced by auto-merge between PR #21 and PR #22 (both branches added the same 12-field schema). - Drop duplicate VEHICLE_SCHEMA keys (idv_count, organization, metrics) caused by both branches adding the same fields in different positions. Version was already bumped to 0.6.0 by PR #21. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- tango/shapes/explicit_schemas.py | 57 -------------------------------- 2 files changed, 1 insertion(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9fbcd..27f7efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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`. diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index afae722..77067e9 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -1051,48 +1051,6 @@ } -# Vehicle detail's bundled `metrics` object (12 lakehouse rollups from -# awards_vehicle_stats). All fields nullable because the stats companion -# is sparse during the bootstrap window between migrate and the first -# sync_vehicle_stats run. -VEHICLE_METRICS_SCHEMA: dict[str, FieldSchema] = { - "avg_offers_received": FieldSchema( - name="avg_offers_received", type=float, is_optional=True, is_list=False - ), - "award_concentration_hhi": FieldSchema( - name="award_concentration_hhi", type=float, is_optional=True, is_list=False - ), - "order_concentration_hhi": FieldSchema( - name="order_concentration_hhi", type=float, is_optional=True, is_list=False - ), - "competed_rate": FieldSchema(name="competed_rate", type=float, is_optional=True, is_list=False), - "using_agency_count": FieldSchema( - name="using_agency_count", type=int, is_optional=True, is_list=False - ), - "avg_order_value": FieldSchema( - name="avg_order_value", type=float, is_optional=True, is_list=False - ), - "max_order_value": FieldSchema( - name="max_order_value", type=float, is_optional=True, is_list=False - ), - "top_recipient_share": FieldSchema( - name="top_recipient_share", type=float, is_optional=True, is_list=False - ), - "recent_obligations_24mo": FieldSchema( - name="recent_obligations_24mo", type=float, is_optional=True, is_list=False - ), - "recent_orders_24mo": FieldSchema( - name="recent_orders_24mo", type=int, is_optional=True, is_list=False - ), - "days_since_last_order": FieldSchema( - name="days_since_last_order", type=int, is_optional=True, is_list=False - ), - "obligation_to_ceiling_ratio": FieldSchema( - name="obligation_to_ceiling_ratio", type=float, is_optional=True, is_list=False - ), -} - - VEHICLE_SCHEMA: dict[str, FieldSchema] = { "uuid": FieldSchema(name="uuid", type=str, is_optional=False, is_list=False), "solicitation_identifier": FieldSchema( @@ -1140,7 +1098,6 @@ # Denormalized rollups "idv_count": FieldSchema(name="idv_count", type=int, is_optional=True, is_list=False), "awardee_count": FieldSchema(name="awardee_count", type=int, is_optional=True, is_list=False), - "idv_count": FieldSchema(name="idv_count", type=int, is_optional=True, is_list=False), "order_count": FieldSchema(name="order_count", type=int, is_optional=True, is_list=False), "total_obligated": FieldSchema( name="total_obligated", type=Decimal, is_optional=True, is_list=False @@ -1180,20 +1137,6 @@ "opportunity": FieldSchema( name="opportunity", type=dict, is_optional=True, is_list=False, nested_model="Opportunity" ), - "organization": FieldSchema( - name="organization", - type=dict, - is_optional=True, - is_list=False, - nested_model="OrganizationOffice", - ), - "metrics": FieldSchema( - name="metrics", - type=dict, - is_optional=True, - is_list=False, - nested_model="VehicleMetrics", - ), "competition_details": FieldSchema( name="competition_details", type=dict, From 91f4336c3afee1d9e6873a2a67741e8acf10bbaa Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 7 May 2026 16:20:57 -0500 Subject: [PATCH 14/14] Fix joiner test post-merge: use PR #21's clean version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-merge between PR #21 and PR #22 created a Frankenstein `test_get_vehicle_supports_joiner_and_flat_lists` that combined PR #22's opportunity-finding setup with PR #21's organization-based assertion — the resulting list_vehicles call (`shape=opportunity(title)`) didn't match either PR's cassette recording. Reverting to PR #21's version: `list_vehicles(limit=1)` plus an organization-flatten check, which matches the merged-in cassette. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test_vehicles_idvs_integration.py | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/integration/test_vehicles_idvs_integration.py b/tests/integration/test_vehicles_idvs_integration.py index 1f0ff13..5802bd5 100644 --- a/tests/integration/test_vehicles_idvs_integration.py +++ b/tests/integration/test_vehicles_idvs_integration.py @@ -133,23 +133,17 @@ def test_get_vehicle_supports_joiner_and_flat_lists(self, tango_client): - flat, flat_lists, and joiner parameters work correctly - Vehicle is parsed correctly """ - # Find a vehicle that has an opportunity so the joiner/flat_lists - # behavior is meaningfully exercised below. Vehicle ordering is - # not guaranteed, and some vehicles legitimately have no opportunity. - list_response = tango_client.list_vehicles(limit=20, shape="uuid,opportunity(title)") + # First, get a vehicle UUID from listing + list_response = tango_client.list_vehicles(limit=1) if not list_response.results: pytest.skip("No vehicles available to test get_vehicle") - vehicle_uuid = None - for result in list_response.results: - opp = result.get("opportunity") if isinstance(result, dict) else result.opportunity - if opp is None: - continue - vehicle_uuid = result.get("uuid") if isinstance(result, dict) else result.uuid - break - - if vehicle_uuid is None: - pytest.skip("No vehicle with an opportunity available to test joiner") + vehicle_uuid = ( + list_response.results[0].get("uuid") + if isinstance(list_response.results[0], dict) + else list_response.results[0].uuid + ) + assert vehicle_uuid is not None, "Vehicle UUID should be present" # Test with flat, flat_lists, and joiner. Uses the post-cutover `organization` # leaf field (the prior `opportunity(...)` expansion is now deprecated).