diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dee1c5..2cd8c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`PaginatedResponse.count_type`**: `"exact"` or `"approximate"`, read from the API's `X-Results-CountType` header on every list method (`None` when the API doesn't send it). Past 1,000 matches the contract, IDV, opportunity and notice lists may return a query-planner estimate as `count`, which can be well off; this is how a caller tells the two apart. It is taken from each response itself, so concurrent calls on one client can't see each other's value the way `last_response_headers` can. + ## [1.13.0] - 2026-10-02 ### Added diff --git a/tango/client.py b/tango/client.py index 6fd1ee6..d205b52 100644 --- a/tango/client.py +++ b/tango/client.py @@ -71,6 +71,9 @@ build_parser_registry_from_client, ) +# Where `_request` stores a list response's `X-Results-CountType` header so each `PaginatedResponse` reads its own, not the shared `last_response_headers`. +_COUNT_TYPE_KEY = "_tango_count_type" + class TangoClient: """Tango API Client""" @@ -180,7 +183,11 @@ def _request( self._raise_for_status(response) - return response.json() if response.content else {} + data = response.json() if response.content else {} + count_type = response.headers.get("X-Results-CountType") + if isinstance(count_type, str) and isinstance(data, dict) and "count" in data: + data[_COUNT_TYPE_KEY] = count_type.lower() + return data except httpx.HTTPError as e: raise TangoAPIError(f"Request failed: {str(e)}") from e @@ -495,6 +502,7 @@ def list_agencies( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=[ ag @@ -530,6 +538,7 @@ def list_offices( return PaginatedResponse( count=data.get("count", 0), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=data.get("results", []), meta=data.get("meta"), @@ -583,6 +592,7 @@ def list_organizations( return PaginatedResponse( count=data.get("count", 0), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -795,6 +805,7 @@ def list_contracts( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, cursor=data.get("cursor"), @@ -857,6 +868,7 @@ def get_contract_subawards( return PaginatedResponse( count=int(data.get("count") or len(results)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, cursor=data.get("cursor"), @@ -880,6 +892,7 @@ def get_contract_transactions( return PaginatedResponse( count=int(data.get("count") or len(data.get("results") or [])), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=data.get("results") or [], cursor=data.get("cursor"), @@ -984,6 +997,7 @@ def list_idvs( return PaginatedResponse( count=int(data.get("count") or len(results)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, cursor=data.get("cursor"), @@ -1087,6 +1101,7 @@ def list_idv_awards( return PaginatedResponse( count=int(data.get("count") or len(results)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, cursor=data.get("cursor"), @@ -1133,6 +1148,7 @@ def list_idv_child_idvs( return PaginatedResponse( count=int(data.get("count") or len(results)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, cursor=data.get("cursor"), @@ -1151,6 +1167,7 @@ def list_idv_transactions( return PaginatedResponse( count=int(data.get("count") or len(data.get("results") or [])), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=data.get("results") or [], cursor=data.get("cursor"), @@ -1234,6 +1251,7 @@ def list_otas( return PaginatedResponse( count=int(data.get("count") or len(results)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, cursor=data.get("cursor"), @@ -1340,6 +1358,7 @@ def list_otidvs( return PaginatedResponse( count=int(data.get("count") or len(results)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, cursor=data.get("cursor"), @@ -1417,6 +1436,7 @@ def list_otidv_awards( return PaginatedResponse( count=int(data.get("count") or len(results)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, cursor=data.get("cursor"), @@ -1474,6 +1494,7 @@ def list_subawards( return PaginatedResponse( count=data.get("count", 0), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -1559,6 +1580,7 @@ def list_gsa_elibrary_contracts( return PaginatedResponse( count=data.get("count", 0), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -1671,6 +1693,7 @@ def list_itdashboard_investments( return PaginatedResponse( count=data.get("count", 0), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -1841,6 +1864,7 @@ def list_vehicles( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -1923,6 +1947,7 @@ def list_vehicle_awardees( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -1972,6 +1997,7 @@ def list_vehicle_orders( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -2007,6 +2033,7 @@ def list_business_types( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -2059,6 +2086,7 @@ def list_naics( return PaginatedResponse( count=data.get("count", 0), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=data.get("results", []), meta=data.get("meta"), @@ -2146,6 +2174,7 @@ def list_entities( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -2203,6 +2232,7 @@ def get_entity_budget_flows( return PaginatedResponse( count=int(data.get("count", 0)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=list(data.get("results") or []), meta=data.get("meta"), @@ -2296,6 +2326,7 @@ def list_forecasts( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -2427,6 +2458,7 @@ def list_opportunities( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -2542,6 +2574,7 @@ def list_notices( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -2664,6 +2697,7 @@ def list_protests( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -2802,6 +2836,7 @@ def list_contract_appeals( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -2940,6 +2975,7 @@ def list_federal_register_documents( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -3080,6 +3116,7 @@ def list_ebuy_requests( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -3288,6 +3325,7 @@ def list_dibbs_rfqs( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -3408,6 +3446,7 @@ def list_dibbs_rfps( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -3546,6 +3585,7 @@ def list_dibbs_awards( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -3695,6 +3735,7 @@ def list_exclusions( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -3826,6 +3867,7 @@ def list_sbir_topics( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -3947,6 +3989,7 @@ def list_sbir_solicitations( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -4155,6 +4198,7 @@ def list_sled_opportunities( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -4267,6 +4311,7 @@ def list_sled_opportunity_revisions( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -4391,6 +4436,7 @@ def list_sled_forecasts( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -4792,6 +4838,7 @@ def list_budget_accounts( return PaginatedResponse( count=data.get("count", 0), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -4844,6 +4891,7 @@ def get_budget_account_quarters( return PaginatedResponse( count=int(data.get("count") or len(data.get("results") or [])), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=data.get("results") or [], meta=data.get("meta"), @@ -4872,6 +4920,7 @@ def get_budget_account_recipients( return PaginatedResponse( count=int(data.get("count") or len(data.get("results") or [])), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=data.get("results") or [], meta=data.get("meta"), @@ -4966,6 +5015,7 @@ def list_grants( return PaginatedResponse( count=data["count"], next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -5059,6 +5109,7 @@ def list_webhook_endpoints( return PaginatedResponse( count=int(data.get("count", len(results))), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -5244,6 +5295,7 @@ def list_webhook_alerts( return PaginatedResponse( count=int(data.get("count", len(results))), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -5462,6 +5514,7 @@ def list_departments(self, page: int = 1, limit: int = 25) -> PaginatedResponse[ return PaginatedResponse( count=int(data.get("count", 0)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=list(data.get("results") or []), meta=data.get("meta"), @@ -5500,6 +5553,7 @@ def list_psc( return PaginatedResponse( count=int(data.get("count", 0)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=list(data.get("results") or []), meta=data.get("meta"), @@ -5601,6 +5655,7 @@ def list_assistance_listings( return PaginatedResponse( count=int(data.get("count", 0)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=list(data.get("results") or []), meta=data.get("meta"), @@ -5648,6 +5703,7 @@ def list_mas_sins( return PaginatedResponse( count=int(data.get("count", 0)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=list(data.get("results") or []), meta=data.get("meta"), @@ -5716,6 +5772,7 @@ def _entity_subresource_contracts( return PaginatedResponse( count=int(data.get("count") or len(results)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, cursor=data.get("cursor"), @@ -5894,6 +5951,7 @@ def list_entity_subawards( return PaginatedResponse( count=int(data.get("count", 0)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, meta=data.get("meta"), @@ -5925,6 +5983,7 @@ def list_entity_lcats( return PaginatedResponse( count=int(data.get("count", 0)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=list(data.get("results") or []), meta=data.get("meta"), @@ -5966,6 +6025,7 @@ def list_idv_lcats( return PaginatedResponse( count=int(data.get("count", 0)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=list(data.get("results") or []), meta=data.get("meta"), @@ -6022,6 +6082,7 @@ def _agency_contracts( return PaginatedResponse( count=int(data.get("count") or len(results)), next=data.get("next"), + count_type=data.get(_COUNT_TYPE_KEY), previous=data.get("previous"), results=results, cursor=data.get("cursor"), diff --git a/tango/models.py b/tango/models.py index be1d39b..f573999 100644 --- a/tango/models.py +++ b/tango/models.py @@ -1462,6 +1462,8 @@ class PaginatedResponse[T]: page_metadata: Always ``None`` — the API has never emitted a ``page_metadata`` key. Retained so existing attribute access keeps working; use ``meta`` instead. + count_type: ``"exact"`` or ``"approximate"``, from the API's ``X-Results-CountType`` header; ``None`` when the endpoint doesn't send it. + The contract, IDV, opportunity and notice lists can switch to a query-planner estimate past 1,000 matches, which can be well off. Examples: >>> from tango import TangoClient, ShapeConfig @@ -1482,6 +1484,7 @@ class PaginatedResponse[T]: cursor: str | None = None meta: dict[str, Any] | None = None page_metadata: dict[str, Any] | None = None + count_type: str | None = None @property def agency_warnings(self) -> list[str]: diff --git a/tests/test_client.py b/tests/test_client.py index b3f7e76..b11ab49 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,6 +4,7 @@ from decimal import Decimal from unittest.mock import Mock, patch +import httpx import pytest from tango import ( @@ -299,6 +300,46 @@ def test_idv_list_methods_return_cursor(self, mock_request, method, args): assert page.cursor == "next-page-token" + @pytest.mark.parametrize( + ("headers", "expected"), + [ + ({"X-Results-CountType": "approximate"}, "approximate"), + ({"X-Results-CountType": "Exact"}, "exact"), + ({}, None), + ], + ) + @patch("tango.client.httpx.Client.request") + def test_list_reports_the_count_type_header(self, mock_request, headers, expected): + mock_response = Mock() + mock_response.is_success = True + mock_response.headers = httpx.Headers(headers) + mock_response.json.return_value = { + "count": 2914, + "next": None, + "previous": None, + "results": [{"key": "IDV-1"}], + } + mock_response.content = b'{"count": 2914}' + mock_request.return_value = mock_response + + page = TangoClient(api_key="test-key").list_idvs(limit=1) + + assert page.count_type == expected + assert all("_tango_count_type" not in r for r in page.results) + + @patch("tango.client.httpx.Client.request") + def test_detail_response_never_carries_the_count_type_key(self, mock_request): + mock_response = Mock() + mock_response.is_success = True + mock_response.headers = httpx.Headers({"X-Results-CountType": "exact"}) + mock_response.json.return_value = {"key": "IDV-1", "piid": "P1"} + mock_response.content = b'{"key": "IDV-1"}' + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + + assert "_tango_count_type" not in client._get("/api/idvs/IDV-1/") + @patch("tango.client.httpx.Client.request") def test_list_otidvs_with_default_shape(self, mock_request): """Test list_otidvs uses default minimal shape"""