From 034779763f529e95e6d611cfc8ed6460649fcf2b Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 3 Aug 2026 14:43:56 -0400 Subject: [PATCH] feat(models): surface the API's agency-filter diagnostics on PaginatedResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agency values resolve fuzzily. A token can match nothing and be dropped, or match an organization the caller never intended and quietly scope the query to that subtree. Both look identical to "no such records exist" from the client side. The API reports both in a response-level `meta` block, but the SDK built `PaginatedResponse` key-by-key — `count`, `next`, `previous`, `results` — so `meta` was read by nobody and discarded. SDK users were the one group that could not see the diagnostics at all. `PaginatedResponse.meta` now carries it, populated at all 48 construction sites, with three accessors over the raw dict: - `unresolved_agency_tokens` — tokens that matched nothing, keyed by filter, for failing loudly in a pipeline. - `resolved_agencies` — the organization each token *did* match. This is the one that catches the wrong-organization case: nothing is dropped there, so an unresolved-token check cannot detect it. Comparing the resolved `name` is the only client-side signal. - `agency_warnings` — the API's human-readable notes. All three return empty rather than raising when `meta` is absent (most responses) or malformed, since `meta` is server-controlled and a shape change must not break a caller's loop. Also documents `page_metadata` as always `None`: the API has never emitted a `page_metadata` key, so the field has only ever read something that does not exist. Retained so existing attribute access keeps working. No change was needed for the 400 path — a fully-unresolvable agency filter already raises `TangoValidationError` via the existing `error`-key handler — but it is new behavior for `list_subawards()`, `list_opportunities()`, `list_notices()` and `list_vehicles()`, which previously returned an empty page. Covered by a test so the contract is pinned. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 ++ README.md | 30 +++++++++- tango/client.py | 48 +++++++++++++++ tango/models.py | 94 ++++++++++++++++++++++++++++- tests/test_client.py | 137 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 312 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b47882b..cc08639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`relationships(type, source)` on entities.** Tango API 4.20.0 added two keys to each entry in the entity `relationships` expand: `type`, the stable relationship-type code (`prime_sub`, `parent_subsidiary`, `ultimate_parent`, `predecessor`), and `source`, where the tie came from (`sam`, `subawards`). Both are shape-selectable, so `shape="uei,relationships(type,source,uei)"` now resolves against the SDK's schema. Re-vendored the contract and regenerated the shape overlay; the coverage gate reports 0 gaps. +- `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. ### Changed - **The `relation` value vocabulary changed upstream — match on `type` instead.** In Tango API 4.20.0 the `relation` label stopped collapsing to `affiliate` for subcontracting and corporate-succession ties and now names the partner's role: `subcontractor` / `prime`, `predecessor` / `successor`, and `descendant` (rather than `child`) on the far side of an ultimate-parent tie. `affiliate` survives only as a fallback for a type the API doesn't recognize. This affects the large majority of relationship entries. No SDK code change is required — `relation` was and remains a `str`. But if you have application code branching on the string `affiliate`, switch it to `type`, which is stable and won't churn again. See the [entities data dictionary](https://docs.makegov.com/data-dictionary/entities/#relationships) for the full vocabulary table. +### 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)