Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.0] - 2026-04-08

### Added
- IT Dashboard investments: `list_itdashboard_investments`, `get_itdashboard_investment` (`/api/itdashboard/`) with shaping and filter params (`search`, `agency_code`, `agency_name`, `type_of_investment`, `updated_time_after`, `updated_time_before`, `cio_rating`, `cio_rating_max`, `performance_risk`). Tier-gated by the API: free tier gets `search`, pro adds structured filters, business+ adds CIO/performance analytics. New `ITDashboardInvestment` model and `ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL` / `ITDASHBOARD_INVESTMENTS_COMPREHENSIVE` defaults.

## [0.4.4] - 2026-03-25

### Added
- `parent_piid` filter parameter on `list_contracts` for filtering orders under a specific parent IDV PIID.
- `user_agent` and `extra_headers` parameters on `TangoClient` for custom request headers.
- `TangoClient.last_response_headers` property for accessing full HTTP headers from the most recent API response.

## [0.4.3] - 2026-03-21

### Added
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.4.3"
version = "0.5.0"
description = "Python SDK for the Tango API"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
12 changes: 12 additions & 0 deletions scripts/check_filter_shape_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"agencies": "list_agencies",
"naics": "list_naics",
"gsa_elibrary_contracts": "list_gsa_elibrary_contracts",
"itdashboard": "list_itdashboard_investments",
# Resources not yet implemented in SDK
"offices": None,
}
Expand All @@ -66,6 +67,7 @@ def get_shape_config_entries() -> list[tuple[str, str, type[Any]]]:
Forecast,
Grant,
GsaElibraryContract,
ITDashboardInvestment,
Notice,
Opportunity,
Organization,
Expand Down Expand Up @@ -98,6 +100,16 @@ def get_shape_config_entries() -> list[tuple[str, str, type[Any]]]:
ShapeConfig.GSA_ELIBRARY_CONTRACTS_MINIMAL,
GsaElibraryContract,
),
(
"ITDASHBOARD_INVESTMENTS_MINIMAL",
ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL,
ITDashboardInvestment,
),
(
"ITDASHBOARD_INVESTMENTS_COMPREHENSIVE",
ShapeConfig.ITDASHBOARD_INVESTMENTS_COMPREHENSIVE,
ITDashboardInvestment,
),
]
for name, shape_str, model_cls in configs:
entries.append((name, shape_str, model_cls))
Expand Down
4 changes: 3 additions & 1 deletion tango/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
)
from .models import (
GsaElibraryContract,
ITDashboardInvestment,
PaginatedResponse,
RateLimitInfo,
SearchFilters,
Expand All @@ -28,7 +29,7 @@
TypeGenerator,
)

__version__ = "0.4.3"
__version__ = "0.5.0"
__all__ = [
"TangoClient",
"TangoAPIError",
Expand All @@ -38,6 +39,7 @@
"TangoRateLimitError",
"RateLimitInfo",
"GsaElibraryContract",
"ITDashboardInvestment",
"PaginatedResponse",
"SearchFilters",
"ShapeConfig",
Expand Down
122 changes: 122 additions & 0 deletions tango/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
Forecast,
Grant,
GsaElibraryContract,
ITDashboardInvestment,
Location,
Notice,
Opportunity,
Expand Down Expand Up @@ -59,6 +60,8 @@ def __init__(
self,
api_key: str | None = None,
base_url: str = "https://tango.makegov.com",
user_agent: str | None = None,
extra_headers: dict[str, str] | None = None,
):
"""
Initialize the Tango API client
Expand All @@ -67,6 +70,8 @@ def __init__(
api_key: API key for authentication. If not provided, will attempt to load from
TANGO_API_KEY environment variable.
base_url: Base URL for the API
user_agent: Custom User-Agent header value.
extra_headers: Additional headers to include in every request.
"""
# Load API key from environment if not provided
self.api_key = api_key or os.getenv("TANGO_API_KEY")
Expand All @@ -76,9 +81,14 @@ def __init__(
headers = {}
if self.api_key:
headers["X-API-KEY"] = self.api_key
if user_agent:
headers["User-Agent"] = user_agent
if extra_headers:
headers.update(extra_headers)

self.client = httpx.Client(headers=headers, timeout=30.0)
self._last_rate_limit_info: RateLimitInfo | None = None
self._last_response_headers: httpx.Headers | None = None

# Use hardcoded sensible defaults
cache_size = 100
Expand All @@ -105,6 +115,11 @@ def rate_limit_info(self) -> RateLimitInfo | None:
"""Rate limit info from the most recent API response."""
return self._last_rate_limit_info

@property
def last_response_headers(self) -> httpx.Headers | None:
"""Full HTTP headers from the most recent API response."""
return self._last_response_headers

@staticmethod
def _parse_rate_limit_headers(headers: httpx.Headers) -> RateLimitInfo:
"""Extract rate limit info from response headers."""
Expand Down Expand Up @@ -140,6 +155,7 @@ def _request(

try:
response = self.client.request(method=method, url=url, params=params, json=json_data)
self._last_response_headers = response.headers
self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers)

if response.status_code == 401:
Expand Down Expand Up @@ -1321,6 +1337,112 @@ def get_gsa_elibrary_contract(
data, shape, GsaElibraryContract, flat, flat_lists, joiner=joiner
)

# ============================================================================
# IT Dashboard Investments
# ============================================================================

def list_itdashboard_investments(
self,
page: int = 1,
limit: int = 25,
shape: str | None = None,
flat: bool = False,
flat_lists: bool = False,
joiner: str = ".",
search: str | None = None,
agency_code: int | None = None,
agency_name: str | None = None,
type_of_investment: str | None = None,
updated_time_after: str | date | datetime | None = None,
updated_time_before: str | date | datetime | None = None,
cio_rating: int | None = None,
cio_rating_max: int | None = None,
performance_risk: bool | None = None,
) -> PaginatedResponse:
"""List federal IT investments from the IT Dashboard (`/api/itdashboard/`).

Filters are tier-gated by the API:

- **Free**: ``search`` (full-text across UII, title, description, agency, bureau)
- **Pro**: ``agency_code``, ``type_of_investment``,
``updated_time_after`` / ``updated_time_before``
- **Business+**: ``agency_name`` (text), ``cio_rating``,
``cio_rating_max``, ``performance_risk``

Hitting a gated filter on a lower tier returns a 403 with upgrade info.

CIO ratings: 1=High Risk, 2=Moderately High, 3=Medium, 4=Moderately Low, 5=Low.
``performance_risk=True`` returns investments with at least one NOT MET metric.
"""
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
if shape is None:
shape = ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL
if shape:
params["shape"] = shape
if flat:
params["flat"] = "true"
if joiner:
params["joiner"] = joiner
if flat_lists:
params["flat_lists"] = "true"
for k, val in (
("search", search),
("agency_code", agency_code),
("agency_name", agency_name),
("type_of_investment", type_of_investment),
("updated_time_after", updated_time_after),
("updated_time_before", updated_time_before),
("cio_rating", cio_rating),
("cio_rating_max", cio_rating_max),
("performance_risk", performance_risk),
):
if val is None:
continue
if isinstance(val, bool):
params[k] = "true" if val else "false"
elif isinstance(val, (date, datetime)):
params[k] = val.isoformat()
else:
params[k] = val
data = self._get("/api/itdashboard/", params)
results = [
self._parse_response_with_shape(
obj, shape, ITDashboardInvestment, flat, flat_lists, joiner=joiner
)
for obj in data.get("results", [])
]
return PaginatedResponse(
count=data.get("count", 0),
next=data.get("next"),
previous=data.get("previous"),
results=results,
)

def get_itdashboard_investment(
self,
uii: str,
shape: str | None = None,
flat: bool = False,
flat_lists: bool = False,
joiner: str = ".",
) -> Any:
"""Get a single IT Dashboard investment by UII (`/api/itdashboard/{uii}/`)."""
params: dict[str, Any] = {}
if shape is None:
shape = ShapeConfig.ITDASHBOARD_INVESTMENTS_COMPREHENSIVE
if shape:
params["shape"] = shape
if flat:
params["flat"] = "true"
if joiner:
params["joiner"] = joiner
if flat_lists:
params["flat_lists"] = "true"
data = self._get(f"/api/itdashboard/{uii}/", params)
return self._parse_response_with_shape(
data, shape, ITDashboardInvestment, flat, flat_lists, joiner=joiner
)

# ============================================================================
# Vehicles (Awards)
# ============================================================================
Expand Down
54 changes: 54 additions & 0 deletions tango/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,45 @@ class GsaElibraryContract:
sins: list[str] | None = None


@dataclass
class ITDashboardInvestment:
"""Schema definition for IT Dashboard Investment (not used for instances)

Federal IT investment from itdashboard.gov, exposed at /api/itdashboard/.
Identified by ``uii`` (Unique Investment Identifier).

Tier-gated shape expansions:
Free base fields only
Pro+ ``funding`` and ``details`` expansions
Business+ nested sub-tables (``cio_evaluation``, ``contracts``,
``projects``, ``cost_pools_towers``, ``funding_sources``,
``performance_metrics``, ``performance_actual``,
``operational_analysis``) and ``business_case_html``
"""

uii: str
agency_code: int | None = None
agency_name: str | None = None
bureau_code: int | None = None
bureau_name: str | None = None
investment_title: str | None = None
type_of_investment: str | None = None
part_of_it_portfolio: str | None = None
updated_time: datetime | None = None
url: str | None = None
business_case_html: str | None = None
funding: dict[str, Any] | None = None
details: dict[str, Any] | None = None
cio_evaluation: list[dict[str, Any]] | None = None
contracts: list[dict[str, Any]] | None = None
projects: list[dict[str, Any]] | None = None
cost_pools_towers: list[dict[str, Any]] | None = None
funding_sources: list[dict[str, Any]] | None = None
performance_metrics: list[dict[str, Any]] | None = None
performance_actual: list[dict[str, Any]] | None = None
operational_analysis: list[dict[str, Any]] | None = None


@dataclass
class Vehicle:
"""Schema definition for Vehicle (not used for instances)"""
Expand Down Expand Up @@ -687,3 +726,18 @@ class ShapeConfig:
GSA_ELIBRARY_CONTRACTS_MINIMAL: Final = (
"uuid,contract_number,schedule,recipient(display_name,uei),idv(key,award_date)"
)

# Default for list_itdashboard_investments()
# Free-tier safe: matches the API's INVESTMENT_LIST_DEFAULT_SHAPE.
ITDASHBOARD_INVESTMENTS_MINIMAL: Final = (
"uii,agency_name,bureau_name,investment_title,"
"type_of_investment,part_of_it_portfolio,updated_time,url"
)

# Default for get_itdashboard_investment()
# Free-tier safe: matches the API's INVESTMENT_RETRIEVE_DEFAULT_SHAPE.
ITDASHBOARD_INVESTMENTS_COMPREHENSIVE: Final = (
"uii,agency_code,agency_name,bureau_code,bureau_name,"
"investment_title,type_of_investment,part_of_it_portfolio,"
"updated_time,url"
)
Loading
Loading