Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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.
- `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
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,14 @@ otidvs = client.list_otidvs(limit=25)
### Vehicles

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

### Entities (Vendors/Recipients)
Expand Down
65 changes: 64 additions & 1 deletion docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,13 +503,14 @@ Vehicles provide a solicitation-centric way to discover groups of related IDVs a

### list_vehicles()

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

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

### list_vehicle_orders()

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

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

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

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

### Vehicle response fields

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

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

### Vehicle shape expansions

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

### Deprecated shape fields

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

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

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

---

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

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

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

## Basic Shaping

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion tango/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
RateLimitInfo,
SearchFilters,
ShapeConfig,
Vehicle,
VehicleMetrics,
WebhookEndpoint,
WebhookEventType,
WebhookEventTypesResponse,
Expand All @@ -29,7 +31,7 @@
TypeGenerator,
)

__version__ = "0.5.0"
__version__ = "0.6.0"
__all__ = [
"TangoClient",
"TangoAPIError",
Expand All @@ -43,6 +45,8 @@
"PaginatedResponse",
"SearchFilters",
"ShapeConfig",
"Vehicle",
"VehicleMetrics",
"WebhookEndpoint",
"WebhookEventType",
"WebhookEventTypesResponse",
Expand Down
98 changes: 97 additions & 1 deletion tango/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tango API Client"""

import os
import warnings
from datetime import date, datetime
from decimal import Decimal
from typing import Any
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -1473,6 +1517,8 @@ def list_vehicles(

if search:
params["search"] = search
if ordering:
params["ordering"] = ordering

data = self._get("/api/vehicles/", params)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"""
Expand Down
Loading
Loading