Skip to content

Commit 42d760d

Browse files
docs: port docs-only content from docs.makegov.com (makegov/docs#16)
Adds three SDK-doc files that previously lived only in makegov/docs under docs/sdks/python/. This is the pre-cutover content port for the auto-pull pipeline (makegov/docs#15), so the docs-site versions can be deleted at cutover without losing content. - docs/ERRORS.md — exception hierarchy, recovery patterns, and shape-error classes (ShapeValidationError, ShapeParseError, TypeGenerationError, ModelInstantiationError) that have no dedicated entry in API_REFERENCE.md - docs/PAGINATION.md — page-based vs cursor-based strategies, iteration patterns, PaginatedResponse field reference - docs/CLIENT.md — TangoClient constructor reference, rate_limit_info and last_response_headers properties, retry-semantics note Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 229f49c commit 42d760d

4 files changed

Lines changed: 348 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4949
- **Shape validator agrees with server on `naics(...)` / `psc(...)` expansions.** The client-side `ShapeParser.validate()` previously rejected the canonical `shape=naics(code,description)` form (which the server has always accepted) and also rejected the alias `shape=naics_code(code,description)`. The parser now mirrors the server's `_EXPAND_ALIASES` (introduced in Tango PR makegov/tango#2259) and rewrites `naics_code(...)` / `psc_code(...)` to their canonical `naics(...)` / `psc(...)` form at parse time. Bare scalar leaves (`shape=naics_code` / `shape=psc_code`) are left untouched and still return the raw column value, matching the server. Schemas for `Contract`, `Forecast`, `Opportunity`, `Notice`, and `Vehicle` gained explicit `naics` / `psc` expand entries backed by the existing `CodeDescription` nested model. Fixes makegov/tango#2266.
5050
- **`Subaward` schema matches the server's `SubawardSerializer`.** The previous `SUBAWARD_SCHEMA` declared two fields the server has never exposed (`id`, `amount`) and was missing every real field on the resource — including `piid`, `key`, `awarding_office` / `funding_office` / `place_of_performance` / `subaward_details` / `fsrs_details` / `highly_compensated_officers` / `usaspending_permalink`, and the denormalized `prime_awardee_*` / `recipient_*` lookup columns. Shape strings that referenced any real field (e.g. `shape="piid"`) would fail client-side validation with `unknown_field`, and conversely the SDK happily passed `shape="id"` / `shape="amount"` through to the server, where they were rejected. `SUBAWARD_SCHEMA` is now derived directly from `awards.serializers.subawards.SubawardSerializer` and the resource's runtime `available_fields`. The `Subaward` dataclass in `tango/models.py` was updated to match. New nested schemas `SubawardDetails`, `FsrsDetails`, `SubawardPlaceOfPerformance`, and `HighlyCompensatedOfficer` are registered so the corresponding shape expansions validate end-to-end.
5151

52+
### Documentation
53+
- New `docs/ERRORS.md` — full exception hierarchy, recovery patterns, and the shape-error classes (`ShapeValidationError`, `ShapeParseError`, `TypeGenerationError`, `ModelInstantiationError`). Ported from `docs.makegov.com/sdks/python/errors.md` ahead of the docs-site auto-pull cutover (makegov/docs#15 / makegov/docs#16).
54+
- New `docs/PAGINATION.md` — page-based vs cursor-based strategies, iteration patterns, and the `PaginatedResponse` field reference. Ported from `docs.makegov.com/sdks/python/pagination.md`.
55+
- New `docs/CLIENT.md``TangoClient` constructor reference, `rate_limit_info` / `last_response_headers` properties, and retry-semantics note (the SDK has no built-in retry). Ported from `docs.makegov.com/sdks/python/client.md`.
56+
5257
## [0.6.0] - 2026-05-07
5358

5459
### Added

docs/CLIENT.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Client Configuration
2+
3+
`TangoClient` is the entry point for every API call. This guide covers the constructor, the rate-limit and response-inspection properties, and how the client handles authentication and transport.
4+
5+
For per-method signatures, see [`API_REFERENCE.md`](API_REFERENCE.md). For error handling, see [`ERRORS.md`](ERRORS.md). For shaping responses, see [`SHAPES.md`](SHAPES.md).
6+
7+
## Constructor
8+
9+
```python
10+
from tango import TangoClient
11+
12+
client = TangoClient(
13+
api_key="your-api-key", # or set TANGO_API_KEY env var
14+
base_url="https://tango.makegov.com", # default
15+
user_agent="my-app/1.0", # optional custom User-Agent
16+
extra_headers={"X-Custom": "val"}, # optional additional headers
17+
)
18+
```
19+
20+
| Parameter | Type | Default | Description |
21+
|---|---|---|---|
22+
| `api_key` | `str \| None` | `None` | API key. Falls back to `TANGO_API_KEY` environment variable. |
23+
| `base_url` | `str` | `"https://tango.makegov.com"` | Base URL for the Tango API. |
24+
| `user_agent` | `str \| None` | `None` | Custom User-Agent string appended to the default. |
25+
| `extra_headers` | `dict[str, str] \| None` | `None` | Additional HTTP headers sent with every request. |
26+
27+
The client uses [httpx](https://www.python-httpx.org/) under the hood with a 30-second timeout. The API key is sent as an `X-API-KEY` header on every request.
28+
29+
## Properties
30+
31+
### `rate_limit_info`
32+
33+
Returns rate limit information from the most recent API response.
34+
35+
```python
36+
resp = client.list_contracts(limit=5)
37+
info = client.rate_limit_info
38+
39+
if info:
40+
print(f"Remaining: {info.remaining}/{info.limit}")
41+
print(f"Resets in: {info.reset}s")
42+
print(f"Daily remaining: {info.daily_remaining}/{info.daily_limit}")
43+
```
44+
45+
The `RateLimitInfo` object exposes:
46+
47+
| Field | Type | Description |
48+
|---|---|---|
49+
| `limit` | `int \| None` | Request limit for the current window |
50+
| `remaining` | `int \| None` | Requests remaining in the current window |
51+
| `reset` | `int \| None` | Seconds until the window resets |
52+
| `daily_limit` | `int \| None` | Daily request limit |
53+
| `daily_remaining` | `int \| None` | Daily requests remaining |
54+
| `daily_reset` | `int \| None` | Seconds until the daily limit resets |
55+
| `burst_limit` | `int \| None` | Burst request limit |
56+
| `burst_remaining` | `int \| None` | Burst requests remaining |
57+
| `burst_reset` | `int \| None` | Seconds until the burst limit resets |
58+
59+
### `last_response_headers`
60+
61+
Returns the full HTTP headers from the most recent API response, as an `httpx.Headers` object.
62+
63+
```python
64+
resp = client.list_contracts(limit=5)
65+
headers = client.last_response_headers
66+
print(headers["content-type"])
67+
```
68+
69+
## Retry Semantics
70+
71+
The SDK does **not** include built-in retry or backoff. Each method call maps to exactly one HTTP request. If you need retry-on-429 or retry-on-transient-error behavior, wrap your calls (or catch [`TangoRateLimitError`](ERRORS.md#tangoratelimiterror-429) and use `wait_in_seconds`).
72+
73+
See the [Rate Limits guide](https://docs.makegov.com/guides/patterns/rate-limits/) for recommended strategies.

docs/ERRORS.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Error Handling
2+
3+
The SDK raises typed exceptions for HTTP errors and for shape-related failures. All exceptions are importable from `tango.exceptions` (and re-exported from the top-level `tango` package for the API errors).
4+
5+
For a compact reference of each class, see [`API_REFERENCE.md` § Error Handling](API_REFERENCE.md#error-handling). This guide covers the hierarchy, recovery patterns, and the shape-error classes that don't have a dedicated section there.
6+
7+
## Exception Hierarchy
8+
9+
```
10+
TangoAPIError
11+
├── TangoAuthError (401 Unauthorized)
12+
├── TangoNotFoundError (404 Not Found)
13+
├── TangoValidationError (400 Bad Request)
14+
├── TangoRateLimitError (429 Too Many Requests)
15+
└── ShapeError
16+
├── ShapeValidationError (invalid field names)
17+
├── ShapeParseError (invalid shape syntax)
18+
├── TypeGenerationError (dynamic type generation failure)
19+
└── ModelInstantiationError (model creation failure)
20+
```
21+
22+
## API Errors
23+
24+
### TangoAPIError (base)
25+
26+
All API errors inherit from this class.
27+
28+
| Attribute | Type | Description |
29+
|---|---|---|
30+
| `status_code` | `int \| None` | HTTP status code |
31+
| `response_data` | `dict` | Parsed response body (credentials redacted) |
32+
| `message` | `str` | Human-readable error message |
33+
34+
```python
35+
from tango import TangoClient
36+
from tango.exceptions import TangoAPIError
37+
38+
client = TangoClient()
39+
40+
try:
41+
resp = client.list_contracts(limit=10)
42+
except TangoAPIError as e:
43+
print(f"API error {e.status_code}: {e.message}")
44+
```
45+
46+
### TangoAuthError (401)
47+
48+
Raised when the API key is missing, invalid, or expired.
49+
50+
```python
51+
from tango.exceptions import TangoAuthError
52+
53+
try:
54+
client = TangoClient(api_key="invalid-key")
55+
client.list_contracts(limit=1)
56+
except TangoAuthError:
57+
print("Check your API key")
58+
```
59+
60+
### TangoNotFoundError (404)
61+
62+
Raised when a resource doesn't exist.
63+
64+
```python
65+
from tango.exceptions import TangoNotFoundError
66+
67+
try:
68+
entity = client.get_entity("INVALID_UEI")
69+
except TangoNotFoundError:
70+
print("Entity not found")
71+
```
72+
73+
### TangoValidationError (400)
74+
75+
Raised for invalid request parameters (bad date format, unknown filter, etc.).
76+
77+
### TangoRateLimitError (429)
78+
79+
Raised when you exceed rate limits. Includes retry information.
80+
81+
| Attribute | Type | Description |
82+
|---|---|---|
83+
| `wait_in_seconds` | `int \| None` | Seconds to wait before retrying |
84+
| `detail` | `str \| None` | Human-readable rate limit message |
85+
| `limit_type` | `str \| None` | `"burst"` or `"daily"` |
86+
87+
```python
88+
import time
89+
from tango.exceptions import TangoRateLimitError
90+
91+
try:
92+
resp = client.list_contracts(limit=10)
93+
except TangoRateLimitError as e:
94+
if e.wait_in_seconds:
95+
print(f"Rate limited ({e.limit_type}). Retrying in {e.wait_in_seconds}s...")
96+
time.sleep(e.wait_in_seconds)
97+
resp = client.list_contracts(limit=10)
98+
```
99+
100+
> **Note:** The SDK does not include built-in retry or backoff. You are responsible for handling rate limit errors. See the [Rate Limits guide](https://docs.makegov.com/guides/patterns/rate-limits/) for strategies.
101+
102+
## Shape Errors
103+
104+
These are raised when there's a problem with the response shaping configuration, not the API itself. See [`SHAPES.md`](SHAPES.md) for shape syntax.
105+
106+
### ShapeValidationError
107+
108+
Raised when a shape string references field names that don't exist on the model.
109+
110+
```python
111+
from tango.exceptions import ShapeValidationError
112+
113+
try:
114+
resp = client.list_contracts(shape="key,piid,nonexistent_field", limit=1)
115+
except ShapeValidationError as e:
116+
print(f"Invalid shape: {e}")
117+
print(f"Shape string: {e.shape}")
118+
```
119+
120+
### ShapeParseError
121+
122+
Raised when the shape string has invalid syntax (unbalanced parentheses, etc.).
123+
124+
| Attribute | Type | Description |
125+
|---|---|---|
126+
| `shape` | `str` | The invalid shape string |
127+
| `position` | `int \| None` | Character position where parsing failed |
128+
129+
### TypeGenerationError
130+
131+
Raised when the SDK fails to generate a dynamic TypedDict for a shaped response.
132+
133+
### ModelInstantiationError
134+
135+
Raised when the SDK fails to create a model instance from API data.
136+
137+
| Attribute | Type | Description |
138+
|---|---|---|
139+
| `field_name` | `str \| None` | Field that caused the failure |
140+
| `expected_type` | `type \| None` | Expected Python type |
141+
| `actual_value` | `Any` | Value that couldn't be coerced |
142+
143+
## Catching Everything
144+
145+
To handle any SDK-raised error in one place, catch `TangoAPIError` and `ShapeError` (or just `Exception` at the outermost boundary):
146+
147+
```python
148+
from tango.exceptions import TangoAPIError, ShapeError
149+
150+
try:
151+
resp = client.list_contracts(shape="key,piid", limit=10)
152+
except TangoAPIError as e:
153+
# HTTP-layer problems (auth, rate limit, validation, etc.)
154+
print(f"API error {e.status_code}: {e.message}")
155+
except ShapeError as e:
156+
# Shape-string or model-construction problems
157+
print(f"Shape error: {e}")
158+
```

docs/PAGINATION.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Pagination
2+
3+
The SDK uses two pagination strategies depending on the endpoint.
4+
5+
For per-method pagination parameters, see [`API_REFERENCE.md`](API_REFERENCE.md). This guide is the conceptual overview and iteration patterns.
6+
7+
## Page-Based Pagination
8+
9+
Most endpoints use traditional page-based pagination with `page` and `limit` parameters.
10+
11+
```python
12+
from tango import TangoClient
13+
14+
client = TangoClient()
15+
16+
# First page
17+
resp = client.list_entities(search="Booz Allen", limit=25)
18+
print(f"Total: {resp.count}")
19+
print(f"This page: {len(resp.results)}")
20+
print(f"Next: {resp.next}")
21+
22+
# Next page
23+
resp2 = client.list_entities(search="Booz Allen", limit=25, page=2)
24+
```
25+
26+
### Iterating All Pages
27+
28+
```python
29+
page = 1
30+
all_results = []
31+
32+
while True:
33+
resp = client.list_entities(search="Booz Allen", limit=100, page=page)
34+
all_results.extend(resp.results)
35+
if not resp.next:
36+
break
37+
page += 1
38+
39+
print(f"Fetched {len(all_results)} of {resp.count} entities")
40+
```
41+
42+
**Endpoints using page-based pagination:** entities, forecasts, opportunities, notices, grants, protests, subawards, vehicles, vehicle awardees, organizations, GSA eLibrary contracts, IT Dashboard investments, agencies, offices, business types, NAICS, webhook subscriptions, webhook endpoints.
43+
44+
## Cursor-Based Pagination
45+
46+
High-volume award endpoints use cursor-based (keyset) pagination for better performance on large datasets. Instead of a page number, you pass a `cursor` token from the previous response.
47+
48+
```python
49+
from urllib.parse import parse_qs, urlparse
50+
51+
from tango import TangoClient
52+
53+
client = TangoClient()
54+
55+
# First page
56+
resp = client.list_contracts(limit=25, sort="award_date", order="desc")
57+
print(f"Total: {resp.count}")
58+
59+
# Get cursor from the next URL
60+
if resp.next:
61+
qs = parse_qs(urlparse(resp.next).query)
62+
cursor = qs.get("cursor", [None])[0]
63+
64+
# Fetch next page
65+
resp2 = client.list_contracts(
66+
limit=25,
67+
cursor=cursor,
68+
sort="award_date",
69+
order="desc",
70+
)
71+
```
72+
73+
### Iterating All Pages
74+
75+
```python
76+
from urllib.parse import parse_qs, urlparse
77+
78+
all_results = []
79+
cursor = None
80+
81+
while True:
82+
resp = client.list_contracts(
83+
keyword="cloud",
84+
limit=100,
85+
cursor=cursor,
86+
sort="award_date",
87+
order="desc",
88+
)
89+
all_results.extend(resp.results)
90+
91+
if not resp.next:
92+
break
93+
qs = parse_qs(urlparse(resp.next).query)
94+
cursor = qs.get("cursor", [None])[0]
95+
96+
print(f"Fetched {len(all_results)} of {resp.count} contracts")
97+
```
98+
99+
**Endpoints using cursor-based pagination:** contracts, IDVs, IDV awards, IDV child IDVs, IDV transactions, OTAs, OTIDVs.
100+
101+
## PaginatedResponse
102+
103+
All list methods return a `PaginatedResponse` object:
104+
105+
| Field | Type | Description |
106+
|---|---|---|
107+
| `count` | `int` | Total number of results available |
108+
| `next` | `str \| None` | Full URL for the next page, or `None` |
109+
| `previous` | `str \| None` | Full URL for the previous page, or `None` |
110+
| `results` | `list[T]` | List of results for this page |
111+
| `cursor` | `str \| None` | Cursor token (cursor-based endpoints only) |
112+
| `page_metadata` | `dict \| None` | Optional additional page metadata |

0 commit comments

Comments
 (0)