From 8d0e91dc8b62c25e27fe50622dc27b1f60c9c7d8 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 9 Jul 2026 12:32:52 -0500 Subject: [PATCH] =?UTF-8?q?feat(protests):=20SBA=20OHA=20support=20?= =?UTF-8?q?=E2=80=94=20naics=5Fcode=20filter=20+=20five=20OHA=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tango API now serves SBA OHA (Office of Hearings and Appeals) decisions on /api/protests/ as a third source_system, alongside GAO and the Court of Federal Claims. This brings the SDK to parity. list_protests() gains a naics_code filter. It is sent verbatim, NOT remapped onto `naics` the way list_contracts() and list_opportunities() remap the same kwarg — the protests API param is literally naics_code. Protest, PROTEST_SCHEMA, and PROTEST_DOCKET_SCHEMA gain challenged_party, naics_code, size_standard, outcome_reason, and judge. All five are opt-in via shape= (null on gao/cofc rows) and ungated by tier, so they belong in the schemas but not in PROTESTS_MINIMAL. Also maps `protests` in the conformance checker's RESOURCE_TO_METHOD. It had no entry, so the resource fell through to "no SDK method implemented" and its filters were never validated against the contract — which is precisely how the naics_code gap survived. Verified the gate now bites: removing the kwarg produces "protests: `list_protests` missing runtime filters: naics_code". Refreshed the vendored contract via scripts/refresh_contract.py. That also picks up unrelated drift accumulated since it was last vendored (opportunities `active`, max_page_size, two filter-class renames) — it is a generated file and cherry-picking would leave it internally inconsistent. 307 unit tests pass; mypy clean. --- CHANGELOG.md | 3 + contracts/filter_shape_contract.json | 21 ++++-- scripts/check_filter_shape_conformance.py | 1 + tango/client.py | 10 ++- tango/models.py | 6 ++ tango/shapes/explicit_schemas.py | 16 +++++ tests/test_client.py | 79 +++++++++++++++++++++++ 7 files changed, 128 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88f51ad..e35eb49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **SBA OHA support on the protests endpoint.** The Tango API now serves SBA OHA (Office of Hearings and Appeals) decisions as a third `source_system`, alongside GAO and the Court of Federal Claims. `list_protests()` gains a `naics_code` filter, and `Protest` / `PROTEST_SCHEMA` / `PROTEST_DOCKET_SCHEMA` gain the five SBA OHA fields: `challenged_party`, `naics_code`, `size_standard`, `outcome_reason`, and `judge`. They are opt-in via `shape=` (null on GAO and CoFC rows, so they are absent from the default shape) and ungated by tier. Note that `naics_code` is sent to `/api/protests/` verbatim — unlike `list_contracts()` and `list_opportunities()`, which remap the same kwarg onto the API's `naics` param. +- `protests` is now mapped in the conformance checker's `RESOURCE_TO_METHOD`. It had no entry, so the resource fell through to "no SDK method implemented" and its filters were never checked against the contract at all — which is how the `naics_code` gap went unnoticed. With the mapping in place, a protests filter that exists in the API but not in `list_protests()` is now a hard conformance error. + - **Contract-first conformance system.** The canonical API filter/shape contract is now vendored at `contracts/filter_shape_contract.json` (refresh with the new `scripts/refresh_contract.py`), so the conformance check runs diff --git a/contracts/filter_shape_contract.json b/contracts/filter_shape_contract.json index 97d280a..2a08b0b 100644 --- a/contracts/filter_shape_contract.json +++ b/contracts/filter_shape_contract.json @@ -350,7 +350,7 @@ ], "pagination": { "class": "KeysetPagination", - "max_page_size": null + "max_page_size": 100 }, "shape": { "expands": { @@ -2039,7 +2039,7 @@ ], "pagination": { "class": "KeysetPagination", - "max_page_size": null + "max_page_size": 100 }, "shape": { "expands": { @@ -2860,7 +2860,7 @@ "type": "date" }, "search": { - "filter_class": "BaseSmartFilter", + "filter_class": "NoticeSearchFilter", "type": "string" }, "set_aside": { @@ -3063,7 +3063,7 @@ "type": "string" }, "place_of_performance": { - "filter_class": "BaseSmartFilter", + "filter_class": "OpportunityPlaceOfPerformanceFilter", "type": "string" }, "psc": { @@ -3111,6 +3111,7 @@ }, "swagger_has_key": true, "swagger_params": [ + "active", "agency", "first_notice_date_after", "first_notice_date_before", @@ -3543,7 +3544,7 @@ ], "pagination": { "class": "KeysetPagination", - "max_page_size": null + "max_page_size": 100 }, "shape": { "expands": { @@ -3891,7 +3892,7 @@ ], "pagination": { "class": "KeysetPagination", - "max_page_size": null + "max_page_size": 100 }, "shape": { "expands": { @@ -4094,6 +4095,7 @@ "decision_date_before", "filed_date_after", "filed_date_before", + "naics_code", "outcome", "protester", "search", @@ -4133,6 +4135,11 @@ "lookup": "lte", "type": "date" }, + "naics_code": { + "filter_class": "CharFilter", + "lookup": "iexact", + "type": "string" + }, "outcome": { "filter_class": "BaseSmartFilter", "type": "string" @@ -5474,6 +5481,7 @@ "last_date_to_order", "latest_award_date", "naics_code", + "name", "opportunity_id", "order_count", "organization", @@ -5900,6 +5908,7 @@ "metrics.top_recipient_share", "metrics.using_agency_count", "naics_code", + "name", "opportunity", "opportunity.active", "opportunity.agency", diff --git a/scripts/check_filter_shape_conformance.py b/scripts/check_filter_shape_conformance.py index 93be26e..66b09ab 100644 --- a/scripts/check_filter_shape_conformance.py +++ b/scripts/check_filter_shape_conformance.py @@ -65,6 +65,7 @@ "gsa_elibrary_contracts": "list_gsa_elibrary_contracts", "itdashboard": "list_itdashboard_investments", "budget_accounts": "list_budget_accounts", + "protests": "list_protests", # Resources not yet implemented in SDK "offices": None, } diff --git a/tango/client.py b/tango/client.py index a7f5720..820e171 100644 --- a/tango/client.py +++ b/tango/client.py @@ -2529,6 +2529,7 @@ def list_protests( agency: str | None = None, case_number: str | None = None, solicitation_number: str | None = None, + naics_code: str | None = None, protester: str | None = None, filed_date_after: str | None = None, filed_date_before: str | None = None, @@ -2552,12 +2553,16 @@ def list_protests( shape: Response shape string (defaults to minimal shape) flat: If True, flatten nested objects in shaped response flat_lists: If True, flatten arrays using indexed keys - source_system: Filter by source system (e.g. gao) - outcome: Filter by outcome (e.g. Denied, Dismissed, Withdrawn, Sustained) + source_system: Filter by source system (gao, cofc, sba_oha) + outcome: Filter by outcome. GAO/CoFC use Denied, Dismissed, + Withdrawn, Sustained; SBA OHA adds Granted, Remanded, + Reversed, Vacated case_type: Filter by case type agency: Filter by protested agency text case_number: Filter by case number (e.g. b-423274) solicitation_number: Filter by exact solicitation number + naics_code: Filter by the NAICS code at issue (e.g. 541512). + Populated only on SBA OHA size and NAICS appeals protester: Filter by protester name text filed_date_after: Filed date on or after filed_date_before: Filed date on or before @@ -2583,6 +2588,7 @@ def list_protests( ("agency", agency), ("case_number", case_number), ("solicitation_number", solicitation_number), + ("naics_code", naics_code), ("protester", protester), ("filed_date_after", filed_date_after), ("filed_date_before", filed_date_before), diff --git a/tango/models.py b/tango/models.py index a40fcb0..989c0b7 100644 --- a/tango/models.py +++ b/tango/models.py @@ -607,6 +607,12 @@ class Protest: docket_url: str | None = None decision_url: str | None = None digest: str | None = None + # SBA OHA only; null on gao/cofc rows. Opt-in via shape=, like digest. + challenged_party: str | None = None + naics_code: str | None = None + size_standard: str | None = None + outcome_reason: str | None = None + judge: str | None = None dockets: list[dict[str, Any]] | None = None diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index ae58128..8bf5d5e 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -727,6 +727,14 @@ "docket_url": FieldSchema(name="docket_url", type=str, is_optional=True, is_list=False), "decision_url": FieldSchema(name="decision_url", type=str, is_optional=True, is_list=False), "digest": FieldSchema(name="digest", type=str, is_optional=True, is_list=False), + # SBA OHA only; null on gao/cofc rows. Opt-in via shape=, like digest. + "challenged_party": FieldSchema( + name="challenged_party", type=str, is_optional=True, is_list=False + ), + "naics_code": FieldSchema(name="naics_code", type=str, is_optional=True, is_list=False), + "size_standard": FieldSchema(name="size_standard", type=str, is_optional=True, is_list=False), + "outcome_reason": FieldSchema(name="outcome_reason", type=str, is_optional=True, is_list=False), + "judge": FieldSchema(name="judge", type=str, is_optional=True, is_list=False), } PROTEST_SCHEMA: dict[str, FieldSchema] = { @@ -750,6 +758,14 @@ "docket_url": FieldSchema(name="docket_url", type=str, is_optional=True, is_list=False), "decision_url": FieldSchema(name="decision_url", type=str, is_optional=True, is_list=False), "digest": FieldSchema(name="digest", type=str, is_optional=True, is_list=False), + # SBA OHA only; null on gao/cofc rows. Opt-in via shape=, like digest. + "challenged_party": FieldSchema( + name="challenged_party", type=str, is_optional=True, is_list=False + ), + "naics_code": FieldSchema(name="naics_code", type=str, is_optional=True, is_list=False), + "size_standard": FieldSchema(name="size_standard", type=str, is_optional=True, is_list=False), + "outcome_reason": FieldSchema(name="outcome_reason", type=str, is_optional=True, is_list=False), + "judge": FieldSchema(name="judge", type=str, is_optional=True, is_list=False), "dockets": FieldSchema( name="dockets", type=dict, is_optional=True, is_list=True, nested_model="ProtestDocket" ), diff --git a/tests/test_client.py b/tests/test_client.py index 68645cd..30c4aa5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -574,6 +574,85 @@ def test_list_budget_accounts_no_filters_sends_only_pagination_and_shape(self, m assert leaked == [], f"unexpected filter keys sent: {leaked}" +class TestListProtestsNaicsCodeFilter: + """`naics_code` on protests is a literal query param, not the `naics` remap.""" + + @patch("tango.client.httpx.Client.request") + def test_naics_code_is_sent_verbatim(self, mock_request): + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + mock_response.content = b'{"count": 0}' + mock_request.return_value = mock_response + + TangoClient(api_key="test-key").list_protests(naics_code="541512") + + params = mock_request.call_args[1]["params"] + # Contracts and opportunities remap naics_code -> `naics`; protests does not. + assert params["naics_code"] == "541512" + assert "naics" not in params + + @patch("tango.client.httpx.Client.request") + def test_omitted_naics_code_sends_no_key(self, mock_request): + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + mock_response.content = b'{"count": 0}' + mock_request.return_value = mock_response + + TangoClient(api_key="test-key").list_protests(source_system="sba_oha") + + params = mock_request.call_args[1]["params"] + assert params["source_system"] == "sba_oha" + assert "naics_code" not in params + + +class TestProtestOhaShapeFields: + """The five SBA OHA fields round-trip through the shape parser.""" + + @patch("tango.client.httpx.Client.request") + def test_oha_fields_parse_off_a_shaped_response(self, mock_request): + shape = "case_id,challenged_party,naics_code,size_standard,outcome_reason,judge" + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "case_id": "0b6a1c9e-1f4d-4a2b-8c3d-5e6f7a8b9c0d", + "challenged_party": "Acme Federal LLC", + "naics_code": "541512", + "size_standard": "$34.0 million", + "outcome_reason": "untimely", + "judge": "Christopher Holleman", + } + ], + } + mock_response.content = b'{"count": 1}' + mock_request.return_value = mock_response + + result = TangoClient(api_key="test-key").list_protests(shape=shape) + + (protest,) = result.results + assert protest.challenged_party == "Acme Federal LLC" + assert protest.naics_code == "541512" + assert protest.size_standard == "$34.0 million" + assert protest.outcome_reason == "untimely" + assert protest.judge == "Christopher Holleman" + + class TestShapeConfig: """Test ShapeConfig class"""