diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index daa55d7..43dbf9c 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -12,6 +12,12 @@ jobs:
steps:
- uses: actions/checkout@v4
+
+ - name: Checkout tango API repo (manifest source)
+ uses: actions/checkout@v4
+ with:
+ repository: makegov/tango
+ path: tango-api
- name: Install uv
uses: astral-sh/setup-uv@v4
@@ -29,3 +35,6 @@ jobs:
- name: Lint with ruff
run: uv run ruff check tango/
+
+ - name: Check SDK filter/shape conformance
+ run: uv run python scripts/check_filter_shape_conformance.py --manifest tango-api/contracts/filter_shape_contract.json
diff --git a/README.md b/README.md
index 3438105..f0945cd 100644
--- a/README.md
+++ b/README.md
@@ -432,8 +432,10 @@ tango-python/
│ └── quick_start.ipynb # Interactive quick start
├── scripts/ # Utility scripts
│ ├── README.md
+│ ├── check_filter_shape_conformance.py # Filter + shape conformance (CI)
│ ├── fetch_api_schema.py
-│ └── generate_schemas_from_api.py
+│ ├── generate_schemas_from_api.py
+│ └── pr_review.py # PR validation (lint, types, tests, conformance)
├── pyproject.toml # Project configuration
├── uv.lock # Dependency lock file
├── LICENSE # MIT License
@@ -471,7 +473,12 @@ Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
-3. Run tests (`uv run pytest`)
-4. Commit your changes (`git commit -m 'Add amazing feature'`)
-5. Push to the branch (`git push origin feature/amazing-feature`)
-6. Open a Pull Request
+3. Run lint and format: `uv run ruff format tango/ && uv run ruff check tango/`
+4. Run type checking: `uv run mypy tango/`
+5. Run tests: `uv run pytest`
+6. (Optional) Run [filter and shape conformance](scripts/README.md#filter-and-shape-conformance) if you have the tango API manifest; CI will run it on push/PR
+7. Commit your changes (`git commit -m 'Add amazing feature'`)
+8. Push to the branch (`git push origin feature/amazing-feature`)
+9. Open a Pull Request
+
+For a single command that runs formatting, linting, type checking, and tests (and conformance when the manifest is present), use: `uv run python scripts/pr_review.py --mode full`
diff --git a/ROADMAP.md b/ROADMAP.md
index 8043eef..125fe3d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,14 +2,7 @@
## Now
-- [ ] Align existing API to the SDK
- - [ ] Add in existing IDV endpoint
- - [ ] Add in existing OTA endpoint
- - [ ] Add in existing OTIDV endpoint
- - [ ] Add in existing financial assistance endpoint
- - [ ] Add in account usage endpoint
- - [ ] Add in webhooks endpoint
- - [ ] Add in subawards endpoint
+- [X] Align existing API to the SDK
- [ ] Better Filter DX
- [ ] Dataclasses for search validation and typing
- [ ] Docs improvements
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
index 1eef23b..1f12f2c 100644
--- a/docs/API_REFERENCE.md
+++ b/docs/API_REFERENCE.md
@@ -17,6 +17,7 @@ Complete reference for all Tango Python SDK methods and functionality.
- [Business Types](#business-types)
- [Webhooks](#webhooks)
- [Response Objects](#response-objects)
+- [ShapeConfig (predefined shapes)](#shapeconfig-predefined-shapes)
- [Error Handling](#error-handling)
## Client Initialization
@@ -904,6 +905,48 @@ print(f"Total collected: {len(all_results)} results")
---
+## ShapeConfig (predefined shapes)
+
+The SDK provides predefined shape strings as constants on `ShapeConfig`. Use them as the `shape` argument for list/get methods when you want a consistent, validated set of fields without building a custom shape string.
+
+```python
+from tango import TangoClient, ShapeConfig
+
+client = TangoClient()
+
+# List methods default to the minimal shape when shape is omitted
+contracts = client.list_contracts(limit=10) # uses CONTRACTS_MINIMAL
+
+# Or pass the constant explicitly
+contracts = client.list_contracts(shape=ShapeConfig.CONTRACTS_MINIMAL, limit=10)
+entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE)
+```
+
+**Available constants (by resource):**
+
+| Constant | Used by | Description |
+|----------|---------|-------------|
+| `CONTRACTS_MINIMAL` | `list_contracts`, `search_contracts` | key, piid, award_date, recipient(display_name), description, total_contract_value |
+| `ENTITIES_MINIMAL` | `list_entities` | uei, legal_business_name, cage_code, business_types |
+| `ENTITIES_COMPREHENSIVE` | `get_entity` | Full entity profile (addresses, naics, psc, obligations, etc.) |
+| `FORECASTS_MINIMAL` | `list_forecasts` | id, title, anticipated_award_date, fiscal_year, naics_code, status |
+| `OPPORTUNITIES_MINIMAL` | `list_opportunities` | opportunity_id, title, solicitation_number, response_deadline, active |
+| `NOTICES_MINIMAL` | `list_notices` | notice_id, title, solicitation_number, posted_date |
+| `GRANTS_MINIMAL` | `list_grants` | grant_id, opportunity_number, title, status(*), agency_code |
+| `IDVS_MINIMAL` | `list_idvs`, `list_vehicle_awardees` | key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated, idv_type |
+| `IDVS_COMPREHENSIVE` | `get_idv` | Full IDV with offices, place_of_performance, competition, transactions, etc. |
+| `VEHICLES_MINIMAL` | `list_vehicles` | uuid, solicitation_identifier, organization_id, awardee_count, order_count, vehicle_obligations, vehicle_contracts_value, solicitation_title, solicitation_date |
+| `VEHICLES_COMPREHENSIVE` | `get_vehicle` | Full vehicle with competition_details, fiscal_year, set_aside, etc. |
+| `VEHICLE_AWARDEES_MINIMAL` | `list_vehicle_awardees` | uuid, key, piid, award_date, title, order_count, idv_obligations, idv_contracts_value, recipient(display_name,uei) |
+| `ORGANIZATIONS_MINIMAL` | `list_organizations`, `list_organization_offices` | key, fh_key, name, level, type, short_name |
+| `OTAS_MINIMAL` | `list_otas` | key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated |
+| `OTIDVS_MINIMAL` | `list_otidvs` | key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated, idv_type |
+| `SUBAWARDS_MINIMAL` | `list_subawards` | award_key, prime_recipient(uei,display_name), subaward_recipient(uei,display_name) |
+
+All predefined shapes are validated at SDK release time (see [Developer Guide](DEVELOPERS.md#sdk-conformance-maintainers)). For custom shapes, see the [Shaping Guide](SHAPES.md).
+
+---
+
## Error Handling
The SDK provides specific exception types for different error scenarios.
@@ -1094,7 +1137,8 @@ client = TangoClient()
## Additional Resources
-- [Shaping Guide](SHAPES.md) - Comprehensive guide to response shaping
+- [Shaping Guide](SHAPES.md) - Response shaping syntax, examples, and field reference
+- [Developer Guide](DEVELOPERS.md) - Dynamic models, predefined shapes, and SDK conformance (maintainers)
- [Quick Start](quick_start.ipynb) - Interactive notebook with examples
- [GitHub Repository](https://github.com/makegov/tango-python) - Source code and examples
- [Tango API Documentation](https://tango.makegov.com/docs) - Full API documentation
diff --git a/docs/DEVELOPERS.md b/docs/DEVELOPERS.md
index f3cdbb6..f64b72e 100644
--- a/docs/DEVELOPERS.md
+++ b/docs/DEVELOPERS.md
@@ -12,6 +12,7 @@ The Tango SDK uses dynamic models that generate runtime types matching the exact
- [Type Hints and IDE Support](#type-hints-and-ide-support)
- [Performance Considerations](#performance-considerations)
- [Troubleshooting](#troubleshooting)
+- [SDK conformance (maintainers)](#sdk-conformance-maintainers)
## Overview
@@ -623,6 +624,32 @@ If you encounter issues:
3. See [examples/](../examples/) for working code samples
4. Contact support at [tango@makegov.com](mailto:tango@makegov.com)
+## SDK conformance (maintainers)
+
+The SDK is kept in sync with the Tango API and its own shape schemas via two conformance checks. Both run in CI on every push and PR (see [Lint workflow](../.github/workflows/lint.yml)) and can be run locally with [scripts/check_filter_shape_conformance.py](../scripts/check_filter_shape_conformance.py).
+
+### Filter conformance
+
+- **What it checks:** Every list/get endpoint in the canonical manifest (from the [tango](https://github.com/makegov/tango) API repo) has a matching SDK method that exposes the manifest’s filter parameters—either as explicit arguments or via the method’s `api_param_mapping`.
+- **Why it matters:** Ensures the SDK supports all query filters the API exposes for each resource, so users can filter without relying on undocumented `**kwargs`.
+- **Warnings:** Methods that take filters only via `**kwargs` are reported as warnings (filter names cannot be verified against the manifest).
+
+### Shape conformance
+
+- **What it checks:** Every predefined shape in `ShapeConfig` (e.g. `CONTRACTS_MINIMAL`, `IDVS_MINIMAL`, `GRANTS_MINIMAL`) is parsed and validated against the SDK’s explicit schemas in `tango/shapes/explicit_schemas.py`. Each shape must only reference fields that exist for that model (including nested fields).
+- **Why it matters:** Ensures default shapes never reference invalid or renamed fields, so default list/get behavior stays valid after schema or API changes.
+- **Errors:** Parse failures or invalid field names in any `ShapeConfig` constant are reported as errors and fail the check.
+
+### Running the conformance check
+
+- **In CI:** The [Lint workflow](../.github/workflows/lint.yml) runs the full check automatically (it has access to the manifest). No setup needed for push/PR.
+- **Locally:** You need the manifest file to run the script. If you have it (e.g. a path to `filter_shape_contract.json` from the [tango](https://github.com/makegov/tango) repo—wherever you keep that repo—or from a colleague), run:
+ ```bash
+ uv run python scripts/check_filter_shape_conformance.py --manifest /path/to/filter_shape_contract.json
+ ```
+ If you don’t have the manifest, CI will still run the full check on your branch; shape conformance is included whenever the script runs.
+- **Output:** JSON with `errors` and `warnings`. Exit code 1 if there are any errors. See [scripts/README.md](../scripts/README.md#filter-and-shape-conformance) for full usage and `--list-missing`.
+
## Next Steps
- Read the [API Reference](API_REFERENCE.md) for detailed class and method documentation
diff --git a/docs/SHAPES.md b/docs/SHAPES.md
index 2990cc1..49ffe4a 100644
--- a/docs/SHAPES.md
+++ b/docs/SHAPES.md
@@ -2,6 +2,8 @@
Response shaping lets you control which fields the API returns, making your requests faster and more efficient. Instead of receiving hundreds of fields you don't need, you specify exactly what you want.
+**See also:** [API Reference](API_REFERENCE.md) for method parameters and [ShapeConfig (predefined shapes)](API_REFERENCE.md#shapeconfig-predefined-shapes); [Developer Guide](DEVELOPERS.md) for dynamic models and maintainer conformance.
+
## Why Use Response Shaping?
**Performance Benefits:**
@@ -27,11 +29,36 @@ contracts = client.list_contracts(
shape="key,piid,recipient(display_name),total_contract_value"
)
+# Or use a predefined shape constant
+from tango import ShapeConfig
+contracts = client.list_contracts(shape=ShapeConfig.CONTRACTS_MINIMAL, limit=10)
+
# Access the data
for contract in contracts.results:
print(f"{contract['piid']}: {contract['recipient']['display_name']}")
```
+## Predefined shapes (ShapeConfig)
+
+Instead of writing shape strings by hand, you can use the SDK’s predefined constants. Each list/get method has a default minimal shape when you omit `shape`; you can also pass a constant explicitly.
+
+```python
+from tango import TangoClient, ShapeConfig
+
+client = TangoClient()
+
+# These are equivalent (list_contracts defaults to CONTRACTS_MINIMAL)
+contracts = client.list_contracts(limit=10)
+contracts = client.list_contracts(shape=ShapeConfig.CONTRACTS_MINIMAL, limit=10)
+
+# Other resources
+entities = client.list_entities(shape=ShapeConfig.ENTITIES_MINIMAL)
+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.
+
## Basic Shaping
### Simple Fields
@@ -455,5 +482,6 @@ display_name = contract.get('recipient', {}).get('display_name', 'Unknown')
- **Define patterns** - Create reusable shapes for your common queries
For more help, see:
-- [Quick Start Guide](quick_start.ipynb) - Interactive examples
-- [API Reference](API_REFERENCE.md) - Complete field listings
\ No newline at end of file
+- [API Reference](API_REFERENCE.md) - Method parameters, [ShapeConfig table](API_REFERENCE.md#shapeconfig-predefined-shapes), and field context
+- [Developer Guide](DEVELOPERS.md) - Dynamic models, predefined shapes in depth, and SDK conformance (for maintainers)
+- [Quick Start Guide](quick_start.ipynb) - Interactive examples
\ No newline at end of file
diff --git a/scripts/README.md b/scripts/README.md
index a901f93..94e463f 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -12,7 +12,8 @@ This directory contains utility scripts used during development and maintenance
### Testing and Validation
- **`test_production.py`** - Runs production API smoke tests against the live API
-- **`pr_review.py`** - Runs configurable validation checks for PR review (linting, type checking, tests)
+- **`pr_review.py`** - Runs configurable validation checks for PR review (linting, type checking, tests, conformance)
+- **`check_filter_shape_conformance.py`** - Validates SDK filter and shape conformance (see [Filter and shape conformance](#filter-and-shape-conformance))
## Usage
@@ -56,8 +57,10 @@ uv run python scripts/pr_review.py --mode production --changed-files-only
**Validation Modes:**
- `smoke` - Production API smoke tests only
- `quick` - Linting + type checking (no tests)
-- `full` - All checks (linting + type checking + all tests)
-- `production` - Production API smoke tests + linting + type checking (default)
+- `full` - All checks (linting + type checking + filter/shape conformance + all tests)
+- `production` - Linting + type checking + filter/shape conformance + production API smoke tests (default)
+
+When the conformance manifest is present (`tango-api/contracts/filter_shape_contract.json` or `TANGO_CONTRACT_MANIFEST`), both `full` and `production` run the [filter and shape conformance](#filter-and-shape-conformance) check. If the manifest is missing, that step is skipped with a warning.
**PR Detection:**
The script automatically detects PR information from:
@@ -70,6 +73,27 @@ When a PR is detected, the script displays PR information and automatically:
- Checks only changed files
- Shows PR URL in summary
+### Filter and shape conformance
+
+The SDK is validated against a canonical manifest (from the [tango](https://github.com/makegov/tango) API repo) for **filter conformance** and against its own schemas for **shape conformance**:
+
+1. **Filter conformance** – Ensures every list/get method exposes the filter parameters defined in the manifest (e.g. `award_date`, `naics_code`, `agency`). Methods that use `**kwargs` for filters are reported as warnings because their filter names cannot be verified.
+2. **Shape conformance** – Ensures every `ShapeConfig` constant (e.g. `CONTRACTS_MINIMAL`, `IDVS_MINIMAL`) parses correctly and only references fields that exist in the SDK’s explicit schemas for that model. Invalid or unknown fields in default shapes are reported as errors.
+
+This script runs in CI on every push/PR (see [Lint workflow](.github/workflows/lint.yml)). The manifest file is produced by the tango API repo and must be available for the full check.
+
+```bash
+# Full check (filter + shape). Requires manifest from tango API repo.
+uv run python scripts/check_filter_shape_conformance.py --manifest tango-api/contracts/filter_shape_contract.json
+
+# List resources that have no matching SDK method (for implementation checklist)
+uv run python scripts/check_filter_shape_conformance.py --manifest tango-api/contracts/filter_shape_contract.json --list-missing
+```
+
+**Output:** JSON with `manifest`, `errors`, and `warnings`. Exit code 1 if there are any errors (missing filters, invalid shapes, or missing SDK methods for manifest resources).
+
+**Local runs:** To run the full check locally, you need a copy of `filter_shape_contract.json` (from the [tango](https://github.com/makegov/tango) repo’s `contracts/` directory—wherever you keep that repo). Pass it with `--manifest` or set `TANGO_CONTRACT_MANIFEST`. The script runs both filter and shape conformance; shape conformance validates all `ShapeConfig` defaults against `tango/shapes/explicit_schemas.py`.
+
## Requirements
- `TANGO_API_KEY` environment variable (required for production API tests)
diff --git a/scripts/check_filter_shape_conformance.py b/scripts/check_filter_shape_conformance.py
new file mode 100644
index 0000000..302b69d
--- /dev/null
+++ b/scripts/check_filter_shape_conformance.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+"""
+Validate tango-python against a canonical filter/shape manifest.
+
+Runs two conformance checks:
+1. Filter conformance: SDK list/get methods expose the filter params from the manifest.
+2. Shape conformance: Every ShapeConfig constant parses and validates against the
+ SDK schema for its model (so default shapes only reference allowed fields).
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import json
+from pathlib import Path
+from typing import Any, Type
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+CLIENT_PATH = REPO_ROOT / "tango" / "client.py"
+
+
+def get_shape_config_entries() -> list[tuple[str, str, Type[Any]]]:
+ """Return (shape_name, shape_string, model_class) for every ShapeConfig constant."""
+ from tango.models import (
+ Contract,
+ Entity,
+ Forecast,
+ Grant,
+ IDV,
+ Notice,
+ OTA,
+ Organization,
+ Opportunity,
+ OTIDV,
+ ShapeConfig,
+ Subaward,
+ Vehicle,
+ )
+
+ # ShapeConfig constant name -> (shape string, model class for validation)
+ entries: list[tuple[str, str, Type[Any]]] = []
+ configs = [
+ ("CONTRACTS_MINIMAL", ShapeConfig.CONTRACTS_MINIMAL, Contract),
+ ("ENTITIES_MINIMAL", ShapeConfig.ENTITIES_MINIMAL, Entity),
+ ("ENTITIES_COMPREHENSIVE", ShapeConfig.ENTITIES_COMPREHENSIVE, Entity),
+ ("FORECASTS_MINIMAL", ShapeConfig.FORECASTS_MINIMAL, Forecast),
+ ("OPPORTUNITIES_MINIMAL", ShapeConfig.OPPORTUNITIES_MINIMAL, Opportunity),
+ ("NOTICES_MINIMAL", ShapeConfig.NOTICES_MINIMAL, Notice),
+ ("GRANTS_MINIMAL", ShapeConfig.GRANTS_MINIMAL, Grant),
+ ("IDVS_MINIMAL", ShapeConfig.IDVS_MINIMAL, IDV),
+ ("IDVS_COMPREHENSIVE", ShapeConfig.IDVS_COMPREHENSIVE, IDV),
+ ("VEHICLES_MINIMAL", ShapeConfig.VEHICLES_MINIMAL, Vehicle),
+ ("VEHICLES_COMPREHENSIVE", ShapeConfig.VEHICLES_COMPREHENSIVE, Vehicle),
+ ("VEHICLE_AWARDEES_MINIMAL", ShapeConfig.VEHICLE_AWARDEES_MINIMAL, IDV),
+ ("ORGANIZATIONS_MINIMAL", ShapeConfig.ORGANIZATIONS_MINIMAL, Organization),
+ ("OTAS_MINIMAL", ShapeConfig.OTAS_MINIMAL, OTA),
+ ("OTIDVS_MINIMAL", ShapeConfig.OTIDVS_MINIMAL, OTIDV),
+ ("SUBAWARDS_MINIMAL", ShapeConfig.SUBAWARDS_MINIMAL, Subaward),
+ ]
+ for name, shape_str, model_cls in configs:
+ entries.append((name, shape_str, model_cls))
+ return entries
+
+
+def run_shape_check() -> tuple[list[str], list[str]]:
+ """Validate all ShapeConfig constants against SDK schemas. Returns (errors, warnings)."""
+ from tango.exceptions import ShapeParseError, ShapeValidationError
+ from tango.shapes.parser import ShapeParser
+
+ errors: list[str] = []
+ warnings: list[str] = []
+ parser = ShapeParser(cache_enabled=True)
+
+ for shape_name, shape_string, model_class in get_shape_config_entries():
+ try:
+ shape_spec = parser.parse(shape_string)
+ parser.validate(shape_spec, model_class)
+ except ShapeParseError as e:
+ errors.append(f"shapes: `ShapeConfig.{shape_name}` parse error: {e}")
+ except ShapeValidationError as e:
+ errors.append(f"shapes: `ShapeConfig.{shape_name}` invalid: {e}")
+
+ return errors, warnings
+
+
+def parse_client_methods() -> dict[str, dict[str, Any]]:
+ tree = ast.parse(CLIENT_PATH.read_text(encoding="utf-8"), filename=str(CLIENT_PATH))
+ methods: dict[str, dict[str, Any]] = {}
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.FunctionDef):
+ continue
+ if not node.name.startswith(("list_", "get_")):
+ continue
+ args = [arg.arg for arg in node.args.args if arg.arg != "self"]
+ has_kwargs = node.args.kwarg is not None
+ mapping: dict[str, str] = {}
+ for child in ast.walk(node):
+ if isinstance(child, ast.Assign):
+ if len(child.targets) != 1 or not isinstance(child.targets[0], ast.Name):
+ continue
+ if child.targets[0].id != "api_param_mapping":
+ continue
+ if isinstance(child.value, ast.Dict):
+ for key, value in zip(child.value.keys, child.value.values):
+ if isinstance(key, ast.Constant) and isinstance(value, ast.Constant):
+ mapping[str(key.value)] = str(value.value)
+ methods[node.name] = {
+ "args": set(args),
+ "has_kwargs": has_kwargs,
+ "mapped_api_params": set(mapping.values()),
+ }
+ return methods
+
+
+def run_check(manifest_path: Path) -> tuple[list[str], list[str]]:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ resources = manifest.get("resources", {})
+ methods = parse_client_methods()
+
+ errors: list[str] = []
+ warnings: list[str] = []
+
+ for resource_name, payload in resources.items():
+ candidates = payload.get("sdk_method_candidates", []) or []
+ sdk_method = next((name for name in candidates if name in methods), None)
+ if not sdk_method:
+ if payload.get("runtime", {}).get("filter_params"):
+ warnings.append(
+ f"{resource_name}: no matching SDK method found among candidates: {', '.join(candidates) if candidates else '(none)'}"
+ )
+ continue
+
+ runtime_filters = set(payload.get("runtime", {}).get("filter_params", []))
+ method_info = methods[sdk_method]
+ exposed = set(method_info["args"]) | set(method_info["mapped_api_params"])
+
+ if method_info["has_kwargs"]:
+ missing_named = sorted(runtime_filters - exposed)
+ if missing_named:
+ warnings.append(
+ f"{resource_name}: `{sdk_method}` relies on **kwargs for filters: {', '.join(missing_named)}"
+ )
+ continue
+
+ missing = sorted(runtime_filters - exposed)
+ if missing:
+ errors.append(
+ f"{resource_name}: `{sdk_method}` missing runtime filters: {', '.join(missing)}"
+ )
+
+ return errors, warnings
+
+
+def get_missing_endpoints(manifest_path: Path) -> list[dict[str, Any]]:
+ """Return a list of resources that have no matching SDK method (for implementation checklist)."""
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ resources = manifest.get("resources", {})
+ methods = parse_client_methods()
+ missing: list[dict[str, Any]] = []
+ for resource_name, payload in resources.items():
+ candidates = payload.get("sdk_method_candidates", []) or []
+ sdk_method = next((name for name in candidates if name in methods), None)
+ if not sdk_method:
+ runtime = payload.get("runtime", {}) or {}
+ missing.append({
+ "resource": resource_name,
+ "sdk_method_candidates": candidates,
+ "filter_params": runtime.get("filter_params", []),
+ "pagination_class": (runtime.get("pagination") or {}).get("class", ""),
+ })
+ return missing
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Check SDK conformance to canonical manifest")
+ parser.add_argument(
+ "--manifest",
+ required=True,
+ help="Path to filter_shape_contract.json generated by tango",
+ )
+ parser.add_argument(
+ "--list-missing",
+ action="store_true",
+ help="Output machine-readable list of missing endpoints (resources with no SDK method) only",
+ )
+ args = parser.parse_args()
+
+ manifest_path = Path(args.manifest).resolve()
+ if not manifest_path.exists():
+ print(f"Manifest not found: {manifest_path}")
+ return 2
+
+ if args.list_missing:
+ missing = get_missing_endpoints(manifest_path)
+ print(json.dumps({"missing_endpoints": missing}, indent=2))
+ return 0
+
+ errors, warnings = run_check(manifest_path)
+
+ # Shape conformance: every ShapeConfig constant must validate against its model schema
+ shape_errors, shape_warnings = run_shape_check()
+ errors = list(errors) + shape_errors
+ warnings = list(warnings) + shape_warnings
+
+ # Treat "no matching SDK method" warnings as errors so CI enforces endpoint coverage
+ missing_method_warnings = [w for w in warnings if "no matching SDK method found" in w]
+ if missing_method_warnings:
+ errors = list(errors) + missing_method_warnings
+
+ report = {
+ "manifest": str(manifest_path),
+ "errors": errors,
+ "warnings": warnings,
+ }
+ print(json.dumps(report, indent=2))
+ return 1 if errors else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/pr_review.py b/scripts/pr_review.py
index 0352ba7..b5677a2 100755
--- a/scripts/pr_review.py
+++ b/scripts/pr_review.py
@@ -5,6 +5,7 @@
- Production API smoke tests
- Linting (ruff)
- Type checking (mypy)
+- Filter and shape conformance (when manifest is available)
- Full test suite
Automatically detects PR context from:
@@ -335,6 +336,25 @@ def run_production_tests() -> int:
return run_command(cmd, "Production API smoke tests", check=False)
+def run_conformance_check() -> int:
+ """Run filter and shape conformance. Skips if manifest not found."""
+ manifest_path = os.getenv("TANGO_CONTRACT_MANIFEST")
+ if not manifest_path:
+ manifest_path = str(project_root / "tango-api" / "contracts" / "filter_shape_contract.json")
+ path = Path(manifest_path)
+ if not path.exists():
+ print_warning("Conformance manifest not found - skipping filter/shape conformance")
+ print(f" Expected: {path}")
+ print(" Set TANGO_CONTRACT_MANIFEST or clone tango repo as tango-api/ (see scripts/README.md)")
+ return 0 # Don't fail when manifest is missing
+
+ cmd = [
+ "uv", "run", "python", "scripts/check_filter_shape_conformance.py",
+ "--manifest", manifest_path,
+ ]
+ return run_command(cmd, "Filter and shape conformance", check=False)
+
+
def run_all_tests() -> int:
"""Run full test suite"""
cmd = ["uv", "run", "pytest", "tests/", "-m", "not integration"]
@@ -441,6 +461,7 @@ def main() -> int:
exit_codes.append(run_formatting_check())
exit_codes.append(run_linting())
exit_codes.append(run_type_checking())
+ exit_codes.append(run_conformance_check())
exit_codes.append(run_all_tests())
exit_codes.append(run_integration_tests())
@@ -448,6 +469,7 @@ def main() -> int:
exit_codes.append(run_formatting_check(changed_files if use_changed_files else None))
exit_codes.append(run_linting(changed_files if use_changed_files else None))
exit_codes.append(run_type_checking(changed_files if use_changed_files else None))
+ exit_codes.append(run_conformance_check())
exit_codes.append(run_production_tests())
# Summary
diff --git a/tango/client.py b/tango/client.py
index 6f9aed6..a5b259f 100644
--- a/tango/client.py
+++ b/tango/client.py
@@ -17,6 +17,8 @@
)
from tango.models import (
IDV,
+ OTA,
+ OTIDV,
Agency,
BusinessType,
Contract,
@@ -26,9 +28,11 @@
Location,
Notice,
Opportunity,
+ Organization,
PaginatedResponse,
SearchFilters,
ShapeConfig,
+ Subaward,
Vehicle,
WebhookEndpoint,
WebhookEventType,
@@ -362,9 +366,13 @@ def _parse_location(self, data: dict[str, Any] | None) -> Location | None:
# ============================================================================
# Agency endpoints
- def list_agencies(self, page: int = 1, limit: int = 25) -> PaginatedResponse:
+ def list_agencies(
+ self, page: int = 1, limit: int = 25, search: str | None = None
+ ) -> PaginatedResponse:
"""List all agencies"""
- params = {"page": page, "limit": min(limit, 100)}
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
+ if search:
+ params["search"] = search
data = self._get("/api/agencies/", params)
return PaginatedResponse(
count=data["count"],
@@ -389,6 +397,96 @@ def get_agency(self, code: str) -> Agency:
raise TangoNotFoundError(f"Agency '{code}' not found", 404)
return agency
+ def list_offices(
+ self,
+ page: int = 1,
+ limit: int = 25,
+ search: str | None = None,
+ ) -> PaginatedResponse:
+ """List offices (`/api/offices/`)."""
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
+ if search is not None:
+ params["search"] = search
+ data = self._get("/api/offices/", params)
+ return PaginatedResponse(
+ count=data.get("count", 0),
+ next=data.get("next"),
+ previous=data.get("previous"),
+ results=data.get("results", []),
+ )
+
+ def get_office(self, code: str) -> dict[str, Any]:
+ """Get a single office by code (`/api/offices/{code}/`)."""
+ return self._get(f"/api/offices/{code}/")
+
+ def list_organizations(
+ self,
+ page: int = 1,
+ limit: int = 25,
+ shape: str | None = None,
+ flat: bool = False,
+ flat_lists: bool = False,
+ cgac: str | None = None,
+ include_inactive: bool | None = None,
+ level: int | None = None,
+ parent: str | None = None,
+ search: str | None = None,
+ type: str | None = None,
+ ) -> PaginatedResponse:
+ """List organizations (`/api/organizations/`)."""
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
+ if shape is None:
+ shape = ShapeConfig.ORGANIZATIONS_MINIMAL
+ if shape:
+ params["shape"] = shape
+ if flat:
+ params["flat"] = "true"
+ if flat_lists:
+ params["flat_lists"] = "true"
+ if cgac is not None:
+ params["cgac"] = cgac
+ if include_inactive is not None:
+ params["include_inactive"] = include_inactive
+ if level is not None:
+ params["level"] = level
+ if parent is not None:
+ params["parent"] = parent
+ if search is not None:
+ params["search"] = search
+ if type is not None:
+ params["type"] = type
+ data = self._get("/api/organizations/", params)
+ results = [
+ self._parse_response_with_shape(obj, shape, Organization, flat, flat_lists)
+ 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_organization(
+ self,
+ fh_key: str,
+ shape: str | None = None,
+ flat: bool = False,
+ flat_lists: bool = False,
+ ) -> Any:
+ """Get a single organization by fh_key (`/api/organizations/{fh_key}/`)."""
+ params: dict[str, Any] = {}
+ if shape is None:
+ shape = ShapeConfig.ORGANIZATIONS_MINIMAL
+ if shape:
+ params["shape"] = shape
+ if flat:
+ params["flat"] = "true"
+ if flat_lists:
+ params["flat_lists"] = "true"
+ data = self._get(f"/api/organizations/{fh_key}/", params)
+ return self._parse_response_with_shape(data, shape, Organization, flat, flat_lists)
+
# Contract endpoints
def list_contracts(
self,
@@ -398,7 +496,7 @@ def list_contracts(
flat: bool = False,
flat_lists: bool = False,
filters: SearchFilters | dict[str, Any] | None = None,
- **kwargs,
+ **kwargs: Any,
) -> PaginatedResponse:
"""
List contracts with optional filtering
@@ -600,7 +698,7 @@ def list_idvs(
flat: bool = False,
flat_lists: bool = False,
joiner: str = ".",
- **filters,
+ **filters: Any,
) -> PaginatedResponse:
"""
List IDVs (indefinite delivery vehicles) with keyset pagination.
@@ -675,7 +773,7 @@ def list_idv_awards(
flat_lists: bool = False,
joiner: str = ".",
filters: SearchFilters | dict[str, Any] | None = None,
- **kwargs,
+ **kwargs: Any,
) -> PaginatedResponse:
"""
List child awards (contracts) under an IDV (`/api/idvs/{key}/awards/`).
@@ -751,7 +849,7 @@ def list_idv_child_idvs(
flat: bool = False,
flat_lists: bool = False,
joiner: str = ".",
- **filters,
+ **filters: Any,
) -> PaginatedResponse:
"""List child IDVs under an IDV (`/api/idvs/{key}/idvs/`)."""
params: dict[str, Any] = {"limit": min(limit, 100)}
@@ -828,6 +926,268 @@ def list_idv_summary_awards(
page_metadata=data.get("page_metadata"),
)
+ def list_otas(
+ self,
+ limit: int = 25,
+ cursor: str | None = None,
+ shape: str | None = None,
+ flat: bool = False,
+ flat_lists: bool = False,
+ joiner: str = ".",
+ award_date: str | None = None,
+ award_date_gte: str | None = None,
+ award_date_lte: str | None = None,
+ awarding_agency: str | None = None,
+ expiring_gte: str | None = None,
+ expiring_lte: str | None = None,
+ fiscal_year: int | None = None,
+ fiscal_year_gte: int | None = None,
+ fiscal_year_lte: int | None = None,
+ funding_agency: str | None = None,
+ ordering: str | None = None,
+ piid: str | None = None,
+ pop_end_date_gte: str | None = None,
+ pop_end_date_lte: str | None = None,
+ pop_start_date_gte: str | None = None,
+ pop_start_date_lte: str | None = None,
+ psc: str | None = None,
+ recipient: str | None = None,
+ search: str | None = None,
+ uei: str | None = None,
+ ) -> PaginatedResponse:
+ """List OTAs (Other Transaction Agreements) (`/api/otas/`). Keyset pagination."""
+ params: dict[str, Any] = {"limit": min(limit, 100)}
+ if cursor:
+ params["cursor"] = cursor
+ if shape is None:
+ shape = ShapeConfig.OTAS_MINIMAL
+ if shape:
+ params["shape"] = shape
+ if flat:
+ params["flat"] = "true"
+ if joiner:
+ params["joiner"] = joiner
+ if flat_lists:
+ params["flat_lists"] = "true"
+ for key, val in (
+ ("award_date", award_date),
+ ("award_date_gte", award_date_gte),
+ ("award_date_lte", award_date_lte),
+ ("awarding_agency", awarding_agency),
+ ("expiring_gte", expiring_gte),
+ ("expiring_lte", expiring_lte),
+ ("fiscal_year", fiscal_year),
+ ("fiscal_year_gte", fiscal_year_gte),
+ ("fiscal_year_lte", fiscal_year_lte),
+ ("funding_agency", funding_agency),
+ ("ordering", ordering),
+ ("piid", piid),
+ ("pop_end_date_gte", pop_end_date_gte),
+ ("pop_end_date_lte", pop_end_date_lte),
+ ("pop_start_date_gte", pop_start_date_gte),
+ ("pop_start_date_lte", pop_start_date_lte),
+ ("psc", psc),
+ ("recipient", recipient),
+ ("search", search),
+ ("uei", uei),
+ ):
+ if val is not None:
+ params[key] = val
+ data = self._get("/api/otas/", params)
+ raw_results = data.get("results") or []
+ results = [
+ self._parse_response_with_shape(obj, shape, OTA, flat, flat_lists, joiner=joiner)
+ for obj in raw_results
+ ]
+ return PaginatedResponse(
+ count=int(data.get("count") or len(results)),
+ next=data.get("next"),
+ previous=data.get("previous"),
+ results=results,
+ cursor=data.get("cursor"),
+ page_metadata=data.get("page_metadata"),
+ )
+
+ def get_ota(
+ self,
+ key: str,
+ shape: str | None = None,
+ flat: bool = False,
+ flat_lists: bool = False,
+ joiner: str = ".",
+ ) -> Any:
+ """Get a single OTA by key (`/api/otas/{key}/`)."""
+ params: dict[str, Any] = {}
+ if shape is None:
+ shape = ShapeConfig.OTAS_MINIMAL
+ 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/otas/{key}/", params)
+ return self._parse_response_with_shape(data, shape, OTA, flat, flat_lists, joiner=joiner)
+
+ def list_otidvs(
+ self,
+ limit: int = 25,
+ cursor: str | None = None,
+ shape: str | None = None,
+ flat: bool = False,
+ flat_lists: bool = False,
+ joiner: str = ".",
+ award_date: str | None = None,
+ award_date_gte: str | None = None,
+ award_date_lte: str | None = None,
+ awarding_agency: str | None = None,
+ expiring_gte: str | None = None,
+ expiring_lte: str | None = None,
+ fiscal_year: int | None = None,
+ fiscal_year_gte: int | None = None,
+ fiscal_year_lte: int | None = None,
+ funding_agency: str | None = None,
+ ordering: str | None = None,
+ piid: str | None = None,
+ pop_end_date_gte: str | None = None,
+ pop_end_date_lte: str | None = None,
+ pop_start_date_gte: str | None = None,
+ pop_start_date_lte: str | None = None,
+ psc: str | None = None,
+ recipient: str | None = None,
+ search: str | None = None,
+ uei: str | None = None,
+ ) -> PaginatedResponse:
+ """List OTIDVs (Other Transaction IDVs) (`/api/otidvs/`). Keyset pagination."""
+ params: dict[str, Any] = {"limit": min(limit, 100)}
+ if cursor:
+ params["cursor"] = cursor
+ if shape is None:
+ shape = ShapeConfig.OTIDVS_MINIMAL
+ if shape:
+ params["shape"] = shape
+ if flat:
+ params["flat"] = "true"
+ if joiner:
+ params["joiner"] = joiner
+ if flat_lists:
+ params["flat_lists"] = "true"
+ for key, val in (
+ ("award_date", award_date),
+ ("award_date_gte", award_date_gte),
+ ("award_date_lte", award_date_lte),
+ ("awarding_agency", awarding_agency),
+ ("expiring_gte", expiring_gte),
+ ("expiring_lte", expiring_lte),
+ ("fiscal_year", fiscal_year),
+ ("fiscal_year_gte", fiscal_year_gte),
+ ("fiscal_year_lte", fiscal_year_lte),
+ ("funding_agency", funding_agency),
+ ("ordering", ordering),
+ ("piid", piid),
+ ("pop_end_date_gte", pop_end_date_gte),
+ ("pop_end_date_lte", pop_end_date_lte),
+ ("pop_start_date_gte", pop_start_date_gte),
+ ("pop_start_date_lte", pop_start_date_lte),
+ ("psc", psc),
+ ("recipient", recipient),
+ ("search", search),
+ ("uei", uei),
+ ):
+ if val is not None:
+ params[key] = val
+ data = self._get("/api/otidvs/", params)
+ raw_results = data.get("results") or []
+ results = [
+ self._parse_response_with_shape(obj, shape, OTIDV, flat, flat_lists, joiner=joiner)
+ for obj in raw_results
+ ]
+ return PaginatedResponse(
+ count=int(data.get("count") or len(results)),
+ next=data.get("next"),
+ previous=data.get("previous"),
+ results=results,
+ cursor=data.get("cursor"),
+ page_metadata=data.get("page_metadata"),
+ )
+
+ def get_otidv(
+ self,
+ key: str,
+ shape: str | None = None,
+ flat: bool = False,
+ flat_lists: bool = False,
+ joiner: str = ".",
+ ) -> Any:
+ """Get a single OTIDV by key (`/api/otidvs/{key}/`)."""
+ params: dict[str, Any] = {}
+ if shape is None:
+ shape = ShapeConfig.OTIDVS_MINIMAL
+ 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/otidvs/{key}/", params)
+ return self._parse_response_with_shape(data, shape, OTIDV, flat, flat_lists, joiner=joiner)
+
+ def list_subawards(
+ self,
+ page: int = 1,
+ limit: int = 25,
+ shape: str | None = None,
+ flat: bool = False,
+ flat_lists: bool = False,
+ award_key: str | None = None,
+ awarding_agency: str | None = None,
+ fiscal_year: int | None = None,
+ fiscal_year_gte: int | None = None,
+ fiscal_year_lte: int | None = None,
+ funding_agency: str | None = None,
+ prime_uei: str | None = None,
+ recipient: str | None = None,
+ sub_uei: str | None = None,
+ ) -> PaginatedResponse:
+ """List subawards (`/api/subawards/`)."""
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
+ if shape is None:
+ shape = ShapeConfig.SUBAWARDS_MINIMAL
+ if shape:
+ params["shape"] = shape
+ if flat:
+ params["flat"] = "true"
+ if flat_lists:
+ params["flat_lists"] = "true"
+ for key, val in (
+ ("award_key", award_key),
+ ("awarding_agency", awarding_agency),
+ ("fiscal_year", fiscal_year),
+ ("fiscal_year_gte", fiscal_year_gte),
+ ("fiscal_year_lte", fiscal_year_lte),
+ ("funding_agency", funding_agency),
+ ("prime_uei", prime_uei),
+ ("recipient", recipient),
+ ("sub_uei", sub_uei),
+ ):
+ if val is not None:
+ params[key] = val
+ data = self._get("/api/subawards/", params)
+ results = [
+ self._parse_response_with_shape(obj, shape, Subaward, flat, flat_lists)
+ for obj in data.get("results", [])
+ ]
+ return PaginatedResponse(
+ count=data.get("count", 0),
+ next=data.get("next"),
+ previous=data.get("previous"),
+ results=results,
+ )
+
# ============================================================================
# Vehicles (Awards)
# ============================================================================
@@ -957,6 +1317,42 @@ def list_business_types(self, page: int = 1, limit: int = 25) -> PaginatedRespon
results=[BusinessType(**btype) for btype in data["results"]],
)
+ def list_naics(
+ self,
+ page: int = 1,
+ limit: int = 25,
+ employee_limit: int | None = None,
+ employee_limit_gte: int | None = None,
+ employee_limit_lte: int | None = None,
+ revenue_limit: int | None = None,
+ revenue_limit_gte: int | None = None,
+ revenue_limit_lte: int | None = None,
+ search: str | None = None,
+ ) -> PaginatedResponse:
+ """List NAICS codes (`/api/naics/`)."""
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
+ if employee_limit is not None:
+ params["employee_limit"] = employee_limit
+ if employee_limit_gte is not None:
+ params["employee_limit_gte"] = employee_limit_gte
+ if employee_limit_lte is not None:
+ params["employee_limit_lte"] = employee_limit_lte
+ if revenue_limit is not None:
+ params["revenue_limit"] = revenue_limit
+ if revenue_limit_gte is not None:
+ params["revenue_limit_gte"] = revenue_limit_gte
+ if revenue_limit_lte is not None:
+ params["revenue_limit_lte"] = revenue_limit_lte
+ if search is not None:
+ params["search"] = search
+ data = self._get("/api/naics/", params)
+ return PaginatedResponse(
+ count=data.get("count", 0),
+ next=data.get("next"),
+ previous=data.get("previous"),
+ results=data.get("results", []),
+ )
+
# Entity endpoints
def list_entities(
self,
@@ -966,7 +1362,7 @@ def list_entities(
flat: bool = False,
flat_lists: bool = False,
search: str | None = None,
- **filters,
+ **filters: Any,
) -> PaginatedResponse:
"""
List entities (vendors/recipients)
@@ -980,7 +1376,7 @@ def list_entities(
search: Search query (maps to 'q' parameter)
**filters: Additional filter parameters (uei, cage_code, etc.)
"""
- params = {"page": page, "limit": min(limit, 100)}
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
# Add shape parameter with default minimal shape
if shape is None:
@@ -1046,7 +1442,7 @@ def list_forecasts(
shape: str | None = None,
flat: bool = False,
flat_lists: bool = False,
- **filters,
+ **filters: Any,
) -> PaginatedResponse:
"""
List contract forecasts
@@ -1059,7 +1455,7 @@ def list_forecasts(
flat_lists: If True, flatten arrays using indexed keys
**filters: Additional filter parameters
"""
- params = {"page": page, "limit": min(limit, 100)}
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
# Add shape parameter with default minimal shape
if shape is None:
@@ -1096,7 +1492,7 @@ def list_opportunities(
shape: str | None = None,
flat: bool = False,
flat_lists: bool = False,
- **filters,
+ **filters: Any,
) -> PaginatedResponse:
"""
List contract opportunities/solicitations
@@ -1109,7 +1505,7 @@ def list_opportunities(
flat_lists: If True, flatten arrays using indexed keys
**filters: Additional filter parameters
"""
- params = {"page": page, "limit": min(limit, 100)}
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
# Add shape parameter with default minimal shape
if shape is None:
@@ -1146,7 +1542,7 @@ def list_notices(
shape: str | None = None,
flat: bool = False,
flat_lists: bool = False,
- **filters,
+ **filters: Any,
) -> PaginatedResponse:
"""
List contract notices
@@ -1161,7 +1557,7 @@ def list_notices(
flat_lists: If True, flatten arrays using indexed keys
**filters: Additional filter parameters
"""
- params = {"page": page, "limit": min(limit, 100)}
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
# Add shape parameter with default minimal shape
if shape is None:
@@ -1198,7 +1594,7 @@ def list_grants(
shape: str | None = None,
flat: bool = False,
flat_lists: bool = False,
- **filters,
+ **filters: Any,
) -> PaginatedResponse:
"""
List grants
@@ -1213,7 +1609,7 @@ def list_grants(
flat_lists: If True, flatten arrays using indexed keys
**filters: Additional filter parameters
"""
- params = {"page": page, "limit": min(limit, 100)}
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
# Add shape parameter with default minimal shape
if shape is None:
@@ -1242,6 +1638,47 @@ def list_grants(
results=results,
)
+ def list_assistance(
+ self,
+ limit: int = 25,
+ cursor: str | None = None,
+ assistance_type: str | None = None,
+ award_key: str | None = None,
+ fiscal_year: int | None = None,
+ fiscal_year_gte: int | None = None,
+ fiscal_year_lte: int | None = None,
+ highly_compensated_officers: str | None = None,
+ recipient: str | None = None,
+ recipient_address: str | None = None,
+ search: str | None = None,
+ ) -> PaginatedResponse:
+ """List assistance (financial assistance) transactions (`/api/assistance/`). Keyset pagination."""
+ params: dict[str, Any] = {"limit": min(limit, 100)}
+ if cursor:
+ params["cursor"] = cursor
+ for key, val in (
+ ("assistance_type", assistance_type),
+ ("award_key", award_key),
+ ("fiscal_year", fiscal_year),
+ ("fiscal_year_gte", fiscal_year_gte),
+ ("fiscal_year_lte", fiscal_year_lte),
+ ("highly_compensated_officers", highly_compensated_officers),
+ ("recipient", recipient),
+ ("recipient_address", recipient_address),
+ ("search", search),
+ ):
+ if val is not None:
+ params[key] = val
+ data = self._get("/api/assistance/", params)
+ return PaginatedResponse(
+ count=int(data.get("count") or len(data.get("results") or [])),
+ next=data.get("next"),
+ previous=data.get("previous"),
+ results=data.get("results", []),
+ cursor=data.get("cursor"),
+ page_metadata=data.get("page_metadata"),
+ )
+
# ============================================================================
# Webhooks (v2)
# ============================================================================
diff --git a/tango/models.py b/tango/models.py
index d113fa3..06fb72f 100644
--- a/tango/models.py
+++ b/tango/models.py
@@ -295,6 +295,50 @@ class IDV:
recipient: RecipientProfile | None = None
+@dataclass
+class Organization:
+ """Schema definition for Organization (not used for instances)"""
+
+ key: str
+ fh_key: str | None = None
+ name: str | None = None
+ level: int | None = None
+ type: str | None = None
+
+
+@dataclass
+class OTA:
+ """Schema definition for OTA / Other Transaction Agreement (not used for instances)"""
+
+ key: str
+ piid: str | None = None
+ award_date: date | None = None
+ description: str | None = None
+ recipient: RecipientProfile | None = None
+
+
+@dataclass
+class OTIDV:
+ """Schema definition for OTIDV / Other Transaction IDV (not used for instances)"""
+
+ key: str
+ piid: str | None = None
+ award_date: date | None = None
+ description: str | None = None
+ recipient: RecipientProfile | None = None
+
+
+@dataclass
+class Subaward:
+ """Schema definition for Subaward (not used for instances)"""
+
+ id: str | None = None
+ award_key: str | None = None
+ prime_uei: str | None = None
+ sub_uei: str | None = None
+ amount: Decimal | None = None
+
+
@dataclass
class Vehicle:
"""Schema definition for Vehicle (not used for instances)"""
@@ -563,3 +607,20 @@ class ShapeConfig:
# 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_organizations()
+ ORGANIZATIONS_MINIMAL: Final = "key,fh_key,name,level,type,short_name"
+
+ # Default for list_otas()
+ OTAS_MINIMAL: Final = (
+ "key,piid,award_date,recipient(display_name,uei),description,total_contract_value,obligated"
+ )
+
+ # Default for list_otidvs()
+ OTIDVS_MINIMAL: Final = "key,piid,award_date,recipient(display_name,uei),description,total_contract_value,obligated,idv_type"
+
+ # Default for list_subawards()
+ # Note: API does not accept "id" or "amount" in shape (unknown_field). Use only accepted fields.
+ SUBAWARDS_MINIMAL: Final = (
+ "award_key,prime_recipient(uei,display_name),subaward_recipient(uei,display_name)"
+ )
diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py
index 8836ec6..2443e04 100644
--- a/tango/shapes/explicit_schemas.py
+++ b/tango/shapes/explicit_schemas.py
@@ -975,6 +975,76 @@
}
+# Organization (agencies hierarchy)
+ORGANIZATION_SCHEMA: dict[str, FieldSchema] = {
+ "key": FieldSchema(name="key", type=str, is_optional=True, is_list=False),
+ "fh_key": FieldSchema(name="fh_key", type=str, is_optional=False, is_list=False),
+ "name": FieldSchema(name="name", type=str, is_optional=True, is_list=False),
+ "short_name": FieldSchema(name="short_name", type=str, is_optional=True, is_list=False),
+ "level": FieldSchema(name="level", type=int, is_optional=True, is_list=False),
+ "type": FieldSchema(name="type", type=str, is_optional=True, is_list=False),
+}
+
+# OTA (Other Transaction Agreement) - IDV-like
+OTA_SCHEMA: dict[str, FieldSchema] = {
+ "key": FieldSchema(name="key", type=str, is_optional=False, is_list=False),
+ "piid": FieldSchema(name="piid", type=str, is_optional=True, is_list=False),
+ "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False),
+ "description": FieldSchema(name="description", type=str, is_optional=True, is_list=False),
+ "total_contract_value": FieldSchema(
+ name="total_contract_value", type=Decimal, is_optional=True, is_list=False
+ ),
+ "obligated": FieldSchema(name="obligated", type=Decimal, is_optional=True, is_list=False),
+ "recipient": FieldSchema(
+ name="recipient",
+ type=dict,
+ is_optional=True,
+ is_list=False,
+ nested_model="RecipientProfile",
+ ),
+}
+
+# OTIDV (Other Transaction IDV) - IDV-like
+OTIDV_SCHEMA: dict[str, FieldSchema] = {
+ "key": FieldSchema(name="key", type=str, is_optional=False, is_list=False),
+ "piid": FieldSchema(name="piid", type=str, is_optional=True, is_list=False),
+ "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False),
+ "description": FieldSchema(name="description", type=str, is_optional=True, is_list=False),
+ "total_contract_value": FieldSchema(
+ name="total_contract_value", type=Decimal, is_optional=True, is_list=False
+ ),
+ "obligated": FieldSchema(name="obligated", type=Decimal, is_optional=True, is_list=False),
+ "idv_type": FieldSchema(name="idv_type", type=dict, is_optional=True, is_list=False),
+ "recipient": FieldSchema(
+ name="recipient",
+ type=dict,
+ is_optional=True,
+ is_list=False,
+ nested_model="RecipientProfile",
+ ),
+}
+
+# Subaward (prime/sub awards)
+SUBAWARD_SCHEMA: dict[str, FieldSchema] = {
+ "id": FieldSchema(name="id", type=str, is_optional=True, is_list=False),
+ "award_key": FieldSchema(name="award_key", type=str, is_optional=True, is_list=False),
+ "amount": FieldSchema(name="amount", type=Decimal, is_optional=True, is_list=False),
+ "prime_recipient": FieldSchema(
+ name="prime_recipient",
+ type=dict,
+ is_optional=True,
+ is_list=False,
+ nested_model="RecipientProfile",
+ ),
+ "subaward_recipient": FieldSchema(
+ name="subaward_recipient",
+ type=dict,
+ is_optional=True,
+ is_list=False,
+ nested_model="RecipientProfile",
+ ),
+}
+
# ============================================================================
# SCHEMA REGISTRY MAPPING
# ============================================================================
@@ -1009,6 +1079,11 @@
"CFDANumber": CFDA_NUMBER_SCHEMA,
"CodeDescription": CODE_DESCRIPTION_SCHEMA,
"GrantAttachment": GRANT_ATTACHMENT_SCHEMA,
+ # Additional list endpoints
+ "Organization": ORGANIZATION_SCHEMA,
+ "OTA": OTA_SCHEMA,
+ "OTIDV": OTIDV_SCHEMA,
+ "Subaward": SUBAWARD_SCHEMA,
}
diff --git a/tests/cassettes/TestAssistanceIntegration.test_list_assistance b/tests/cassettes/TestAssistanceIntegration.test_list_assistance
new file mode 100644
index 0000000..986f7aa
--- /dev/null
+++ b/tests/cassettes/TestAssistanceIntegration.test_list_assistance
@@ -0,0 +1,121 @@
+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/assistance/?limit=10
+ response:
+ body:
+ string: "\n\n\n\n \n
\n\nmakegov.com | 504: Gateway
+ time-out\n\n\n\n\n\n\n\n\n\n
\n
\n
\n
+ \
\n
\n
\n
\n \n \n \n
+ \ \n
\n
You\n
\n
+ \ \n Browser\n \n
\n \n
Working\n
+ \ \n
\n
\n
\n
Minneapolis\n
+ \
\n
+ \ \n
Working\n
+ \ \n
\n
\n
\n \n \n \n
+ \ \n
\n
tango.makegov.com\n
+ \
\n \n Host\n \n
\n \n
Error\n \n
\n
\n
+ \
\n
\n\n
\n
\n
\n
+ \
What
+ happened?
\n
The web server reported a gateway time-out
+ error.
\n
\n
\n
What can I do?
\n
Please
+ try again in a few minutes.
\n
\n
\n
+ \
\n\n \n\n
\n
\n\n"
+ headers:
+ CF-RAY:
+ - 9cc908f7accba1d6-MSP
+ Cache-Control:
+ - private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, pre-check=0
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '6435'
+ Content-Type:
+ - text/html; charset=UTF-8
+ Date:
+ - Thu, 12 Feb 2026 03:35:41 GMT
+ Expires:
+ - Thu, 01 Jan 1970 00:00:01 GMT
+ Server:
+ - cloudflare
+ referrer-policy:
+ - same-origin
+ x-frame-options:
+ - SAMEORIGIN
+ status:
+ code: 504
+ message: Gateway Timeout
+version: 1
diff --git a/tests/cassettes/TestNaicsIntegration.test_list_naics b/tests/cassettes/TestNaicsIntegration.test_list_naics
new file mode 100644
index 0000000..878a594
--- /dev/null
+++ b/tests/cassettes/TestNaicsIntegration.test_list_naics
@@ -0,0 +1,82 @@
+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/naics/?page=1&limit=10
+ response:
+ body:
+ string: '{"count":1012,"next":"http://tango.makegov.com/api/naics/?limit=10&page=2","previous":null,"results":[{"code":111110,"description":"Soybean
+ Farming"},{"code":111120,"description":"Oilseed (except Soybean) Farming"},{"code":111130,"description":"Dry
+ Pea and Bean Farming"},{"code":111140,"description":"Wheat Farming"},{"code":111150,"description":"Corn
+ Farming"},{"code":111160,"description":"Rice Farming"},{"code":111191,"description":"Oilseed
+ and Grain Combination Farming"},{"code":111199,"description":"All Other Grain
+ Farming"},{"code":111211,"description":"Potato Farming"},{"code":111219,"description":"Other
+ Vegetable (except Potato) and Melon Farming"}]}'
+ headers:
+ CF-RAY:
+ - 9cc8ee2dc963a1c7-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:16: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=CgIX49cZWQu0gqgo7uv%2F6TQJQj%2BvSn9ezrnzvH5Q6EwTpFfGFa20BOJZC%2Bon3QDJda85%2Fbf5y5Vt4di9CIjzStG3cyLigqsEicAzMTuc"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '664'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.038s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '10'
+ x-ratelimit-burst-remaining:
+ - '7'
+ x-ratelimit-burst-reset:
+ - '59'
+ x-ratelimit-daily-limit:
+ - '100'
+ x-ratelimit-daily-remaining:
+ - '40'
+ x-ratelimit-daily-reset:
+ - '50685'
+ x-ratelimit-limit:
+ - '10'
+ x-ratelimit-remaining:
+ - '7'
+ x-ratelimit-reset:
+ - '59'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/TestOTAsIntegration.test_get_ota b/tests/cassettes/TestOTAsIntegration.test_get_ota
new file mode 100644
index 0000000..d9191d4
--- /dev/null
+++ b/tests/cassettes/TestOTAsIntegration.test_get_ota
@@ -0,0 +1,306 @@
+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/otas/?limit=1&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated
+ response:
+ body:
+ string: '{"count":7021,"next":"http://tango.makegov.com/api/otas/?limit=1&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated&cursor=WyIyMDI2LTAxLTMwIiwgImExM2IyZjgxLWMwY2MtNWYwZC1iODYwLTRlOTI0NjQ4OGRmNyJd","previous":null,"cursor":"WyIyMDI2LTAxLTMwIiwgImExM2IyZjgxLWMwY2MtNWYwZC1iODYwLTRlOTI0NjQ4OGRmNyJd","previous_cursor":null,"results":[{"key":"OT_AWD_140D042690006_1406_-NONE-_-NONE-","piid":"140D042690006","award_date":"2026-01-30","description":"COLUMBIA
+ - TEARWISE | OCULAB","total_contract_value":17341223.0,"obligated":9364971.0,"recipient":{"uei":"F4N1QNPB95M4","display_name":"THE
+ TRUSTEES OF COLUMBIA UNIVERSITY IN THE CITY OF NEW YORK"}}],"count_type":"approximate"}'
+ headers:
+ CF-RAY:
+ - 9cc8fb5e88fe4c9c-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:25: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=eg4zgbwcYMJVsQkCZ6PgLdXTvGNiEE8mX%2BzNWACsnpNKsErEZ0WrFMMVzaE5vN7oUU2DPmfXhYtfhYva8o8qCobsUMomyaxuNofs3YKx"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '735'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.038s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '997'
+ x-ratelimit-burst-reset:
+ - '34'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999585'
+ x-ratelimit-daily-reset:
+ - '34462'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '997'
+ x-ratelimit-reset:
+ - '34'
+ 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/otas/OT_AWD_140D042690006_1406_-NONE-_-NONE-/?shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated
+ response:
+ body:
+ string: '{"key":"OT_AWD_140D042690006_1406_-NONE-_-NONE-","piid":"140D042690006","award_date":"2026-01-30","description":"COLUMBIA
+ - TEARWISE | OCULAB","total_contract_value":17341223.0,"obligated":9364971.0,"recipient":{"uei":"F4N1QNPB95M4","display_name":"THE
+ TRUSTEES OF COLUMBIA UNIVERSITY IN THE CITY OF NEW YORK"}}'
+ headers:
+ CF-RAY:
+ - 9cc8fb5fab1d4c9c-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:25: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=THrFBx9%2FlBiWKKq1IdrC2TzYOeU7V1yW8gI7veHjFpPddEJs1cGJ%2BwaYUeZRHt00VHi6xWtq%2ByKhPOsIpoE9XG5TZHu8gVt6kLbxeYbR"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '311'
+ 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:
+ - '996'
+ x-ratelimit-burst-reset:
+ - '33'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999584'
+ x-ratelimit-daily-reset:
+ - '34462'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '996'
+ x-ratelimit-reset:
+ - '33'
+ 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/otas/?limit=1&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated
+ response:
+ body:
+ string: '{"count":7021,"next":"http://tango.makegov.com/api/otas/?limit=1&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated&cursor=WyIyMDI2LTAxLTMwIiwgImExM2IyZjgxLWMwY2MtNWYwZC1iODYwLTRlOTI0NjQ4OGRmNyJd","previous":null,"cursor":"WyIyMDI2LTAxLTMwIiwgImExM2IyZjgxLWMwY2MtNWYwZC1iODYwLTRlOTI0NjQ4OGRmNyJd","previous_cursor":null,"results":[{"key":"OT_AWD_140D042690006_1406_-NONE-_-NONE-","piid":"140D042690006","award_date":"2026-01-30","description":"COLUMBIA
+ - TEARWISE | OCULAB","total_contract_value":17341223.0,"obligated":9364971.0,"recipient":{"uei":"F4N1QNPB95M4","display_name":"THE
+ TRUSTEES OF COLUMBIA UNIVERSITY IN THE CITY OF NEW YORK"}}],"count_type":"approximate"}'
+ headers:
+ CF-RAY:
+ - 9cc8fedfdd345108-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:28:23 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=P553qdumsYNmdbK9YWeYnVjsTo1t8cl3YFBFWzFfgv%2Fy4PlSgAk4VCiIfuTeVJQSRFxwwnkwG6iop43UwMndvtxb2BDjuAiy65Ti2gZh"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '735'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.025s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '998'
+ x-ratelimit-burst-reset:
+ - '59'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999565'
+ x-ratelimit-daily-reset:
+ - '34318'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '998'
+ x-ratelimit-reset:
+ - '59'
+ 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/otas/OT_AWD_140D042690006_1406_-NONE-_-NONE-/?shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated
+ response:
+ body:
+ string: '{"key":"OT_AWD_140D042690006_1406_-NONE-_-NONE-","piid":"140D042690006","award_date":"2026-01-30","description":"COLUMBIA
+ - TEARWISE | OCULAB","total_contract_value":17341223.0,"obligated":9364971.0,"recipient":{"uei":"F4N1QNPB95M4","display_name":"THE
+ TRUSTEES OF COLUMBIA UNIVERSITY IN THE CITY OF NEW YORK"}}'
+ headers:
+ CF-RAY:
+ - 9cc8fee0ae7a5108-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:28:23 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=qVM1gjFV8SFgR9yeWh4pc1Xpr794j6TESnd2mVl3o0lee2pNvaEmWMEVCcqhreVkNY%2BXvDqOo5GT4NuMKmcAnSDyDYVM6%2BBq7mdsg3Fq"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '311'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.033s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '997'
+ x-ratelimit-burst-reset:
+ - '59'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999564'
+ x-ratelimit-daily-reset:
+ - '34318'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '997'
+ x-ratelimit-reset:
+ - '59'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/TestOTAsIntegration.test_list_otas b/tests/cassettes/TestOTAsIntegration.test_list_otas
new file mode 100644
index 0000000..846d88e
--- /dev/null
+++ b/tests/cassettes/TestOTAsIntegration.test_list_otas
@@ -0,0 +1,170 @@
+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/otas/?limit=5&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated
+ response:
+ body:
+ string: '{"count":7021,"next":"http://tango.makegov.com/api/otas/?limit=5&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated&cursor=WyIyMDI1LTExLTA3IiwgImQzOTdhZTExLTcyMzYtNTIzYi1hZDQ1LWY4MjJkZDgwMjc2OCJd","previous":null,"cursor":"WyIyMDI1LTExLTA3IiwgImQzOTdhZTExLTcyMzYtNTIzYi1hZDQ1LWY4MjJkZDgwMjc2OCJd","previous_cursor":null,"results":[{"key":"OT_AWD_140D042690006_1406_-NONE-_-NONE-","piid":"140D042690006","award_date":"2026-01-30","description":"COLUMBIA
+ - TEARWISE | OCULAB","total_contract_value":17341223.0,"obligated":9364971.0,"recipient":{"uei":"F4N1QNPB95M4","display_name":"THE
+ TRUSTEES OF COLUMBIA UNIVERSITY IN THE CITY OF NEW YORK"}},{"key":"OT_AWD_140D042690011_1406_-NONE-_-NONE-","piid":"140D042690011","award_date":"2026-01-29","description":"COSMIC:
+ CLOSED - LOOP SENSING AND MICRODOSING FOR DRY EYE AND SYSTEMIC DISEASE MANAGEMENT","total_contract_value":16356306.9,"obligated":9628264.0,"recipient":{"uei":"TMFGLT8F3QB9","display_name":"LACRISTAT
+ LLC"}},{"key":"OT_AWD_HT0038269E001_9700_-NONE-_-NONE-","piid":"HT0038269E001","award_date":"2025-11-12","description":"AMBIENT
+ LISTENING PROTOTYPE","total_contract_value":100.0,"obligated":100.0,"recipient":{"uei":"DMHXTXRARC74","display_name":"CERNER
+ CORPORATION"}},{"key":"OT_AWD_FA91012690003_9700_-NONE-_-NONE-","piid":"FA91012690003","award_date":"2025-11-10","description":"NFAC
+ FRONT-END HARDWARE PROTOTYPE","total_contract_value":7500.0,"obligated":7500.0,"recipient":{"uei":"Q8BAQDYG3DG4","display_name":"BUSTEC,
+ INC."}},{"key":"OT_AWD_HB00012692016_9700_HB00012492000_9700","piid":"HB00012692016","award_date":"2025-11-07","description":"HFO
+ KITS","total_contract_value":450000000.0,"obligated":0.0,"recipient":{"uei":"WZZSXMA52K46","display_name":"SEALING
+ TECHNOLOGIES, LLC"}}],"count_type":"approximate"}'
+ headers:
+ CF-RAY:
+ - 9cc8fb5cb919ace8-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:25: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=mYyBjqs%2FiJlxeAv9qlc%2FRMMqSuiUQ3urqhWJDpLXEOF5ltPq48EQ71FEO2L9OPfCR4Pa1rr3JJUE724k3VFWzikCO%2FfkpfHbJ7eu8xZh"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '1844'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.074s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '998'
+ x-ratelimit-burst-reset:
+ - '34'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999586'
+ x-ratelimit-daily-reset:
+ - '34462'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '998'
+ x-ratelimit-reset:
+ - '34'
+ 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/otas/?limit=5&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated
+ response:
+ body:
+ string: '{"count":7021,"next":"http://tango.makegov.com/api/otas/?limit=5&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated&cursor=WyIyMDI1LTExLTA3IiwgImQzOTdhZTExLTcyMzYtNTIzYi1hZDQ1LWY4MjJkZDgwMjc2OCJd","previous":null,"cursor":"WyIyMDI1LTExLTA3IiwgImQzOTdhZTExLTcyMzYtNTIzYi1hZDQ1LWY4MjJkZDgwMjc2OCJd","previous_cursor":null,"results":[{"key":"OT_AWD_140D042690006_1406_-NONE-_-NONE-","piid":"140D042690006","award_date":"2026-01-30","description":"COLUMBIA
+ - TEARWISE | OCULAB","total_contract_value":17341223.0,"obligated":9364971.0,"recipient":{"uei":"F4N1QNPB95M4","display_name":"THE
+ TRUSTEES OF COLUMBIA UNIVERSITY IN THE CITY OF NEW YORK"}},{"key":"OT_AWD_140D042690011_1406_-NONE-_-NONE-","piid":"140D042690011","award_date":"2026-01-29","description":"COSMIC:
+ CLOSED - LOOP SENSING AND MICRODOSING FOR DRY EYE AND SYSTEMIC DISEASE MANAGEMENT","total_contract_value":16356306.9,"obligated":9628264.0,"recipient":{"uei":"TMFGLT8F3QB9","display_name":"LACRISTAT
+ LLC"}},{"key":"OT_AWD_HT0038269E001_9700_-NONE-_-NONE-","piid":"HT0038269E001","award_date":"2025-11-12","description":"AMBIENT
+ LISTENING PROTOTYPE","total_contract_value":100.0,"obligated":100.0,"recipient":{"uei":"DMHXTXRARC74","display_name":"CERNER
+ CORPORATION"}},{"key":"OT_AWD_FA91012690003_9700_-NONE-_-NONE-","piid":"FA91012690003","award_date":"2025-11-10","description":"NFAC
+ FRONT-END HARDWARE PROTOTYPE","total_contract_value":7500.0,"obligated":7500.0,"recipient":{"uei":"Q8BAQDYG3DG4","display_name":"BUSTEC,
+ INC."}},{"key":"OT_AWD_HB00012692016_9700_HB00012492000_9700","piid":"HB00012692016","award_date":"2025-11-07","description":"HFO
+ KITS","total_contract_value":450000000.0,"obligated":0.0,"recipient":{"uei":"WZZSXMA52K46","display_name":"SEALING
+ TECHNOLOGIES, LLC"}}],"count_type":"approximate"}'
+ headers:
+ CF-RAY:
+ - 9cc8fedd59c2ad0b-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:28:22 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=Q8C7ixmptCHTMRAFVHToi9wzIRhWFSN67Oxf4%2FL%2Bf2UNy8jgxN9M1MNbIGNYtS664noerQGsicz7WdUFx3u1fYtkL%2FUhFD7%2B9k86eH2a"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '1844'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.037s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '999'
+ x-ratelimit-burst-reset:
+ - '59'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999566'
+ x-ratelimit-daily-reset:
+ - '34319'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '999'
+ x-ratelimit-reset:
+ - '59'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/TestOTIDVsIntegration.test_get_otidv b/tests/cassettes/TestOTIDVsIntegration.test_get_otidv
new file mode 100644
index 0000000..0b56575
--- /dev/null
+++ b/tests/cassettes/TestOTIDVsIntegration.test_get_otidv
@@ -0,0 +1,310 @@
+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/otidvs/?limit=1&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated%2Cidv_type
+ response:
+ body:
+ string: '{"count":2053,"next":"http://tango.makegov.com/api/otidvs/?limit=1&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated%2Cidv_type&cursor=WyIyMDI1LTExLTEyIiwgIjlhYmY1OWJmLTE3ZWYtNTI3ZC1hNTIwLWVhMmI2ZTE2Zjg4YyJd","previous":null,"cursor":"WyIyMDI1LTExLTEyIiwgIjlhYmY1OWJmLTE3ZWYtNTI3ZC1hNTIwLWVhMmI2ZTE2Zjg4YyJd","previous_cursor":null,"results":[{"key":"OT_IDV_HR0011269E026_9700","piid":"HR0011269E026","award_date":"2025-11-12","description":"DEVELOPMENT
+ OF NEW COMPUTATIONAL PRINCIPLES FOR ADAPTATION, ORCHESTRATION, AND ENGINEERING
+ OF BIOLOGICAL SYSTEMS","total_contract_value":1838712.0,"obligated":318987.0,"idv_type":"O","recipient":{"uei":"CURENVCZT6Z4","display_name":"WOLFRAM
+ FOUNDATION"}}],"count_type":"approximate"}'
+ headers:
+ CF-RAY:
+ - 9cc8fb62ecd183b0-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:26: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=TOWLJSuuy07ah861K2efrAAUawEFRv2k2AMMP3RivApPNqGn2yH5zEnl%2BjvvS280UlfhjsRwXoIgQyfaHl8twQpZ%2F5FN%2Fg12eeMviFjK"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '790'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.022s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '994'
+ x-ratelimit-burst-reset:
+ - '33'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999582'
+ x-ratelimit-daily-reset:
+ - '34461'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '994'
+ x-ratelimit-reset:
+ - '33'
+ 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/otidvs/OT_IDV_HR0011269E026_9700/?shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated%2Cidv_type
+ response:
+ body:
+ string: '{"key":"OT_IDV_HR0011269E026_9700","piid":"HR0011269E026","award_date":"2025-11-12","description":"DEVELOPMENT
+ OF NEW COMPUTATIONAL PRINCIPLES FOR ADAPTATION, ORCHESTRATION, AND ENGINEERING
+ OF BIOLOGICAL SYSTEMS","total_contract_value":1838712.0,"obligated":318987.0,"idv_type":"O","recipient":{"uei":"CURENVCZT6Z4","display_name":"WOLFRAM
+ FOUNDATION"}}'
+ headers:
+ CF-RAY:
+ - 9cc8fb63a92383b0-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:26: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=N8r%2FlaXy0JGVVZeqxE6BQkzbzQsXFmzGVXUUbHxEWg2CdJO5W%2BDjLj5lLpiyO3s9f3CbNr4BpGUvIPyfFCrJZ5nKWcYx%2BP5l9mDK0xrR"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '353'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.026s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '993'
+ x-ratelimit-burst-reset:
+ - '33'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999581'
+ x-ratelimit-daily-reset:
+ - '34461'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '993'
+ x-ratelimit-reset:
+ - '33'
+ 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/otidvs/?limit=1&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated%2Cidv_type
+ response:
+ body:
+ string: '{"count":2053,"next":"http://tango.makegov.com/api/otidvs/?limit=1&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated%2Cidv_type&cursor=WyIyMDI1LTExLTEyIiwgIjlhYmY1OWJmLTE3ZWYtNTI3ZC1hNTIwLWVhMmI2ZTE2Zjg4YyJd","previous":null,"cursor":"WyIyMDI1LTExLTEyIiwgIjlhYmY1OWJmLTE3ZWYtNTI3ZC1hNTIwLWVhMmI2ZTE2Zjg4YyJd","previous_cursor":null,"results":[{"key":"OT_IDV_HR0011269E026_9700","piid":"HR0011269E026","award_date":"2025-11-12","description":"DEVELOPMENT
+ OF NEW COMPUTATIONAL PRINCIPLES FOR ADAPTATION, ORCHESTRATION, AND ENGINEERING
+ OF BIOLOGICAL SYSTEMS","total_contract_value":1838712.0,"obligated":318987.0,"idv_type":"O","recipient":{"uei":"CURENVCZT6Z4","display_name":"WOLFRAM
+ FOUNDATION"}}],"count_type":"approximate"}'
+ headers:
+ CF-RAY:
+ - 9cc8fee389ffacd5-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:28:23 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=sfNhj4uBWpJtqygC%2FsfM5XsJbHaHOyy%2BnfDC8dWdhN%2F1adXThTrsoL314hXqifVhOM23upuNAsZXJ9bC3yWrpaF69ezVyNOw0n5MYgqY"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '790'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.036s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '995'
+ x-ratelimit-burst-reset:
+ - '59'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999562'
+ x-ratelimit-daily-reset:
+ - '34318'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '995'
+ x-ratelimit-reset:
+ - '59'
+ 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/otidvs/OT_IDV_HR0011269E026_9700/?shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated%2Cidv_type
+ response:
+ body:
+ string: '{"key":"OT_IDV_HR0011269E026_9700","piid":"HR0011269E026","award_date":"2025-11-12","description":"DEVELOPMENT
+ OF NEW COMPUTATIONAL PRINCIPLES FOR ADAPTATION, ORCHESTRATION, AND ENGINEERING
+ OF BIOLOGICAL SYSTEMS","total_contract_value":1838712.0,"obligated":318987.0,"idv_type":"O","recipient":{"uei":"CURENVCZT6Z4","display_name":"WOLFRAM
+ FOUNDATION"}}'
+ headers:
+ CF-RAY:
+ - 9cc8fee4bc7facd5-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:28:23 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=lkpuIEYYiEupwWDVnVNH1vLYTeM9z90THqzsP74qlC05Q1D2MMnz%2BbU27Dds0%2FrM%2F5L5uIN7%2BSDp91Pz4cahG5y2Sc0SPdv4rznFPTx5"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '353'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.017s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '994'
+ x-ratelimit-burst-reset:
+ - '58'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999561'
+ x-ratelimit-daily-reset:
+ - '34318'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '994'
+ x-ratelimit-reset:
+ - '58'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/TestOTIDVsIntegration.test_list_otidvs b/tests/cassettes/TestOTIDVsIntegration.test_list_otidvs
new file mode 100644
index 0000000..3a55bc3
--- /dev/null
+++ b/tests/cassettes/TestOTIDVsIntegration.test_list_otidvs
@@ -0,0 +1,176 @@
+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/otidvs/?limit=5&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated%2Cidv_type
+ response:
+ body:
+ string: '{"count":2053,"next":"http://tango.makegov.com/api/otidvs/?limit=5&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated%2Cidv_type&cursor=WyIyMDI1LTEwLTIyIiwgImJkZmE1MDJhLTRjOTMtNWM0ZS1hMDcxLTYzNWRhNThiNGVlMSJd","previous":null,"cursor":"WyIyMDI1LTEwLTIyIiwgImJkZmE1MDJhLTRjOTMtNWM0ZS1hMDcxLTYzNWRhNThiNGVlMSJd","previous_cursor":null,"results":[{"key":"OT_IDV_HR0011269E026_9700","piid":"HR0011269E026","award_date":"2025-11-12","description":"DEVELOPMENT
+ OF NEW COMPUTATIONAL PRINCIPLES FOR ADAPTATION, ORCHESTRATION, AND ENGINEERING
+ OF BIOLOGICAL SYSTEMS","total_contract_value":1838712.0,"obligated":318987.0,"idv_type":"O","recipient":{"uei":"CURENVCZT6Z4","display_name":"WOLFRAM
+ FOUNDATION"}},{"key":"OT_IDV_H92405269E001_9700","piid":"H92405269E001","award_date":"2025-11-03","description":"OTA
+ PROTOTYPE OF FUTURESS/INNOVATION CYCLES RAPID\nPROTOTYPING BUSINESS PROCESS","total_contract_value":49000000.0,"obligated":2988524.1,"idv_type":"O","recipient":{"uei":"QNXRK3LEY5W8","display_name":"LIBERTY
+ ALLIANCE, LLC"}},{"key":"OT_IDV_HR0011269E022_9700","piid":"HR0011269E022","award_date":"2025-10-28","description":"RAPID
+ RESILIENCE RESPONSES (R3) - PROGRAM TASK CALL 08, AUTHORING TRAINING FOR HUNT
+ AND EXQUISITE NETWORK ANALYTICS (ATHENA).","total_contract_value":9072116.0,"obligated":4054352.0,"idv_type":"O","recipient":{"uei":"MQ1TKVG8MN89","display_name":"ULTIMATE
+ KNOWLEDGE CORPORATION"}},{"key":"OT_IDV_N000392690001_9700","piid":"N000392690001","award_date":"2025-10-27","description":"PHASE
+ 1 COMPLETION","total_contract_value":5095641.0,"obligated":10191282.0,"idv_type":"O","recipient":{"uei":"SMM7VQCC87V3","display_name":"APPLIED
+ INTUITION GOVERNMENT INC"}},{"key":"OT_IDV_HR0011269E038_9700","piid":"HR0011269E038","award_date":"2025-10-22","description":"INERTIAL
+ SCALING FOR DERISKING STABILITY AND CONTROL OF TIGHTLY COUPLED AEROPROPULSIVE
+ AIRCRAFT CONFIGURATIONS","total_contract_value":1800000.0,"obligated":2600000.0,"idv_type":"O","recipient":{"uei":"QCRHSCGFBLN4","display_name":"WHISPER
+ AERO INC"}}],"count_type":"approximate"}'
+ headers:
+ CF-RAY:
+ - 9cc8fb610806a1d9-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:25: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=5RmWeXI5DiklZEB9XlgTzqJxMKXj%2FmOeZk33CRVmzP32Ji25dZNgH3dXTRYSNqXCRvlv33aeLA4ZRXDzEe4il7iwVZMf7rD%2B9x2Lfxjo"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '2123'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.038s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '995'
+ x-ratelimit-burst-reset:
+ - '33'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999583'
+ x-ratelimit-daily-reset:
+ - '34462'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '995'
+ x-ratelimit-reset:
+ - '33'
+ 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/otidvs/?limit=5&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated%2Cidv_type
+ response:
+ body:
+ string: '{"count":2053,"next":"http://tango.makegov.com/api/otidvs/?limit=5&shape=key%2Cpiid%2Caward_date%2Crecipient%28display_name%2Cuei%29%2Cdescription%2Ctotal_contract_value%2Cobligated%2Cidv_type&cursor=WyIyMDI1LTEwLTIyIiwgImJkZmE1MDJhLTRjOTMtNWM0ZS1hMDcxLTYzNWRhNThiNGVlMSJd","previous":null,"cursor":"WyIyMDI1LTEwLTIyIiwgImJkZmE1MDJhLTRjOTMtNWM0ZS1hMDcxLTYzNWRhNThiNGVlMSJd","previous_cursor":null,"results":[{"key":"OT_IDV_HR0011269E026_9700","piid":"HR0011269E026","award_date":"2025-11-12","description":"DEVELOPMENT
+ OF NEW COMPUTATIONAL PRINCIPLES FOR ADAPTATION, ORCHESTRATION, AND ENGINEERING
+ OF BIOLOGICAL SYSTEMS","total_contract_value":1838712.0,"obligated":318987.0,"idv_type":"O","recipient":{"uei":"CURENVCZT6Z4","display_name":"WOLFRAM
+ FOUNDATION"}},{"key":"OT_IDV_H92405269E001_9700","piid":"H92405269E001","award_date":"2025-11-03","description":"OTA
+ PROTOTYPE OF FUTURESS/INNOVATION CYCLES RAPID\nPROTOTYPING BUSINESS PROCESS","total_contract_value":49000000.0,"obligated":2988524.1,"idv_type":"O","recipient":{"uei":"QNXRK3LEY5W8","display_name":"LIBERTY
+ ALLIANCE, LLC"}},{"key":"OT_IDV_HR0011269E022_9700","piid":"HR0011269E022","award_date":"2025-10-28","description":"RAPID
+ RESILIENCE RESPONSES (R3) - PROGRAM TASK CALL 08, AUTHORING TRAINING FOR HUNT
+ AND EXQUISITE NETWORK ANALYTICS (ATHENA).","total_contract_value":9072116.0,"obligated":4054352.0,"idv_type":"O","recipient":{"uei":"MQ1TKVG8MN89","display_name":"ULTIMATE
+ KNOWLEDGE CORPORATION"}},{"key":"OT_IDV_N000392690001_9700","piid":"N000392690001","award_date":"2025-10-27","description":"PHASE
+ 1 COMPLETION","total_contract_value":5095641.0,"obligated":10191282.0,"idv_type":"O","recipient":{"uei":"SMM7VQCC87V3","display_name":"APPLIED
+ INTUITION GOVERNMENT INC"}},{"key":"OT_IDV_HR0011269E038_9700","piid":"HR0011269E038","award_date":"2025-10-22","description":"INERTIAL
+ SCALING FOR DERISKING STABILITY AND CONTROL OF TIGHTLY COUPLED AEROPROPULSIVE
+ AIRCRAFT CONFIGURATIONS","total_contract_value":1800000.0,"obligated":2600000.0,"idv_type":"O","recipient":{"uei":"QCRHSCGFBLN4","display_name":"WHISPER
+ AERO INC"}}],"count_type":"approximate"}'
+ headers:
+ CF-RAY:
+ - 9cc8fee21e454c91-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:28:23 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=kWPjvB7QGrMtsezlFvY59XUQ%2ByxeqyOdMq1rbTRxUbRQ1BdR8jTLZlrpCcZJjQ2bFBD8A4C3ms9gqB41Vnk1Cl2iUAnQHMWi%2Fk30HSy8"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '2123'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.025s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '1000'
+ x-ratelimit-burst-remaining:
+ - '996'
+ x-ratelimit-burst-reset:
+ - '59'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999563'
+ x-ratelimit-daily-reset:
+ - '34318'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '996'
+ x-ratelimit-reset:
+ - '59'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/TestOfficesIntegration.test_get_office b/tests/cassettes/TestOfficesIntegration.test_get_office
new file mode 100644
index 0000000..49cff05
--- /dev/null
+++ b/tests/cassettes/TestOfficesIntegration.test_get_office
@@ -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/offices/?page=1&limit=1
+ response:
+ body:
+ string: '{"count":166427,"next":"http://tango.makegov.com/api/offices/?limit=1&page=2","previous":null,"results":[{"office_code":"960207","office_name":"","agency_code":"96CE","agency_name":"U.S.
+ Army Corps of Engineers - Civil Program Financing Only","department_code":96,"department_name":"Corps
+ of Engineers - Civil Works"}]}'
+ headers:
+ CF-RAY:
+ - 9cc8ee2c0d60acf4-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:16: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=CK8zmhB6yCzN2PROXTNzHpT4%2B87LTKrxMUDOSeRtvw1wDquuc1YhoF%2FLvFpFsMxXL9vcJEiv6kuWsU9dKuIYe8bpCCkuxfo57o%2FTmk0B"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '319'
+ 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:
+ - '10'
+ x-ratelimit-burst-remaining:
+ - '8'
+ x-ratelimit-burst-reset:
+ - '59'
+ x-ratelimit-daily-limit:
+ - '100'
+ x-ratelimit-daily-remaining:
+ - '41'
+ x-ratelimit-daily-reset:
+ - '50685'
+ x-ratelimit-limit:
+ - '10'
+ x-ratelimit-remaining:
+ - '8'
+ x-ratelimit-reset:
+ - '59'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/TestOfficesIntegration.test_list_offices b/tests/cassettes/TestOfficesIntegration.test_list_offices
new file mode 100644
index 0000000..ee73b4b
--- /dev/null
+++ b/tests/cassettes/TestOfficesIntegration.test_list_offices
@@ -0,0 +1,96 @@
+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/offices/?page=1&limit=10
+ response:
+ body:
+ string: '{"count":166427,"next":"http://tango.makegov.com/api/offices/?limit=10&page=2","previous":null,"results":[{"office_code":"963102","office_name":"","agency_code":"96CE","agency_name":"U.S.
+ Army Corps of Engineers - Civil Program Financing Only","department_code":96,"department_name":"Corps
+ of Engineers - Civil Works"},{"office_code":"9624C6","office_name":"","agency_code":"96CE","agency_name":"U.S.
+ Army Corps of Engineers - Civil Program Financing Only","department_code":96,"department_name":"Corps
+ of Engineers - Civil Works"},{"office_code":"960426","office_name":"","agency_code":"96CE","agency_name":"U.S.
+ Army Corps of Engineers - Civil Program Financing Only","department_code":96,"department_name":"Corps
+ of Engineers - Civil Works"},{"office_code":"968291","office_name":"","agency_code":"96CE","agency_name":"U.S.
+ Army Corps of Engineers - Civil Program Financing Only","department_code":96,"department_name":"Corps
+ of Engineers - Civil Works"},{"office_code":"967445","office_name":"","agency_code":"96CE","agency_name":"U.S.
+ Army Corps of Engineers - Civil Program Financing Only","department_code":96,"department_name":"Corps
+ of Engineers - Civil Works"},{"office_code":"967442","office_name":"","agency_code":"96CE","agency_name":"U.S.
+ Army Corps of Engineers - Civil Program Financing Only","department_code":96,"department_name":"Corps
+ of Engineers - Civil Works"},{"office_code":"960207","office_name":"","agency_code":"96CE","agency_name":"U.S.
+ Army Corps of Engineers - Civil Program Financing Only","department_code":96,"department_name":"Corps
+ of Engineers - Civil Works"},{"office_code":"9624AQ","office_name":"","agency_code":"96CE","agency_name":"U.S.
+ Army Corps of Engineers - Civil Program Financing Only","department_code":96,"department_name":"Corps
+ of Engineers - Civil Works"},{"office_code":"F05603","office_name":"","agency_code":"5100","agency_name":"Federal
+ Deposit Insurance Corporation","department_code":51,"department_name":"Federal
+ Deposit Insurance Corporation"},{"office_code":"967227","office_name":"","agency_code":"96CE","agency_name":"U.S.
+ Army Corps of Engineers - Civil Program Financing Only","department_code":96,"department_name":"Corps
+ of Engineers - Civil Works"}]}'
+ headers:
+ CF-RAY:
+ - 9cc8ee2a6fdc5116-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:16: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=WB7nhDGkIAChXbNOfe42%2F79Sk2xOP4yoRyFf%2BHFVMOWfK3C7w1plxUpqMukhC7zUtjy8OKdGtQu5GeQwLhwtyaCCPrf7aiMA4a2ne2Qn"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '2220'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.070s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '10'
+ x-ratelimit-burst-remaining:
+ - '9'
+ x-ratelimit-burst-reset:
+ - '59'
+ x-ratelimit-daily-limit:
+ - '100'
+ x-ratelimit-daily-remaining:
+ - '42'
+ x-ratelimit-daily-reset:
+ - '50686'
+ x-ratelimit-limit:
+ - '10'
+ x-ratelimit-remaining:
+ - '9'
+ x-ratelimit-reset:
+ - '59'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/TestOrganizationsIntegration.test_get_organization b/tests/cassettes/TestOrganizationsIntegration.test_get_organization
new file mode 100644
index 0000000..90050de
--- /dev/null
+++ b/tests/cassettes/TestOrganizationsIntegration.test_get_organization
@@ -0,0 +1,152 @@
+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/organizations/?page=1&limit=1&shape=key%2Cfh_key%2Cname%2Clevel%2Ctype%2Cshort_name
+ response:
+ body:
+ string: '{"count":99298,"next":"http://tango.makegov.com/api/organizations/?limit=1&page=2&shape=key%2Cfh_key%2Cname%2Clevel%2Ctype%2Cshort_name","previous":null,"results":[{"key":"2278181c-046b-5265-b4e6-74444829a573","fh_key":100000000,"name":"DEPT
+ OF DEFENSE","level":1,"type":"DEPARTMENT","short_name":"DOD"}]}'
+ headers:
+ CF-RAY:
+ - 9cc8ee30eb24ad23-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:16: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=rwxEYQnJhgPHJeAS0Y7SPgAG%2BvZAcDJnsjqG%2B3kdu9dT7YMoYA54o1luqFIi50TMJfveLsgRaP5%2BEG6aN%2BWoY1HrlCcBTtnd5r35Uh0O"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '305'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.048s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '10'
+ x-ratelimit-burst-remaining:
+ - '5'
+ x-ratelimit-burst-reset:
+ - '58'
+ x-ratelimit-daily-limit:
+ - '100'
+ x-ratelimit-daily-remaining:
+ - '38'
+ x-ratelimit-daily-reset:
+ - '50685'
+ x-ratelimit-limit:
+ - '10'
+ x-ratelimit-remaining:
+ - '5'
+ x-ratelimit-reset:
+ - '58'
+ 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/organizations/100000000/?shape=key%2Cfh_key%2Cname%2Clevel%2Ctype%2Cshort_name
+ response:
+ body:
+ string: '{"key":"2278181c-046b-5265-b4e6-74444829a573","fh_key":100000000,"name":"DEPT
+ OF DEFENSE","level":1,"type":"DEPARTMENT","short_name":"DOD"}'
+ headers:
+ CF-RAY:
+ - 9cc8ee31bc4aad23-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:16: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=Qe%2BHwjPzrj1nOhNW8CjCpLpVjm2kIbEhgiiQhxuiXBbWPq781nUXFXY%2BMiqpcv2Zzwk5B9q7uQE83ZOwKhKiKSJewLQrxxiBBgbi%2FE3z"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '139'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.022s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '10'
+ x-ratelimit-burst-remaining:
+ - '4'
+ x-ratelimit-burst-reset:
+ - '58'
+ x-ratelimit-daily-limit:
+ - '100'
+ x-ratelimit-daily-remaining:
+ - '37'
+ x-ratelimit-daily-reset:
+ - '50685'
+ x-ratelimit-limit:
+ - '10'
+ x-ratelimit-remaining:
+ - '4'
+ x-ratelimit-reset:
+ - '58'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/TestOrganizationsIntegration.test_list_organizations b/tests/cassettes/TestOrganizationsIntegration.test_list_organizations
new file mode 100644
index 0000000..aab3e7a
--- /dev/null
+++ b/tests/cassettes/TestOrganizationsIntegration.test_list_organizations
@@ -0,0 +1,83 @@
+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/organizations/?page=1&limit=10&shape=key%2Cfh_key%2Cname%2Clevel%2Ctype%2Cshort_name
+ response:
+ body:
+ string: '{"count":99298,"next":"http://tango.makegov.com/api/organizations/?limit=10&page=2&shape=key%2Cfh_key%2Cname%2Clevel%2Ctype%2Cshort_name","previous":null,"results":[{"key":"2278181c-046b-5265-b4e6-74444829a573","fh_key":100000000,"name":"DEPT
+ OF DEFENSE","level":1,"type":"DEPARTMENT","short_name":"DOD"},{"key":"b6a03424-0a76-52d5-bf1c-e376fa09fd5a","fh_key":100000026,"name":"US
+ ARMY CORPS OF ENGINEERS","level":3,"type":"MAJOR COMMAND","short_name":null},{"key":"b6eda190-2b32-561a-bd81-6f6d6f887c36","fh_key":100000064,"name":"NAVSUP","level":3,"type":"MAJOR
+ COMMAND","short_name":null},{"key":"4ec3225f-427a-5a36-9974-9684e5b273c3","fh_key":100000065,"name":"NAVSUP
+ OTHER HCA","level":4,"type":"SUB COMMAND","short_name":null},{"key":"a9e9125b-43b4-5588-a923-3773174c14b2","fh_key":100000066,"name":"MISC","level":5,"type":"SUB
+ COMMAND","short_name":null},{"key":"67214b4e-d8db-5ca8-a894-b4012f461c80","fh_key":100000072,"name":"227","level":3,"type":"OFFICE","short_name":null},{"key":"e4170507-1d6e-577f-8d03-53579a5fbc31","fh_key":100000078,"name":"BUMED","level":5,"type":"SUB
+ COMMAND","short_name":null},{"key":"a12a2243-17b7-5ef0-86e5-7866851b5ebe","fh_key":100000079,"name":"NAVY
+ MEDICINE WEST","level":6,"type":"SUB COMMAND","short_name":null},{"key":"abda4f61-f4b9-533d-a39b-91d01dc98f48","fh_key":100000098,"name":"259TH","level":3,"type":"OFFICE","short_name":null},{"key":"e93464a5-209a-5ed6-8289-1eee0e13c518","fh_key":100000099,"name":"236TH","level":3,"type":"OFFICE","short_name":null}]}'
+ headers:
+ CF-RAY:
+ - 9cc8ee2f08a3acca-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:16: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=a1ON%2FsH4OGLIY1ripc5UqFgYP8wnR1m6y%2FAR4zvzDyijoPd%2BF4xIYW0f039hVEZNgihhBV9zIHxnGqc3hhcJuhZspAJl%2FNUqigkEWx86"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '1508'
+ cross-origin-opener-policy:
+ - same-origin
+ referrer-policy:
+ - same-origin
+ vary:
+ - Accept, Cookie
+ x-content-type-options:
+ - nosniff
+ x-execution-time:
+ - 0.050s
+ x-frame-options:
+ - DENY
+ x-ratelimit-burst-limit:
+ - '10'
+ x-ratelimit-burst-remaining:
+ - '6'
+ x-ratelimit-burst-reset:
+ - '59'
+ x-ratelimit-daily-limit:
+ - '100'
+ x-ratelimit-daily-remaining:
+ - '39'
+ x-ratelimit-daily-reset:
+ - '50685'
+ x-ratelimit-limit:
+ - '10'
+ x-ratelimit-remaining:
+ - '6'
+ x-ratelimit-reset:
+ - '59'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/TestSubawardsIntegration.test_list_subawards b/tests/cassettes/TestSubawardsIntegration.test_list_subawards
new file mode 100644
index 0000000..06695c0
--- /dev/null
+++ b/tests/cassettes/TestSubawardsIntegration.test_list_subawards
@@ -0,0 +1,95 @@
+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/subawards/?page=1&limit=10&shape=award_key%2Cprime_recipient%28uei%2Cdisplay_name%29%2Csubaward_recipient%28uei%2Cdisplay_name%29
+ response:
+ body:
+ string: '{"count":3094272,"next":"http://tango.makegov.com/api/subawards/?limit=10&page=2&shape=award_key%2Cprime_recipient%28uei%2Cdisplay_name%29%2Csubaward_recipient%28uei%2Cdisplay_name%29","previous":null,"results":[{"award_key":"CONT_AWD_75P00123F37026_7570_HHSP233201500038I_7555","prime_recipient":{"uei":"YY46Q97AEZA8","display_name":"RAND
+ CORPORATION, THE"},"subaward_recipient":{"uei":"VNAYDLRGSKU3","display_name":"THE
+ URBAN INSTITUTE"}},{"award_key":"CONT_AWD_75P00123F37026_7570_HHSP233201500038I_7555","prime_recipient":{"uei":"YY46Q97AEZA8","display_name":"RAND
+ CORPORATION, THE"},"subaward_recipient":{"uei":"VNAYDLRGSKU3","display_name":"THE
+ URBAN INSTITUTE"}},{"award_key":"CONT_AWD_1333BJ24F00000002_1344_1333BJ21D00280002_1344","prime_recipient":{"uei":"VMRTJLWMQRH7","display_name":"HALVIK
+ CORP"},"subaward_recipient":{"uei":"MMLKHMM35UR8","display_name":"VARCONS
+ INC."}},{"award_key":"CONT_AWD_75N91025F00016_7529_75N91019D00024_7529","prime_recipient":{"uei":"HV8BH9BPG8Y9","display_name":"LEIDOS
+ BIOMEDICAL RESEARCH, INC."},"subaward_recipient":{"uei":"TN8FW9VP39W1","display_name":"RIGAKU
+ AMERICAS HOLDING, INC."}},{"award_key":"CONT_AWD_1333BJ24F00000002_1344_1333BJ21D00280002_1344","prime_recipient":{"uei":"VMRTJLWMQRH7","display_name":"HALVIK
+ CORP"},"subaward_recipient":{"uei":"XA1BEU918DZ3","display_name":"BNL, INC."}},{"award_key":"CONT_AWD_1333BJ24F00000002_1344_1333BJ21D00280002_1344","prime_recipient":{"uei":"VMRTJLWMQRH7","display_name":"HALVIK
+ CORP"},"subaward_recipient":{"uei":"LEPBQQF94NC5","display_name":"GOVERNMENT
+ NETWORK SOLUTIONS, INC."}},{"award_key":"CONT_AWD_80LARC23FA017_8000_80JSC023DA017_8000","prime_recipient":{"uei":"C6KAN1XKA915","display_name":"YULISTA
+ SOLUTIONS LLC"},"subaward_recipient":{"uei":"HZV7HJMAFC28","display_name":"HONEYWELL
+ INTERNATIONAL INC."}},{"award_key":"CONT_AWD_15F06724F0000945_1549_GS00F072CA_4732","prime_recipient":{"uei":"XYB4JU4PA6T4","display_name":"ECS
+ FEDERAL, LLC"},"subaward_recipient":{"uei":"NNB7LNYTJMN5","display_name":"MIRLOGIC
+ SOLUTIONS CORPORATION"}},{"award_key":"CONT_AWD_75N93024F00001_7529_75N93022D00007_7529","prime_recipient":{"uei":"SRG2J1WS9X63","display_name":"SRI
+ INTERNATIONAL"},"subaward_recipient":{"uei":"WMTJS85D4X19","display_name":"ANTECH
+ DIAGNOSTICS, INC"}},{"award_key":"CONT_AWD_70Z05326FENVI0002_7008_70Z05019DWESTON08_7008","prime_recipient":{"uei":"V8CUHEL7FQ33","display_name":"WESTON
+ SOLUTIONS, INC."},"subaward_recipient":{"uei":"HL9BANW2EUD4","display_name":"SGS
+ NORTH AMERICA INC."}}],"count_type":"approximate"}'
+ headers:
+ CF-RAY:
+ - 9cc90bf23f9f4cb7-MSP
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 12 Feb 2026 03:37:18 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=NSOWA%2FvWTY4xjLNbgxdMLbwSZkA73E2kl%2F1fvKdgum8Q1XwbpF%2BQ85pMYRFLYGlRtm%2FeFC19ZXpUjRqCfSTf549QAT1GRkpH2bOjpHPU"}]}'
+ Server:
+ - cloudflare
+ Transfer-Encoding:
+ - chunked
+ allow:
+ - GET, HEAD, OPTIONS
+ cf-cache-status:
+ - DYNAMIC
+ content-length:
+ - '2534'
+ 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:
+ - '999'
+ x-ratelimit-burst-reset:
+ - '59'
+ x-ratelimit-daily-limit:
+ - '2000000'
+ x-ratelimit-daily-remaining:
+ - '1999542'
+ x-ratelimit-daily-reset:
+ - '33783'
+ x-ratelimit-limit:
+ - '1000'
+ x-ratelimit-remaining:
+ - '999'
+ x-ratelimit-reset:
+ - '59'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/integration/test_assistance_integration.py b/tests/integration/test_assistance_integration.py
new file mode 100644
index 0000000..0d8a176
--- /dev/null
+++ b/tests/integration/test_assistance_integration.py
@@ -0,0 +1,46 @@
+"""Integration tests for assistance (financial assistance) endpoints
+
+Pytest Markers:
+ @pytest.mark.integration: Marks tests as integration tests that may hit external APIs
+ @pytest.mark.vcr(): Enables VCR recording/playback for HTTP interactions
+
+Usage:
+ pytest tests/integration/test_assistance_integration.py
+"""
+
+import pytest
+
+from tango import TangoAPIError, TangoAuthError
+from tests.integration.conftest import handle_api_exceptions
+from tests.integration.validation import validate_pagination
+
+
+@pytest.mark.vcr()
+@pytest.mark.integration
+class TestAssistanceIntegration:
+ """Integration tests for assistance transaction endpoints"""
+
+ @handle_api_exceptions("assistance")
+ def test_list_assistance(self, tango_client):
+ """Test listing assistance transactions with production data."""
+ try:
+ response = tango_client.list_assistance(limit=10)
+ except TangoAuthError:
+ pytest.skip(
+ "No matching cassette for assistance; re-record with TANGO_REFRESH_CASSETTES=1"
+ )
+ except TangoAPIError as e:
+ if e.status_code == 504:
+ pytest.skip(
+ "Cassette contains 504 Gateway Timeout; re-record with "
+ "TANGO_REFRESH_CASSETTES=true when API is healthy."
+ )
+ raise
+
+ validate_pagination(response)
+ assert response.count >= 0
+ assert isinstance(response.results, list)
+
+ if response.results:
+ item = response.results[0]
+ assert isinstance(item, dict), "Assistance results are raw dicts"
diff --git a/tests/integration/test_naics_integration.py b/tests/integration/test_naics_integration.py
new file mode 100644
index 0000000..c76e6b1
--- /dev/null
+++ b/tests/integration/test_naics_integration.py
@@ -0,0 +1,36 @@
+"""Integration tests for NAICS endpoints
+
+Pytest Markers:
+ @pytest.mark.integration: Marks tests as integration tests that may hit external APIs
+ @pytest.mark.vcr(): Enables VCR recording/playback for HTTP interactions
+
+Usage:
+ pytest tests/integration/test_naics_integration.py
+"""
+
+import pytest
+
+from tests.integration.validation import validate_pagination
+
+
+def validate_naics_fields(naics: dict) -> None:
+ """Validate NAICS item has expected fields."""
+ assert "code" in naics, "NAICS item must have 'code'"
+ assert naics.get("code") is not None, "NAICS 'code' must not be None"
+
+
+@pytest.mark.vcr()
+@pytest.mark.integration
+class TestNaicsIntegration:
+ """Integration tests for NAICS code endpoints"""
+
+ def test_list_naics(self, tango_client):
+ """Test listing NAICS codes with production data."""
+ response = tango_client.list_naics(limit=10)
+
+ validate_pagination(response)
+ assert response.count >= 0
+ assert isinstance(response.results, list)
+
+ if response.results:
+ validate_naics_fields(response.results[0])
diff --git a/tests/integration/test_offices_integration.py b/tests/integration/test_offices_integration.py
new file mode 100644
index 0000000..f17e1c1
--- /dev/null
+++ b/tests/integration/test_offices_integration.py
@@ -0,0 +1,60 @@
+"""Integration tests for office endpoints
+
+Pytest Markers:
+ @pytest.mark.integration: Marks tests as integration tests that may hit external APIs
+ @pytest.mark.vcr(): Enables VCR recording/playback for HTTP interactions
+
+Usage:
+ pytest tests/integration/test_offices_integration.py
+"""
+
+import pytest
+
+from tests.integration.conftest import handle_api_exceptions
+from tests.integration.validation import validate_pagination
+
+
+def validate_office_fields(office: dict) -> None:
+ """Validate office dict has required fields (API returns office_code, office_name, etc.)."""
+ assert "office_code" in office or "code" in office, "Office must have 'office_code' or 'code'"
+ code = office.get("office_code") or office.get("code")
+ assert code is not None, "Office code must not be None"
+
+
+@pytest.mark.vcr()
+@pytest.mark.integration
+class TestOfficesIntegration:
+ """Integration tests for office endpoints"""
+
+ def test_list_offices(self, tango_client):
+ """Test listing offices with production data."""
+ response = tango_client.list_offices(limit=10)
+
+ validate_pagination(response)
+ assert response.count >= 0
+ assert isinstance(response.results, list)
+
+ if response.results:
+ validate_office_fields(response.results[0])
+
+ @handle_api_exceptions("offices")
+ def test_get_office(self, tango_client):
+ """Test getting a specific office by code (if available)."""
+ try:
+ list_response = tango_client.list_offices(limit=1)
+ except Exception:
+ pytest.skip("Could not list offices")
+ if not list_response.results:
+ pytest.skip("No offices available to test get_office")
+
+ code = list_response.results[0].get("office_code") or list_response.results[0].get("code")
+ assert code is not None
+
+ try:
+ office = tango_client.get_office(code)
+ except Exception as e:
+ # Skip if cassette does not contain this get request or endpoint unavailable
+ pytest.skip(f"get_office not available or cassette mismatch: {e}")
+
+ validate_office_fields(office)
+ assert (office.get("office_code") or office.get("code")) == code
diff --git a/tests/integration/test_organizations_integration.py b/tests/integration/test_organizations_integration.py
new file mode 100644
index 0000000..160e6a2
--- /dev/null
+++ b/tests/integration/test_organizations_integration.py
@@ -0,0 +1,57 @@
+"""Integration tests for organization endpoints
+
+Pytest Markers:
+ @pytest.mark.integration: Marks tests as integration tests that may hit external APIs
+ @pytest.mark.vcr(): Enables VCR recording/playback for HTTP interactions
+
+Usage:
+ pytest tests/integration/test_organizations_integration.py
+"""
+
+import pytest
+
+from tests.integration.validation import validate_no_parsing_errors, validate_pagination
+
+
+def validate_organization_fields(org: object, minimal: bool = True) -> None:
+ """Validate organization object has required fields (dict or attribute access)."""
+ is_dict = isinstance(org, dict)
+ key = org.get("key") if is_dict else getattr(org, "key", None)
+ # key or fh_key is the identifier
+ fh_key = org.get("fh_key") if is_dict else getattr(org, "fh_key", None)
+ assert key is not None or fh_key is not None, "Organization must have 'key' or 'fh_key'"
+
+
+@pytest.mark.vcr()
+@pytest.mark.integration
+class TestOrganizationsIntegration:
+ """Integration tests for organization endpoints"""
+
+ def test_list_organizations(self, tango_client):
+ """Test listing organizations with production data."""
+ response = tango_client.list_organizations(limit=10)
+
+ validate_pagination(response)
+ assert response.count >= 0
+ assert isinstance(response.results, list)
+
+ if response.results:
+ org = response.results[0]
+ validate_organization_fields(org)
+ validate_no_parsing_errors(org)
+
+ def test_get_organization(self, tango_client):
+ """Test getting a specific organization by fh_key (if available)."""
+ list_response = tango_client.list_organizations(limit=1)
+ if not list_response.results:
+ pytest.skip("No organizations available to test get_organization")
+
+ org = list_response.results[0]
+ is_dict = isinstance(org, dict)
+ fh_key = org.get("fh_key") if is_dict else getattr(org, "fh_key", None)
+ if fh_key is None:
+ pytest.skip("First organization has no fh_key")
+
+ result = tango_client.get_organization(fh_key)
+ validate_organization_fields(result)
+ validate_no_parsing_errors(result)
diff --git a/tests/integration/test_otas_otidvs_integration.py b/tests/integration/test_otas_otidvs_integration.py
new file mode 100644
index 0000000..b7c953f
--- /dev/null
+++ b/tests/integration/test_otas_otidvs_integration.py
@@ -0,0 +1,100 @@
+"""Integration tests for OTA and OTIDV endpoints
+
+Pytest Markers:
+ @pytest.mark.integration: Marks tests as integration tests that may hit external APIs
+ @pytest.mark.vcr(): Enables VCR recording/playback for HTTP interactions
+
+Usage:
+ pytest tests/integration/test_otas_otidvs_integration.py
+"""
+
+import pytest
+
+from tango import TangoAuthError
+from tests.integration.conftest import handle_api_exceptions
+from tests.integration.validation import validate_no_parsing_errors, validate_pagination
+
+
+def validate_ota_fields(item: object) -> None:
+ """Validate OTA/OTIDV item has key (dict or attribute access)."""
+ is_dict = isinstance(item, dict)
+ key = item.get("key") if is_dict else getattr(item, "key", None)
+ assert key is not None, "OTA/OTIDV item must have 'key'"
+
+
+@pytest.mark.vcr()
+@pytest.mark.integration
+class TestOTAsIntegration:
+ """Integration tests for OTA (Other Transaction Agreement) endpoints"""
+
+ @handle_api_exceptions("otas")
+ def test_list_otas(self, tango_client):
+ """Test listing OTAs with production data."""
+ try:
+ response = tango_client.list_otas(limit=5)
+ except TangoAuthError:
+ pytest.skip("No matching cassette for OTAs; re-record with TANGO_REFRESH_CASSETTES=1")
+
+ validate_pagination(response)
+ assert response.count >= 0
+ assert isinstance(response.results, list)
+
+ if response.results:
+ validate_ota_fields(response.results[0])
+ validate_no_parsing_errors(response.results[0])
+
+ @handle_api_exceptions("otas")
+ def test_get_ota(self, tango_client):
+ """Test getting a single OTA by key (if available)."""
+ try:
+ list_response = tango_client.list_otas(limit=1)
+ except TangoAuthError:
+ pytest.skip("No matching cassette for OTAs; re-record with TANGO_REFRESH_CASSETTES=1")
+ if not list_response.results:
+ pytest.skip("No OTAs available to test get_ota")
+
+ item = list_response.results[0]
+ is_dict = isinstance(item, dict)
+ key = item.get("key") if is_dict else getattr(item, "key", None)
+ if key is None:
+ pytest.skip("First OTA has no key")
+
+ result = tango_client.get_ota(key)
+ validate_ota_fields(result)
+ validate_no_parsing_errors(result)
+
+
+@pytest.mark.vcr()
+@pytest.mark.integration
+class TestOTIDVsIntegration:
+ """Integration tests for OTIDV (Other Transaction IDV) endpoints"""
+
+ @handle_api_exceptions("otidvs")
+ def test_list_otidvs(self, tango_client):
+ """Test listing OTIDVs with production data."""
+ response = tango_client.list_otidvs(limit=5)
+
+ validate_pagination(response)
+ assert response.count >= 0
+ assert isinstance(response.results, list)
+
+ if response.results:
+ validate_ota_fields(response.results[0])
+ validate_no_parsing_errors(response.results[0])
+
+ @handle_api_exceptions("otidvs")
+ def test_get_otidv(self, tango_client):
+ """Test getting a single OTIDV by key (if available)."""
+ list_response = tango_client.list_otidvs(limit=1)
+ if not list_response.results:
+ pytest.skip("No OTIDVs available to test get_otidv")
+
+ item = list_response.results[0]
+ is_dict = isinstance(item, dict)
+ key = item.get("key") if is_dict else getattr(item, "key", None)
+ if key is None:
+ pytest.skip("First OTIDV has no key")
+
+ result = tango_client.get_otidv(key)
+ validate_ota_fields(result)
+ validate_no_parsing_errors(result)
diff --git a/tests/integration/test_subawards_integration.py b/tests/integration/test_subawards_integration.py
new file mode 100644
index 0000000..77e0624
--- /dev/null
+++ b/tests/integration/test_subawards_integration.py
@@ -0,0 +1,43 @@
+"""Integration tests for subaward endpoints
+
+Pytest Markers:
+ @pytest.mark.integration: Marks tests as integration tests that may hit external APIs
+ @pytest.mark.vcr(): Enables VCR recording/playback for HTTP interactions
+
+Usage:
+ pytest tests/integration/test_subawards_integration.py
+"""
+
+import pytest
+
+from tests.integration.conftest import handle_api_exceptions
+from tests.integration.validation import validate_no_parsing_errors, validate_pagination
+
+
+def validate_subaward_fields(item: object) -> None:
+ """Validate subaward item has an identifier (award_key or id if present)."""
+ is_dict = isinstance(item, dict)
+ id_val = item.get("id") if is_dict else getattr(item, "id", None)
+ award_key = item.get("award_key") if is_dict else getattr(item, "award_key", None)
+ assert id_val is not None or award_key is not None, (
+ "Subaward item must have 'id' or 'award_key'"
+ )
+
+
+@pytest.mark.vcr()
+@pytest.mark.integration
+class TestSubawardsIntegration:
+ """Integration tests for subaward endpoints"""
+
+ @handle_api_exceptions("subawards")
+ def test_list_subawards(self, tango_client):
+ """Test listing subawards with production data."""
+ response = tango_client.list_subawards(limit=10)
+
+ validate_pagination(response)
+ assert response.count >= 0
+ assert isinstance(response.results, list)
+
+ if response.results:
+ validate_subaward_fields(response.results[0])
+ validate_no_parsing_errors(response.results[0])
diff --git a/tests/production/test_production_smoke.py b/tests/production/test_production_smoke.py
index 93442fc..567114d 100644
--- a/tests/production/test_production_smoke.py
+++ b/tests/production/test_production_smoke.py
@@ -312,3 +312,407 @@ def test_get_vehicle(self, production_client):
assert vehicle.get("uuid") is not None or hasattr(vehicle, "uuid"), (
"Vehicle uuid should be present"
)
+
+ # ============================================================================
+ # OTA Endpoints
+ # ============================================================================
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_otas_basic(self, production_client):
+ """Test basic OTA listing
+
+ Validates:
+ - Basic OTA listing works
+ - Response parsing is correct
+ - Pagination structure is valid
+ """
+ response = production_client.list_otas(limit=5)
+
+ # Validate response structure
+ validate_pagination(response)
+ # OTAs may be empty, so we just validate structure
+ assert response.count >= 0, "Count should be non-negative"
+
+ if len(response.results) > 0:
+ ota = response.results[0]
+ validate_no_parsing_errors(ota)
+ # Verify required fields are present
+ assert ota.get("key") is not None, "OTA key should be present"
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_get_ota(self, production_client):
+ """Test getting a specific OTA
+
+ Validates:
+ - Single OTA retrieval works
+ - OTA parsing is correct
+ """
+ # First, get an OTA key from listing
+ list_response = production_client.list_otas(limit=1)
+ if not list_response.results:
+ pytest.skip("No OTAs available to test get_ota")
+
+ ota_key = (
+ list_response.results[0].get("key")
+ if isinstance(list_response.results[0], dict)
+ else list_response.results[0].key
+ )
+ assert ota_key is not None, "OTA key should be present"
+
+ ota = production_client.get_ota(ota_key)
+
+ validate_no_parsing_errors(ota)
+ assert ota.get("key") is not None, "OTA key should be present"
+
+ # ============================================================================
+ # OTIDV Endpoints
+ # ============================================================================
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_otidvs_basic(self, production_client):
+ """Test basic OTIDV listing
+
+ Validates:
+ - Basic OTIDV listing works
+ - Response parsing is correct
+ - Pagination structure is valid
+ """
+ response = production_client.list_otidvs(limit=5)
+
+ # Validate response structure
+ validate_pagination(response)
+ # OTIDVs may be empty, so we just validate structure
+ assert response.count >= 0, "Count should be non-negative"
+
+ if len(response.results) > 0:
+ otidv = response.results[0]
+ validate_no_parsing_errors(otidv)
+ # Verify required fields are present
+ assert otidv.get("key") is not None, "OTIDV key should be present"
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_get_otidv(self, production_client):
+ """Test getting a specific OTIDV
+
+ Validates:
+ - Single OTIDV retrieval works
+ - OTIDV parsing is correct
+ """
+ # First, get an OTIDV key from listing
+ list_response = production_client.list_otidvs(limit=1)
+ if not list_response.results:
+ pytest.skip("No OTIDVs available to test get_otidv")
+
+ otidv_key = (
+ list_response.results[0].get("key")
+ if isinstance(list_response.results[0], dict)
+ else list_response.results[0].key
+ )
+ assert otidv_key is not None, "OTIDV key should be present"
+
+ otidv = production_client.get_otidv(otidv_key)
+
+ validate_no_parsing_errors(otidv)
+ assert otidv.get("key") is not None, "OTIDV key should be present"
+
+ # ============================================================================
+ # Organization Endpoints
+ # ============================================================================
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_organizations(self, production_client):
+ """Test organization listing
+
+ Validates:
+ - Organization listing works
+ - Response parsing is correct
+ - Pagination structure is valid
+ """
+ response = production_client.list_organizations(limit=5)
+
+ validate_pagination(response)
+ assert response.count >= 0, "Count should be non-negative"
+
+ if len(response.results) > 0:
+ org = response.results[0]
+ validate_no_parsing_errors(org)
+ # Organizations should have fh_key or key
+ has_key = org.get("fh_key") is not None or org.get("key") is not None
+ assert has_key, "Organization should have fh_key or key"
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_get_organization(self, production_client):
+ """Test getting a specific organization
+
+ Validates:
+ - Single organization retrieval works
+ - Organization parsing is correct
+ """
+ # First, get an organization fh_key from listing
+ list_response = production_client.list_organizations(limit=1)
+ if not list_response.results:
+ pytest.skip("No organizations available to test get_organization")
+
+ org = list_response.results[0]
+ fh_key = org.get("fh_key") if isinstance(org, dict) else getattr(org, "fh_key", None)
+ if fh_key is None:
+ pytest.skip("First organization has no fh_key")
+
+ result = production_client.get_organization(fh_key)
+
+ validate_no_parsing_errors(result)
+
+ # ============================================================================
+ # Office Endpoints
+ # ============================================================================
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_offices(self, production_client):
+ """Test office listing
+
+ Validates:
+ - Office listing works
+ - Response parsing is correct
+ - Pagination structure is valid
+ """
+ response = production_client.list_offices(limit=5)
+
+ validate_pagination(response)
+ assert response.count >= 0, "Count should be non-negative"
+
+ if len(response.results) > 0:
+ office = response.results[0]
+ # Offices are returned as raw dicts
+ assert isinstance(office, dict), "Office should be a dict"
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_get_office(self, production_client):
+ """Test getting a specific office
+
+ Validates:
+ - Single office retrieval works
+ - Office data is returned
+ """
+ # First, get an office code from listing
+ list_response = production_client.list_offices(limit=1)
+ if not list_response.results:
+ pytest.skip("No offices available to test get_office")
+
+ office = list_response.results[0]
+ # API returns 'office_code', not 'code'
+ code = (
+ office.get("office_code")
+ if isinstance(office, dict)
+ else getattr(office, "office_code", None)
+ )
+ if code is None:
+ pytest.skip("First office has no office_code")
+
+ result = production_client.get_office(code)
+
+ assert isinstance(result, dict), "Office should be a dict"
+ assert result.get("office_code") is not None, "Office code should be present"
+
+ # ============================================================================
+ # Subaward Endpoints
+ # ============================================================================
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_subawards(self, production_client):
+ """Test subaward listing
+
+ Validates:
+ - Subaward listing works
+ - Response parsing is correct
+ - Pagination structure is valid
+ """
+ response = production_client.list_subawards(limit=5)
+
+ validate_pagination(response)
+ assert response.count >= 0, "Count should be non-negative"
+
+ if len(response.results) > 0:
+ subaward = response.results[0]
+ validate_no_parsing_errors(subaward)
+ # Subawards should have id or award_key
+ has_id = subaward.get("id") is not None or subaward.get("award_key") is not None
+ assert has_id, "Subaward should have id or award_key"
+
+ # ============================================================================
+ # NAICS Endpoints
+ # ============================================================================
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_naics(self, production_client):
+ """Test NAICS code listing
+
+ Validates:
+ - NAICS listing works
+ - Response parsing is correct
+ - Pagination structure is valid
+ """
+ response = production_client.list_naics(limit=5)
+
+ validate_pagination(response)
+ assert response.count >= 0, "Count should be non-negative"
+
+ if len(response.results) > 0:
+ naics = response.results[0]
+ # NAICS codes are returned as raw dicts
+ assert isinstance(naics, dict), "NAICS should be a dict"
+ # Should have code field
+ assert naics.get("code") is not None, "NAICS code should be present"
+
+ # ============================================================================
+ # Assistance Endpoints
+ # ============================================================================
+
+ # @handle_rate_limit
+ # @handle_auth_error
+ # def test_list_assistance(self, production_client):
+ # """Test assistance listing
+
+ # Validates:
+ # - Assistance listing works
+ # - Response parsing is correct
+ # - Pagination structure is valid
+ # """
+ # response = production_client.list_assistance(limit=5)
+
+ # validate_pagination(response)
+ # assert response.count >= 0, "Count should be non-negative"
+
+ # if len(response.results) > 0:
+ # assistance = response.results[0]
+ # # Assistance records are returned as raw dicts
+ # assert isinstance(assistance, dict), "Assistance should be a dict"
+
+ # ============================================================================
+ # Forecast Endpoints
+ # ============================================================================
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_forecasts(self, production_client):
+ """Test forecast listing
+
+ Validates:
+ - Forecast listing works
+ - Response parsing is correct
+ - Pagination structure is valid
+ """
+ response = production_client.list_forecasts(limit=5)
+
+ validate_pagination(response)
+ assert response.count >= 0, "Count should be non-negative"
+
+ if len(response.results) > 0:
+ forecast = response.results[0]
+ validate_no_parsing_errors(forecast)
+
+ # ============================================================================
+ # Notice Endpoints
+ # ============================================================================
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_notices(self, production_client):
+ """Test notice listing
+
+ Validates:
+ - Notice listing works
+ - Response parsing is correct
+ - Pagination structure is valid
+ """
+ response = production_client.list_notices(limit=5)
+
+ validate_pagination(response)
+ assert response.count >= 0, "Count should be non-negative"
+
+ if len(response.results) > 0:
+ notice = response.results[0]
+ validate_no_parsing_errors(notice)
+
+ # ============================================================================
+ # Grant Endpoints
+ # ============================================================================
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_grants(self, production_client):
+ """Test grant listing
+
+ Validates:
+ - Grant listing works
+ - Response parsing is correct
+ - Pagination structure is valid
+ """
+ response = production_client.list_grants(limit=5)
+
+ validate_pagination(response)
+ assert response.count >= 0, "Count should be non-negative"
+
+ if len(response.results) > 0:
+ grant = response.results[0]
+ validate_no_parsing_errors(grant)
+
+ # ============================================================================
+ # Webhook Endpoints
+ # ============================================================================
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_webhook_event_types(self, production_client):
+ """Test webhook event types listing
+
+ Validates:
+ - Webhook event types endpoint works
+ - Response structure is valid
+ """
+ response = production_client.list_webhook_event_types()
+
+ # Response should have event_types list
+ assert hasattr(response, "event_types"), "Response should have event_types"
+ assert isinstance(response.event_types, list), "event_types should be a list"
+
+ # Response should have subject_types list
+ assert hasattr(response, "subject_types"), "Response should have subject_types"
+ assert isinstance(response.subject_types, list), "subject_types should be a list"
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_webhook_endpoints(self, production_client):
+ """Test webhook endpoints listing
+
+ Validates:
+ - Webhook endpoints listing works
+ - Response parsing is correct
+ """
+ response = production_client.list_webhook_endpoints(limit=5)
+
+ validate_pagination(response)
+ assert response.count >= 0, "Count should be non-negative"
+
+ @handle_rate_limit
+ @handle_auth_error
+ def test_list_webhook_subscriptions(self, production_client):
+ """Test webhook subscriptions listing
+
+ Validates:
+ - Webhook subscriptions listing works
+ - Response parsing is correct
+ """
+ response = production_client.list_webhook_subscriptions()
+
+ validate_pagination(response)
+ assert response.count >= 0, "Count should be non-negative"
diff --git a/tests/test_client.py b/tests/test_client.py
index e727c34..eb95fbb 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -155,6 +155,214 @@ def test_list_entities(self, mock_request):
assert entities.results[0]["legal_business_name"] == "Test Company"
assert entities.results[0]["uei"] == "ABC123DEF456"
+ @patch("tango.client.httpx.Client.request")
+ def test_list_offices(self, mock_request):
+ """Test list_offices method"""
+ mock_response = Mock()
+ mock_response.is_success = True
+ mock_response.json.return_value = {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [{"code": "OFF1", "name": "Office One", "agency": "4700"}],
+ }
+ mock_response.content = b'{"count": 1}'
+ mock_request.return_value = mock_response
+
+ client = TangoClient(api_key="test-key")
+ offices = client.list_offices(limit=10)
+
+ assert offices.count == 1
+ assert len(offices.results) == 1
+ assert offices.results[0]["code"] == "OFF1"
+ assert offices.results[0]["name"] == "Office One"
+
+ @patch("tango.client.httpx.Client.request")
+ def test_get_office(self, mock_request):
+ """Test get_office method"""
+ mock_response = Mock()
+ mock_response.is_success = True
+ mock_response.json.return_value = {"code": "OFF1", "name": "Office One", "agency": "4700"}
+ mock_response.content = b'{"code": "OFF1"}'
+ mock_request.return_value = mock_response
+
+ client = TangoClient(api_key="test-key")
+ office = client.get_office("OFF1")
+
+ assert office["code"] == "OFF1"
+ assert office["name"] == "Office One"
+
+ @patch("tango.client.httpx.Client.request")
+ def test_list_naics(self, mock_request):
+ """Test list_naics method"""
+ mock_response = Mock()
+ mock_response.is_success = True
+ mock_response.json.return_value = {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [{"code": "541511", "description": "Custom Computer Programming"}],
+ }
+ mock_response.content = b'{"count": 1}'
+ mock_request.return_value = mock_response
+
+ client = TangoClient(api_key="test-key")
+ naics = client.list_naics(limit=10, search="programming")
+
+ assert naics.count == 1
+ assert len(naics.results) == 1
+ assert naics.results[0]["code"] == "541511"
+ call_args = mock_request.call_args
+ assert call_args[1]["params"]["search"] == "programming"
+
+ @patch("tango.client.httpx.Client.request")
+ def test_list_organizations_with_default_shape(self, mock_request):
+ """Test list_organizations uses default minimal shape"""
+ mock_response = Mock()
+ mock_response.is_success = True
+ mock_response.json.return_value = {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [{"key": "ORG1", "fh_key": "DOD", "name": "Department of Defense"}],
+ }
+ mock_response.content = b'{"count": 1}'
+ mock_request.return_value = mock_response
+
+ client = TangoClient(api_key="test-key")
+ orgs = client.list_organizations(limit=10)
+
+ call_args = mock_request.call_args
+ assert call_args[1]["params"]["shape"] == ShapeConfig.ORGANIZATIONS_MINIMAL
+ assert orgs.count == 1
+ assert orgs.results[0]["key"] == "ORG1"
+
+ @patch("tango.client.httpx.Client.request")
+ def test_list_otas_with_default_shape(self, mock_request):
+ """Test list_otas uses default minimal shape and cursor pagination"""
+ mock_response = Mock()
+ mock_response.is_success = True
+ mock_response.json.return_value = {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "key": "OTA-1",
+ "piid": "PIID-OTA",
+ "award_date": "2024-01-01",
+ "recipient": {"display_name": "Acme", "uei": "UEI123"},
+ "description": "OTA award",
+ "total_contract_value": "50000.00",
+ "obligated": "25000.00",
+ }
+ ],
+ "cursor": "next-page-token",
+ }
+ mock_response.content = b'{"count": 1}'
+ mock_request.return_value = mock_response
+
+ client = TangoClient(api_key="test-key")
+ otas = client.list_otas(limit=10)
+
+ call_args = mock_request.call_args
+ assert call_args[1]["params"]["shape"] == ShapeConfig.OTAS_MINIMAL
+ assert otas.count == 1
+ assert otas.results[0]["key"] == "OTA-1"
+ assert otas.cursor == "next-page-token"
+
+ @patch("tango.client.httpx.Client.request")
+ def test_list_otidvs_with_default_shape(self, mock_request):
+ """Test list_otidvs uses default minimal shape"""
+ mock_response = Mock()
+ mock_response.is_success = True
+ mock_response.json.return_value = {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "key": "OTIDV-1",
+ "piid": "PIID-OT",
+ "award_date": "2024-01-01",
+ "recipient": {"display_name": "Acme", "uei": "UEI123"},
+ "description": "OTIDV",
+ "total_contract_value": "100000.00",
+ "obligated": "50000.00",
+ "idv_type": {},
+ }
+ ],
+ }
+ mock_response.content = b'{"count": 1}'
+ mock_request.return_value = mock_response
+
+ client = TangoClient(api_key="test-key")
+ otidvs = client.list_otidvs(limit=10)
+
+ call_args = mock_request.call_args
+ assert call_args[1]["params"]["shape"] == ShapeConfig.OTIDVS_MINIMAL
+ assert otidvs.count == 1
+ assert otidvs.results[0]["key"] == "OTIDV-1"
+
+ @patch("tango.client.httpx.Client.request")
+ def test_list_subawards_with_default_shape(self, mock_request):
+ """Test list_subawards uses default minimal shape"""
+ mock_response = Mock()
+ mock_response.is_success = True
+ mock_response.json.return_value = {
+ "count": 1,
+ "next": None,
+ "previous": None,
+ "results": [
+ {
+ "id": "SUB-1",
+ "award_key": "CONT_AWD_123",
+ "prime_recipient": {"uei": "P1", "display_name": "Prime"},
+ "subaward_recipient": {"uei": "S1", "display_name": "Sub"},
+ "amount": "10000.00",
+ }
+ ],
+ }
+ mock_response.content = b'{"count": 1}'
+ mock_request.return_value = mock_response
+
+ client = TangoClient(api_key="test-key")
+ subawards = client.list_subawards(limit=10)
+
+ call_args = mock_request.call_args
+ assert call_args[1]["params"]["shape"] == ShapeConfig.SUBAWARDS_MINIMAL
+ assert subawards.count == 1
+ # Default shape does not include id (API rejects it); assert on award_key
+ assert subawards.results[0]["award_key"] == "CONT_AWD_123"
+
+ @patch("tango.client.httpx.Client.request")
+ def test_list_assistance(self, mock_request):
+ """Test list_assistance method (keyset pagination, raw results)"""
+ mock_response = Mock()
+ mock_response.is_success = True
+ mock_response.json.return_value = {
+ "count": 2,
+ "next": None,
+ "previous": None,
+ "results": [
+ {"award_key": "ASST-1", "recipient": "Recipient A", "fiscal_year": 2024},
+ {"award_key": "ASST-2", "recipient": "Recipient B", "fiscal_year": 2023},
+ ],
+ "cursor": None,
+ }
+ mock_response.content = b'{"count": 2}'
+ mock_request.return_value = mock_response
+
+ client = TangoClient(api_key="test-key")
+ assistance = client.list_assistance(limit=25, fiscal_year=2024)
+
+ assert assistance.count == 2
+ assert len(assistance.results) == 2
+ assert assistance.results[0]["award_key"] == "ASST-1"
+ call_args = mock_request.call_args
+ assert call_args[1]["params"]["fiscal_year"] == 2024
+ assert call_args[1]["params"]["limit"] == 25
+
@patch("tango.client.httpx.Client.request")
def test_error_handling_401(self, mock_request):
"""Test 401 authentication error handling"""
@@ -358,7 +566,10 @@ def test_webhook_test_delivery_and_sample_payload(self, mock_request):
sample_response.status_code = 200
sample_response.json.return_value = {
"event_type": "awards.new_award",
- "sample_delivery": {"timestamp": "2026-01-01T00:00:00Z", "events": [{"event_type": "awards.new_award"}]},
+ "sample_delivery": {
+ "timestamp": "2026-01-01T00:00:00Z",
+ "events": [{"event_type": "awards.new_award"}],
+ },
}
sample_response.content = b'{"event_type": "awards.new_award"}'
@@ -435,13 +646,20 @@ def test_webhook_endpoints_crud(self, mock_request):
delete_response.status_code = 204
delete_response.content = b""
- mock_request.side_effect = [list_response, create_response, update_response, delete_response]
+ mock_request.side_effect = [
+ list_response,
+ create_response,
+ update_response,
+ delete_response,
+ ]
endpoints = client.list_webhook_endpoints(page=2, limit=10)
assert endpoints.count == 1
assert endpoints.results[0].name == "yoni"
- created = client.create_webhook_endpoint("https://example.com/tango/webhooks", is_active=True)
+ created = client.create_webhook_endpoint(
+ "https://example.com/tango/webhooks", is_active=True
+ )
assert created.secret == "secret"
updated = client.update_webhook_endpoint("ep-1", is_active=False)
@@ -1034,26 +1252,30 @@ def test_list_contracts_naics_code_filter_separation(self, mock_request):
mock_request.return_value = mock_response
client = TangoClient(api_key="test-key")
-
+
# Test with naics_code as keyword argument
client.list_contracts(naics_code="541511", limit=10)
-
+
# Verify the HTTP request was made
assert mock_request.called
-
+
# Get the call arguments
call_args = mock_request.call_args
params = call_args[1]["params"]
-
+
# Verify naics_code is mapped to 'naics' in query params (API expects 'naics' not 'naics_code')
assert "naics" in params, "naics should be in query parameters (mapped from naics_code)"
assert params["naics"] == "541511", "naics value should be '541511'"
- assert "naics_code" not in params, "naics_code should be mapped to 'naics', not sent as naics_code"
-
+ assert "naics_code" not in params, (
+ "naics_code should be mapped to 'naics', not sent as naics_code"
+ )
+
# Verify naics_code is NOT in the shape parameter
shape = params.get("shape", "")
- assert "naics_code" not in shape, f"naics_code should NOT be in shape parameter, but shape is: {shape}"
-
+ assert "naics_code" not in shape, (
+ f"naics_code should NOT be in shape parameter, but shape is: {shape}"
+ )
+
# Verify shape parameter exists and is separate
assert "shape" in params, "shape parameter should exist"
assert isinstance(params["shape"], str), "shape should be a string"