Skip to content

Commit 132641f

Browse files
fix: address copilot review + windows webhook-receiver test (PR #25)
CI fix - `tests/test_webhooks_receiver.py::test_receiver_404s_on_unknown_path` was failing on windows-latest with `httpx.ReadError WinError 10053`. The receiver's do_POST returned a 404 before draining the request body on unknown paths. On Linux/macOS that's absorbed silently; on Windows the abrupt socket close abort the still-pending request. do_POST now drains the body first, then branches on path. Behavior changes - `_post` / `_patch` now raise `TangoValidationError` when both `json_data=` and `json=` are passed instead of silently preferring one. Single-kwarg usage is unchanged. - `create_webhook_endpoint(name=...)` is now **required** (was a `DeprecationWarning` in 0.7.0 — never publicly released). The server enforces unique(user, name), so omitting `name` returned a 400 anyway. Raising client-side gives a clear error and avoids the wasted round-trip. Updated `test_create_endpoint_without_name_warns` → `..._raises`. Docs/example fixes - `get_psc_metrics` / `get_naics_metrics` / `get_entity_metrics` docstrings — `period_grouping` values are "month"/"quarter"/"year" (path-segment values), not "monthly"/"quarterly". - `docs/API_REFERENCE.md#get_agency` — consistent `get_agency("GSA")` example + note that the parameter accepts CGAC / FPDS / short code / abbreviation / canonical name. - `README.md` Quick Start — `agency.name` instead of `agency['name']`; `get_agency()` returns an `Agency` dataclass. - `scripts/smoke_api_parity.py` — `list_business_types(limit=1)` is now wrapped in the `run(...)` helper so a failure there records FAIL instead of crashing the whole script. CHANGELOG updated to reflect the behavior changes. Closes the open Copilot comments on PR #25. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 06e7feb commit 132641f

8 files changed

Lines changed: 56 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
2424
### Added
2525
- `ordering` parameter on `list_forecasts`, `list_grants`, `list_subawards`, `list_gsa_elibrary_contracts`, and `list_opportunities`. Prefix with `-` for descending. Closes a parity gap with the API surface (these endpoints all accept `?ordering=` server-side).
26-
- `create_webhook_endpoint` accepts `name=` (keyword-only). Required by the Tango API since multi-endpoint support landed; omitting it now emits a `DeprecationWarning` and will become an error in a future major version.
26+
- `create_webhook_endpoint` accepts `name=` (keyword-only) and now **requires** it. The Tango API enforces unique `(user, name)` on endpoints; omitting `name` returns a 400 server-side, so the SDK raises `TangoValidationError` client-side instead of round-tripping. (0.7.0 — never publicly released — emitted a `DeprecationWarning` instead.)
2727
- `update_webhook_endpoint` accepts `name=` for renaming an endpoint.
2828
- Webhook alerts (filter subscriptions): `list_webhook_alerts`, `get_webhook_alert`, `create_webhook_alert`, `update_webhook_alert`, `delete_webhook_alert` — the canonical write surface over `/api/webhooks/alerts/`. New `WebhookAlert` dataclass exported from the top-level package.
2929
- `resolve(name, target_type, ...)` — POST `/api/resolve/` to rank entity / organization candidates from a free-text name. Returns `ResolveResult` with `ResolveCandidate` entries (both exported).
@@ -48,7 +48,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4848
- `ordering` kwarg from `list_notices` and `list_protests`. The notices and protests viewsets reject every `?ordering=` value at runtime (tango#2254); the kwarg silently sent unsupported values. Other five list methods retain `ordering`.
4949

5050
### Fixed
51-
- `TangoClient._post()` and `_patch()` now accept both `json_data=` (positional) and `json=` (keyword) for backward compatibility. Internal callers and docs examples that use `json=` no longer fail with `TypeError`.
51+
- `TangoClient._post()` and `_patch()` accept both `json_data=` (positional) and `json=` (keyword) for backward compatibility. Internal callers and docs examples that use `json=` no longer fail with `TypeError`. Passing **both** now raises `TangoValidationError` rather than silently preferring one — that ambiguity would hide caller bugs.
52+
- `get_psc_metrics` / `get_naics_metrics` / `get_entity_metrics` docstrings — `period_grouping` values are `"month"` / `"quarter"` / `"year"` (the path-segment values the API accepts), not `"monthly"` / `"quarterly"`.
53+
- `docs/API_REFERENCE.md#get_agency` — example uses `client.get_agency("GSA")` consistently and notes the parameter accepts CGAC / FPDS / short code / abbreviation / canonical name.
54+
- `README.md` Quick Start — `get_agency()` returns an `Agency` dataclass, so the example uses attribute access (`agency.name`) instead of `agency['name']` which would `TypeError`.
55+
- `scripts/smoke_api_parity.py``list_business_types(limit=1)` is now wrapped in the `run(...)` helper so a failure on that call records FAIL instead of aborting the smoke run.
5256
- `tango webhooks endpoints create` CLI now accepts and requires `--name` (passed through to `create_webhook_endpoint(name=...)`). Previously the option was absent, meaning the CLI could never set a custom endpoint name and every call would 400 server-side (the server enforces `unique(user, name)`).
5357
- `WebhookAlert.query_type` and `WebhookAlert.filters` tightened from `Optional` to non-optional (`str` and `dict[str, Any]` respectively). Legacy nullable rows were purged by the tango#2275 migration; the server model and serializer guarantee non-null values for all current data. `WebhookAlert.status` narrowed from `str` to `Literal["active", "paused"]` — the server serializer produces exactly those two values.
5458
- **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.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ print(f"Found {agencies.count} agencies")
3939

4040
# Get specific agency
4141
agency = client.get_agency("GSA")
42-
print(f"Agency: {agency['name']}")
42+
print(f"Agency: {agency.name}")
4343

4444
# Search contracts
4545
contracts = client.list_contracts(

docs/API_REFERENCE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,17 @@ for agency in agencies.results:
8686
Get a specific agency by code.
8787

8888
```python
89-
agency = client.get_agency(code="GSA")
89+
agency = client.get_agency("GSA")
9090
```
9191

9292
**Parameters:**
93-
- `code` (str): Agency code (e.g., "GSA", "DOD", "HHS")
93+
- `code` (str): Agency identifier. Accepts CGAC ("097"), FPDS code ("4712"), short code ("GSA"), abbreviation, or canonical name. See [Federal agency hierarchy](https://docs.makegov.com/api-reference/concepts/federal-agency-hierarchy/) for code semantics.
9494

9595
**Returns:** `Agency` dataclass with agency details
9696

9797
**Example:**
9898
```python
99-
gsa = client.get_agency("4712")
99+
gsa = client.get_agency("GSA")
100100
print(f"Name: {gsa.name}")
101101
print(f"Abbreviation: {gsa.abbreviation or 'N/A'}")
102102
if gsa.department:

scripts/smoke_api_parity.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ def run(label: str, fn: Callable[[], Any], *, skip_if_blank: str | None = None)
9696
lambda: client.get_naics_metrics(NAICS_CODE, 12, "month"),
9797
)
9898

99-
bt_pager = client.list_business_types(limit=1)
99+
bt_pager = run("list_business_types(limit=1)", lambda: client.list_business_types(limit=1))
100100
bt_code = ""
101-
if bt_pager.results:
101+
if bt_pager and bt_pager.results:
102102
bt_code = str(bt_pager.results[0].code or "")
103103
run(
104104
f"get_business_type({bt_code!r})",

tango/client.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,13 @@ def _post(
216216
217217
Accepts either ``json_data`` (positional) or ``json=`` (keyword) for
218218
backward compatibility with internal callers and docs examples.
219+
Passing both raises ``TangoValidationError`` rather than silently
220+
picking one — that ambiguity would hide caller bugs.
219221
"""
222+
if json_data is not None and json is not None:
223+
raise TangoValidationError(
224+
"_post: pass `json_data` or `json`, not both."
225+
)
220226
body = json_data if json_data is not None else json
221227
if body is None:
222228
body = {}
@@ -233,7 +239,13 @@ def _patch(
233239
234240
Accepts either ``json_data`` (positional) or ``json=`` (keyword) for
235241
backward compatibility with internal callers and docs examples.
242+
Passing both raises ``TangoValidationError`` rather than silently
243+
picking one — that ambiguity would hide caller bugs.
236244
"""
245+
if json_data is not None and json is not None:
246+
raise TangoValidationError(
247+
"_patch: pass `json_data` or `json`, not both."
248+
)
237249
body = json_data if json_data is not None else json
238250
if body is None:
239251
body = {}
@@ -2502,18 +2514,18 @@ def create_webhook_endpoint(
25022514
if not callback_url:
25032515
raise TangoValidationError("Webhook callback_url is required")
25042516
if name is None:
2505-
warnings.warn(
2506-
"create_webhook_endpoint() called without name=; the Tango "
2507-
"API requires `name` and this call will fail server-side. "
2508-
"Pass name='your-endpoint-name'. This will become a required "
2509-
"argument in a future major version.",
2510-
DeprecationWarning,
2511-
stacklevel=2,
2517+
# `name` was a deprecation warning in 0.7.0 (never publicly
2518+
# released). 1.0.0 makes it a hard error since the server
2519+
# enforces unique(user, name) and the warn-then-400 path
2520+
# was a worse DX than just raising client-side.
2521+
raise TangoValidationError(
2522+
"create_webhook_endpoint(): `name=` is required. "
2523+
"The Tango API enforces unique(user, name) on endpoints; "
2524+
"omitting it would return a 400 server-side. "
2525+
"Pass name='your-endpoint-name'."
25122526
)
25132527

2514-
body: dict[str, Any] = {"callback_url": callback_url, "is_active": is_active}
2515-
if name is not None:
2516-
body["name"] = name
2528+
body: dict[str, Any] = {"callback_url": callback_url, "is_active": is_active, "name": name}
25172529

25182530
data = self._post("/api/webhooks/endpoints/", body)
25192531
return WebhookEndpoint(
@@ -2901,7 +2913,8 @@ def get_psc_metrics(self, code: str, months: int, period_grouping: str) -> dict[
29012913
Args:
29022914
code: PSC code.
29032915
months: Window size in months (e.g. 6, 12, 24, 36).
2904-
period_grouping: ``monthly``, ``quarterly``, etc.
2916+
period_grouping: ``"month"``, ``"quarter"``, or ``"year"`` (the
2917+
values the API accepts on the path segment).
29052918
"""
29062919
if not code:
29072920
raise TangoValidationError("PSC code is required")

tango/webhooks/receiver.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,18 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
113113
return
114114

115115
def do_POST(self) -> None: # noqa: N802 (stdlib API)
116+
# Always drain the request body before responding, even on
117+
# error paths. Windows surfaces an undrained body as
118+
# `WinError 10053` (connection aborted by the host) when
119+
# the server closes the socket mid-request; Linux/macOS
120+
# absorb it silently. Reading first keeps the response
121+
# cycle clean across platforms.
122+
length = int(self.headers.get("Content-Length", "0") or 0)
123+
body = self.rfile.read(length) if length > 0 else b""
124+
116125
if self.path != receiver.path:
117126
self._write_json(404, {"ok": False, "error": "not_found"})
118127
return
119-
length = int(self.headers.get("Content-Length", "0") or 0)
120-
body = self.rfile.read(length) if length > 0 else b""
121128
signature = self.headers.get(SIGNATURE_HEADER)
122129
verified = bool(receiver.secret) and verify_signature(
123130
body, receiver.secret, signature

tests/test_api_parity.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -253,20 +253,17 @@ def test_create_endpoint_passes_name(self, mock_request: Mock) -> None:
253253
assert body["name"] == "primary"
254254
assert body["callback_url"] == "https://x/"
255255

256-
def test_create_endpoint_without_name_warns(self, mock_request: Mock) -> None:
257-
mock_request.return_value = _mock_response(
258-
{
259-
"id": "ep-1",
260-
"name": "",
261-
"callback_url": "https://x/",
262-
"is_active": True,
263-
"created_at": "2026-01-01",
264-
"updated_at": "2026-01-01",
265-
}
266-
)
267-
client = TangoClient(api_key="x", base_url="https://t.example")
268-
with pytest.warns(DeprecationWarning, match="name"):
256+
def test_create_endpoint_without_name_raises(self, mock_request: Mock) -> None:
257+
# 1.0.0 turned the 0.7.0 DeprecationWarning into a hard error: the
258+
# server enforces unique(user, name), so omitting name would 400
259+
# anyway. Raising client-side gives a better error message and
260+
# avoids the wasted round-trip.
261+
mock_request.return_value = _mock_response({})
262+
client = TangoClient(api_key="x", base_url="https://t.example")
263+
with pytest.raises(TangoValidationError, match="name"):
269264
client.create_webhook_endpoint("https://x/")
265+
# And the request never went out.
266+
mock_request.assert_not_called()
270267

271268
def test_update_endpoint_passes_name(self, mock_request: Mock) -> None:
272269
mock_request.return_value = _mock_response(

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)