diff --git a/CHANGELOG.md b/CHANGELOG.md index f00fe73..24be621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `PaginatedResponse.meta` now carries the response-level `meta` block the API returns, with three accessors over it: `unresolved_agency_tokens` (agency tokens that matched nothing, per filter), `resolved_agencies` (the organization each token *did* match), and `agency_warnings` (the API's human-readable notes). Agency values resolve fuzzily, so a token can match an organization the caller did not intend and quietly scope the query to that subtree — and a token that resolves to nothing was previously dropped with no signal at all. Both cases were indistinguishable from "no such records exist". The API now reports both; until now the SDK read the envelope key-by-key and dropped `meta` on the floor, so SDK users were the one group that could not see it. `resolved_agencies` is the accessor that matters for the wrong-organization case: nothing is dropped there, so an unresolved-token check cannot detect it. All accessors return empty rather than raising when `meta` is absent (most responses) or malformed. +- A fully-unresolvable agency filter now raises `TangoValidationError` naming the offending value instead of returning an empty page. This needed no SDK change — the existing 400 handler already reads the API's `error` key — but it is new behavior for callers of `list_subawards()`, `list_opportunities()`, `list_notices()`, and `list_vehicles()`, which previously returned an empty result set. Contracts, IDVs, OTAs, and OTIDVs already behaved this way. + +### Notes +- `PaginatedResponse.page_metadata` is documented as always `None`: the API has never emitted a `page_metadata` key, so the field has only ever read a value that does not exist. It is retained so existing attribute access keeps working. Use `meta`. + ## [1.4.0] - 2026-07-20 ### Added diff --git a/README.md b/README.md index 969add1..7ccd97d 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,37 @@ contracts = client.list_contracts( - `expiring_gte`, `expiring_lte` - Contract expiration date range **Party Filters:** -- `awarding_agency`, `funding_agency` - Agency codes +- `awarding_agency`, `funding_agency` - Agency codes, names, abbreviations, or organization UUIDs. Multi-value OR via `|`. - `recipient_name`, `recipient_uei` - Vendor/recipient filters +### Checking how agency filters resolved + +Agency values are resolved fuzzily, so a token can match an organization you did not +intend — which silently scopes the query to that organization's subtree. A short result +set is then indistinguishable from "no such records exist". Responses expose what +actually happened: + +```python +response = client.list_contracts(awarding_agency="HUD|HUDD") + +# Tokens that matched nothing and were ignored. +if response.unresolved_agency_tokens: + raise SystemExit(f"dropped: {response.unresolved_agency_tokens}") + # {'awarding_agency': ['HUDD']} + +# What the tokens that DID match resolved to — the only way to catch a +# plausible-but-wrong match, where nothing was dropped at all. +for org in response.resolved_agencies.get("awarding_agency", []): + print(org["name"], org["cgac"]) + # Department of Housing and Urban Development 086 + +for warning in response.agency_warnings: + print(warning) +``` + +If *every* token for a filter fails to resolve, the API returns `400` and the SDK raises +`TangoValidationError` naming the offending value, rather than an empty page. + **Classification:** - `naics_code`, `psc_code` - Industry/product codes - `set_aside_type` - Set-aside type diff --git a/tango/client.py b/tango/client.py index a159e22..29d9f1b 100644 --- a/tango/client.py +++ b/tango/client.py @@ -491,6 +491,7 @@ def list_agencies( for ag in (self._parse_agency(agency) for agency in data["results"]) if ag is not None ], + meta=data.get("meta"), ) def get_agency(self, code: str) -> Agency: @@ -521,6 +522,7 @@ def list_offices( next=data.get("next"), previous=data.get("previous"), results=data.get("results", []), + meta=data.get("meta"), ) def get_office(self, code: str) -> dict[str, Any]: @@ -573,6 +575,7 @@ def list_organizations( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_organization( @@ -785,6 +788,7 @@ def list_contracts( previous=data.get("previous"), results=results, cursor=data.get("cursor"), + meta=data.get("meta"), ) def get_contract( @@ -846,6 +850,7 @@ def get_contract_subawards( previous=data.get("previous"), results=results, cursor=data.get("cursor"), + meta=data.get("meta"), ) def get_contract_transactions( @@ -868,6 +873,7 @@ def get_contract_transactions( previous=data.get("previous"), results=data.get("results") or [], cursor=data.get("cursor"), + meta=data.get("meta"), ) # ============================================================================ @@ -971,6 +977,7 @@ def list_idvs( previous=data.get("previous"), results=results, page_metadata=data.get("page_metadata"), + meta=data.get("meta"), ) def get_idv( @@ -1072,6 +1079,7 @@ def list_idv_awards( previous=data.get("previous"), results=results, page_metadata=data.get("page_metadata"), + meta=data.get("meta"), ) def list_idv_child_idvs( @@ -1116,6 +1124,7 @@ def list_idv_child_idvs( previous=data.get("previous"), results=results, page_metadata=data.get("page_metadata"), + meta=data.get("meta"), ) def list_idv_transactions( @@ -1132,6 +1141,7 @@ def list_idv_transactions( previous=data.get("previous"), results=data.get("results") or [], page_metadata=data.get("page_metadata"), + meta=data.get("meta"), ) def list_otas( @@ -1214,6 +1224,7 @@ def list_otas( results=results, cursor=data.get("cursor"), page_metadata=data.get("page_metadata"), + meta=data.get("meta"), ) def get_ota( @@ -1319,6 +1330,7 @@ def list_otidvs( results=results, cursor=data.get("cursor"), page_metadata=data.get("page_metadata"), + meta=data.get("meta"), ) def get_otidv( @@ -1395,6 +1407,7 @@ def list_otidv_awards( results=results, cursor=data.get("cursor"), page_metadata=data.get("page_metadata"), + meta=data.get("meta"), ) def list_subawards( @@ -1449,6 +1462,7 @@ def list_subawards( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_subaward( @@ -1533,6 +1547,7 @@ def list_gsa_elibrary_contracts( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_gsa_elibrary_contract( @@ -1639,6 +1654,7 @@ def list_itdashboard_investments( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_itdashboard_investment( @@ -1808,6 +1824,7 @@ def list_vehicles( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_vehicle( @@ -1889,6 +1906,7 @@ def list_vehicle_awardees( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def list_vehicle_orders( @@ -1937,6 +1955,7 @@ def list_vehicle_orders( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) # Business Types endpoints @@ -1971,6 +1990,7 @@ def list_business_types( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def list_naics( @@ -2022,6 +2042,7 @@ def list_naics( next=data.get("next"), previous=data.get("previous"), results=data.get("results", []), + meta=data.get("meta"), ) # Entity endpoints @@ -2108,6 +2129,7 @@ def list_entities( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_entity( @@ -2164,6 +2186,7 @@ def get_entity_budget_flows( next=data.get("next"), previous=data.get("previous"), results=list(data.get("results") or []), + meta=data.get("meta"), ) # Forecast endpoints @@ -2256,6 +2279,7 @@ def list_forecasts( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_forecast( @@ -2379,6 +2403,7 @@ def list_opportunities( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_opportunity( @@ -2493,6 +2518,7 @@ def list_notices( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_notice( @@ -2614,6 +2640,7 @@ def list_protests( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_protest( @@ -2750,6 +2777,7 @@ def list_dibbs_rfqs( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_dibbs_rfq( @@ -2869,6 +2897,7 @@ def list_dibbs_rfps( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_dibbs_rfp( @@ -3006,6 +3035,7 @@ def list_dibbs_awards( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_dibbs_award( @@ -3151,6 +3181,7 @@ def list_exclusions( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_exclusion( @@ -3278,6 +3309,7 @@ def list_sbir_topics( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_sbir_topic( @@ -3398,6 +3430,7 @@ def list_sbir_solicitations( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_sbir_solicitation( @@ -3772,6 +3805,7 @@ def list_budget_accounts( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_budget_account( @@ -3823,6 +3857,7 @@ def get_budget_account_quarters( next=data.get("next"), previous=data.get("previous"), results=data.get("results") or [], + meta=data.get("meta"), ) def get_budget_account_recipients( @@ -3850,6 +3885,7 @@ def get_budget_account_recipients( next=data.get("next"), previous=data.get("previous"), results=data.get("results") or [], + meta=data.get("meta"), ) # Grant endpoints @@ -3943,6 +3979,7 @@ def list_grants( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_grant( @@ -4035,6 +4072,7 @@ def list_webhook_endpoints( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def create_webhook_endpoint( @@ -4219,6 +4257,7 @@ def list_webhook_alerts( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def get_webhook_alert(self, alert_id: str) -> WebhookAlert: @@ -4433,6 +4472,7 @@ def list_departments(self, page: int = 1, limit: int = 25) -> PaginatedResponse[ next=data.get("next"), previous=data.get("previous"), results=list(data.get("results") or []), + meta=data.get("meta"), ) def get_department(self, code: str) -> dict[str, Any]: @@ -4470,6 +4510,7 @@ def list_psc( next=data.get("next"), previous=data.get("previous"), results=list(data.get("results") or []), + meta=data.get("meta"), ) def get_psc( @@ -4570,6 +4611,7 @@ def list_assistance_listings( next=data.get("next"), previous=data.get("previous"), results=list(data.get("results") or []), + meta=data.get("meta"), ) def get_assistance_listing( @@ -4616,6 +4658,7 @@ def list_mas_sins( next=data.get("next"), previous=data.get("previous"), results=list(data.get("results") or []), + meta=data.get("meta"), ) def get_mas_sin( @@ -4685,6 +4728,7 @@ def _entity_subresource_contracts( results=results, cursor=data.get("cursor"), page_metadata=data.get("page_metadata"), + meta=data.get("meta"), ) def list_entity_contracts( @@ -4860,6 +4904,7 @@ def list_entity_subawards( next=data.get("next"), previous=data.get("previous"), results=results, + meta=data.get("meta"), ) def list_entity_lcats( @@ -4890,6 +4935,7 @@ def list_entity_lcats( next=data.get("next"), previous=data.get("previous"), results=list(data.get("results") or []), + meta=data.get("meta"), ) def get_entity_metrics(self, uei: str, months: int, period_grouping: str) -> dict[str, Any]: @@ -4930,6 +4976,7 @@ def list_idv_lcats( next=data.get("next"), previous=data.get("previous"), results=list(data.get("results") or []), + meta=data.get("meta"), ) # ============================================================================ @@ -4987,6 +5034,7 @@ def _agency_contracts( results=results, cursor=data.get("cursor"), page_metadata=data.get("page_metadata"), + meta=data.get("meta"), ) def list_agency_awarding_contracts( diff --git a/tango/models.py b/tango/models.py index 181fcb8..ff40e84 100644 --- a/tango/models.py +++ b/tango/models.py @@ -1044,7 +1044,15 @@ class PaginatedResponse[T]: previous: URL for the previous page of results (None if first page) results: List of result items (type depends on shape parameter) cursor: Cursor token for cursor-based pagination (None if not available) - page_metadata: Optional metadata about the current page + meta: Response-level metadata the API attached to this page, when present. + Currently carries agency-filter diagnostics: ``resolved_filters`` maps + each agency filter to the organizations its ``|``-separated tokens + resolved to (or ``None``), and ``warnings`` lists human-readable notes + about tokens that were dropped or matched loosely. See + :meth:`agency_warnings` and :meth:`unresolved_agency_tokens`. + page_metadata: Always ``None`` — the API has never emitted a + ``page_metadata`` key. Retained so existing attribute access keeps + working; use ``meta`` instead. Examples: >>> from tango import TangoClient, ShapeConfig @@ -1063,8 +1071,92 @@ class PaginatedResponse[T]: previous: str | None results: list[T] cursor: str | None = None + meta: dict[str, Any] | None = None page_metadata: dict[str, Any] | None = None + @property + def agency_warnings(self) -> list[str]: + """Warnings the API raised about agency filters on this request. + + Empty when every supplied agency token resolved cleanly. A non-empty list + means part of the filter did not apply, so a small or empty ``results`` is + not evidence that no such records exist. + + Examples: + >>> response = client.list_contracts(awarding_agency="GSA|NOTANAGENCY") + >>> for warning in response.agency_warnings: + ... print(warning) + Agency filter 'awarding_agency': 'NOTANAGENCY' did not match any organization and was ignored. + """ + if not self.meta: + return [] + warnings = self.meta.get("warnings") + return list(warnings) if isinstance(warnings, list) else [] + + @property + def unresolved_agency_tokens(self) -> dict[str, list[str]]: + """Agency tokens that matched no organization, keyed by filter name. + + Empty when everything resolved. Use this to fail loudly in a pipeline + rather than treating a silently-narrowed result set as an answer. + + Examples: + >>> response = client.list_contracts(awarding_agency="GSA|NOTANAGENCY") + >>> response.unresolved_agency_tokens + {'awarding_agency': ['NOTANAGENCY']} + """ + if not self.meta: + return {} + resolved = self.meta.get("resolved_filters") + if not isinstance(resolved, dict): + return {} + dropped: dict[str, list[str]] = {} + for filter_name, entries in resolved.items(): + if not isinstance(entries, list): + continue + tokens = [ + entry["token"] + for entry in entries + if isinstance(entry, dict) + and entry.get("resolved") is None + and entry.get("token") is not None + ] + if tokens: + dropped[filter_name] = tokens + return dropped + + @property + def resolved_agencies(self) -> dict[str, list[dict[str, Any]]]: + """What each agency token actually resolved to, keyed by filter name. + + Agency resolution is fuzzy, so a token can match an organization the caller + did not intend and scope the query to that subtree instead. Checking the + resolved ``name`` is the only way to catch that from the client side — an + unresolved-token check cannot, because nothing was dropped. + + Examples: + >>> response = client.list_contracts(awarding_agency="HUD") + >>> [org["name"] for org in response.resolved_agencies["awarding_agency"]] + ['Department of Housing and Urban Development'] + """ + if not self.meta: + return {} + resolved = self.meta.get("resolved_filters") + if not isinstance(resolved, dict): + return {} + matched: dict[str, list[dict[str, Any]]] = {} + for filter_name, entries in resolved.items(): + if not isinstance(entries, list): + continue + orgs = [ + entry["resolved"] + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("resolved"), dict) + ] + if orgs: + matched[filter_name] = orgs + return matched + class ShapeConfig: """Predefined response shape configurations used as defaults for API methods diff --git a/tests/test_client.py b/tests/test_client.py index 7645e9a..737c1b1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1990,3 +1990,140 @@ def test_list_psc_omits_unset_has_awards(self, mock_request): _stub_empty_page(mock_request) TangoClient(api_key="test-key").list_psc() assert "has_awards" not in mock_request.call_args[1]["params"] + + +class TestAgencyFilterDiagnostics: + """`meta` from the API's agency-filter diagnostics. + + Agency resolution is fuzzy, so a token can be dropped entirely or matched to an + organization the caller did not intend. Before the API exposed `meta`, both were + indistinguishable from "no such records exist" — and the SDK is the last place that + distinction can reach a user. + """ + + HUD = { + "key": "3f2a0000-0000-0000-0000-000000000001", + "name": "Department of Housing and Urban Development", + "level": 1, + "cgac": "086", + "fpds_code": None, + } + + def _mock(self, mock_request, meta=None): + payload = {"count": 0, "next": None, "previous": None, "results": []} + if meta is not None: + payload["meta"] = meta + response = Mock() + response.is_success = True + response.json.return_value = payload + response.content = b'{"count": 0}' + mock_request.return_value = response + return TangoClient(api_key="test-key") + + @patch("tango.client.httpx.Client.request") + def test_meta_is_carried_through_to_the_response(self, mock_request): + meta = { + "resolved_filters": { + "awarding_agency": [ + {"token": "HUD", "resolved": self.HUD}, + {"token": "HUDD", "resolved": None}, + ] + }, + "warnings": ["Agency filter 'awarding_agency': 'HUDD' did not match."], + } + client = self._mock(mock_request, meta) + + response = client.list_contracts(awarding_agency="HUD|HUDD") + + assert response.meta == meta + + @patch("tango.client.httpx.Client.request") + def test_dropped_tokens_are_reported_per_filter(self, mock_request): + client = self._mock( + mock_request, + { + "resolved_filters": { + "awarding_agency": [ + {"token": "HUD", "resolved": self.HUD}, + {"token": "HUDD", "resolved": None}, + ], + "funding_agency": [{"token": "NOPE", "resolved": None}], + } + }, + ) + + response = client.list_contracts(awarding_agency="HUD|HUDD") + + assert response.unresolved_agency_tokens == { + "awarding_agency": ["HUDD"], + "funding_agency": ["NOPE"], + } + + @patch("tango.client.httpx.Client.request") + def test_resolved_agencies_expose_the_matched_organization(self, mock_request): + """The wrong-subtree case: nothing was dropped, so only the resolved name + reveals that a token matched an organization the caller did not intend.""" + client = self._mock( + mock_request, + {"resolved_filters": {"awarding_agency": [{"token": "HUD", "resolved": self.HUD}]}}, + ) + + response = client.list_contracts(awarding_agency="HUD") + + assert response.unresolved_agency_tokens == {} + assert [org["name"] for org in response.resolved_agencies["awarding_agency"]] == [ + "Department of Housing and Urban Development" + ] + + @patch("tango.client.httpx.Client.request") + def test_warnings_are_surfaced(self, mock_request): + client = self._mock( + mock_request, {"warnings": ["Agency filter 'agency': 'X' did not match."]} + ) + + response = client.list_opportunities() + + assert response.agency_warnings == ["Agency filter 'agency': 'X' did not match."] + + @patch("tango.client.httpx.Client.request") + def test_absent_meta_yields_empty_accessors_not_errors(self, mock_request): + """Most responses carry no `meta` at all; the accessors must stay total.""" + client = self._mock(mock_request, meta=None) + + response = client.list_contracts() + + assert response.meta is None + assert response.agency_warnings == [] + assert response.unresolved_agency_tokens == {} + assert response.resolved_agencies == {} + + @patch("tango.client.httpx.Client.request") + def test_malformed_meta_does_not_raise(self, mock_request): + """`meta` is server-controlled; a shape change must not crash a caller's loop.""" + client = self._mock( + mock_request, + {"resolved_filters": "not-a-dict", "warnings": "not-a-list"}, + ) + + response = client.list_contracts() + + assert response.agency_warnings == [] + assert response.unresolved_agency_tokens == {} + assert response.resolved_agencies == {} + + @patch("tango.client.httpx.Client.request") + def test_full_miss_raises_with_the_offending_token(self, mock_request): + """A fully-unresolvable agency filter is a 400, not an empty page.""" + response = Mock() + response.is_success = False + response.status_code = 400 + response.content = b'{"error": "No agency found matching HUDD."}' + response.json.return_value = {"error": "No agency found matching 'HUDD'."} + mock_request.return_value = response + + client = TangoClient(api_key="test-key") + + with pytest.raises(TangoValidationError) as excinfo: + client.list_contracts(awarding_agency="HUDD") + + assert "HUDD" in str(excinfo.value)