From 8d561c02c0d958587613bce3669223ea315414de Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Sat, 24 Oct 2026 12:46:59 -0500 Subject: [PATCH 1/2] feat(lists): exact=True for real counts on opportunities, notices, contracts and IDVs (v1.20.0) Past 10,000 matches these endpoints report an estimated count that is only a lower bound, and the SDK had no way to send the API's ?exact=true opt-in. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++ docs/API_REFERENCE.md | 16 +++++++++ pyproject.toml | 2 +- scripts/check_filter_shape_conformance.py | 1 + tango/__init__.py | 2 +- tango/client.py | 20 ++++++++++++ tests/test_exact_count.py | 40 +++++++++++++++++++++++ uv.lock | 2 +- 8 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/test_exact_count.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d14cf13..9a8be21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.20.0] - 2026-10-24 + +### Added + +- **`exact=True` on `list_opportunities()`, `list_notices()`, `list_contracts()` and `list_idvs()`.** Past 10,000 matches these endpoints report an estimated `count` that is only a lower bound; `exact=True` sends `?exact=true` for a real count (correct on opportunities and notices from Tango API 5.11.1). Keyword-only and off by default, so existing calls send the same request. + ## [1.19.0] - 2026-10-21 ### Added diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 2ddeffa..3b3a289 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -265,6 +265,7 @@ contracts = client.list_contracts( - `shape` (str): Fields to return (see [Shaping Guide](SHAPES.md)) - `flat` (bool): Flatten nested objects to dot-notation keys - `flat_lists` (bool): Flatten arrays with indexed keys +- `exact` (bool, keyword-only): Ask for an exact `count` (see [Exact counts](#exact-counts)) **Filter Parameters:** @@ -664,6 +665,7 @@ Notes: - This endpoint uses **keyset pagination** (`cursor` + `limit`) rather than page numbers. - `commercial_item_acquisition_procedures` takes the same FPDS codes `A`–`E` as on `list_contracts()`, with `|` for OR. +- `exact=True` (keyword-only) asks for an exact `count` (see [Exact counts](#exact-counts)). ### get_idv() @@ -915,6 +917,7 @@ opportunities = client.list_opportunities( - `shape` (str): Fields to return - `flat` (bool): Flatten nested objects - `flat_lists` (bool): Flatten arrays with indexed keys +- `exact` (bool, keyword-only): Ask for an exact `count` (see [Exact counts](#exact-counts)). Requires Tango API 5.11.1 **Filter Parameters:** - `active` - Filter by active status (bool) @@ -1061,6 +1064,7 @@ notices = client.list_notices( - `shape` (str): Fields to return - `flat` (bool): Flatten nested objects - `flat_lists` (bool): Flatten arrays with indexed keys +- `exact` (bool, keyword-only): Ask for an exact `count` (see [Exact counts](#exact-counts)). Requires Tango API 5.11.1 **Filter Parameters:** - `active` - Filter by active status (bool) @@ -2575,6 +2579,18 @@ response.count # Total number of results response.next # URL to next page (or None) response.previous # URL to previous page (or None) response.results # List of result dictionaries +response.count_type # "exact" or "approximate" (the API's X-Results-CountType header) +``` + +#### Exact counts + +`list_opportunities()`, `list_notices()`, `list_contracts()` and `list_idvs()` report an estimated `count` (`count_type == "approximate"`) once a filtered result set passes 10,000 matches, and that estimate is only a lower bound. +Pass `exact=True` to get a real count instead; it costs the server a full `COUNT(*)`, so ask only when you need the total. +On opportunities and notices it needs Tango API 5.11.1; earlier releases returned the estimate under an `exact` label. + +```python +page = client.list_opportunities(agency="VA", active=False, limit=1, exact=True) +print(page.count, page.count_type) # count_type is "exact" ``` **Example:** diff --git a/pyproject.toml b/pyproject.toml index a696593..45d99f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tango-python" -version = "1.19.0" +version = "1.20.0" description = "Python SDK for the Tango API" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/check_filter_shape_conformance.py b/scripts/check_filter_shape_conformance.py index ca7b27f..a440414 100644 --- a/scripts/check_filter_shape_conformance.py +++ b/scripts/check_filter_shape_conformance.py @@ -114,6 +114,7 @@ # SearchFilters escape hatch; the rest are pagination/shaping controls. NON_FILTER_ARGS = { "cursor", + "exact", "limit", "page", "shape", diff --git a/tango/__init__.py b/tango/__init__.py index 07f9069..62cd274 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -62,7 +62,7 @@ ) from .webhooks.receiver import Delivery, WebhookReceiver -__version__ = "1.19.0" +__version__ = "1.20.0" __all__ = [ "TangoClient", "TangoAPIError", diff --git a/tango/client.py b/tango/client.py index 32c96b8..4f76875 100644 --- a/tango/client.py +++ b/tango/client.py @@ -690,6 +690,8 @@ def list_contracts( set_aside_type: str | None = None, sort: str | None = None, order: str | None = None, + *, + exact: bool = False, ) -> PaginatedResponse: """ List contracts with optional filtering @@ -733,6 +735,7 @@ def list_contracts( set_aside_type: Set-aside type (mapped to 'set_aside' API param) sort: Field to sort by (combined with 'order' to produce 'ordering') order: Sort order ('asc' or 'desc', default 'asc') + exact: If True, ask the API for an exact count. A filtered list past 10,000 matches otherwise reports an estimate (``count_type == "approximate"``) that is only a lower bound; an exact count costs the server a full COUNT. Examples: >>> contracts = client.list_contracts(limit=10) @@ -831,6 +834,8 @@ def list_contracts( api_key = api_param_mapping.get(key, key) params[api_key] = value + if exact: + params["exact"] = "true" data = self._get("/api/contracts/", params) # Always use dynamic parsing @@ -973,6 +978,8 @@ def list_idvs( set_aside: str | None = None, solicitation_identifier: str | None = None, uei: str | None = None, + *, + exact: bool = False, ) -> PaginatedResponse: """ List IDVs (indefinite delivery vehicles) with keyset pagination. @@ -981,6 +988,8 @@ def list_idvs( plus shaping via `shape`. `commercial_item_acquisition_procedures` takes an FPDS commercial item acquisition procedures code `A`–`E` (case-insensitive; `E` is DoD Section 803 Commercial Solutions Opening), pipe-separated for OR, e.g. `"A|E"`; an unknown code matches nothing. + + Pass `exact=True` for an exact `count`: a filtered list past 10,000 matches otherwise reports an estimate (`count_type == "approximate"`) that is only a lower bound, and an exact count costs the server a full COUNT. """ params: dict[str, Any] = {"limit": min(limit, 100)} if cursor: @@ -1027,6 +1036,8 @@ def list_idvs( if val is not None: params[key] = val + if exact: + params["exact"] = "true" data = self._get("/api/idvs/", params) raw_results = data.get("results") or [] @@ -2425,6 +2436,7 @@ def list_opportunities( *, awarded: bool | None = None, awardee_uei: str | None = None, + exact: bool = False, ) -> PaginatedResponse: """ List contract opportunities/solicitations @@ -2453,6 +2465,7 @@ def list_opportunities( search: Search query set_aside: Set-aside type filter solicitation_number: Solicitation number filter + exact: If True, ask the API for an exact count. A filtered list past 10,000 matches otherwise reports an estimate (``count_type == "approximate"``) that is only a lower bound; an exact count costs the server a full COUNT. Correct on opportunities and notices from Tango API 5.11.1 """ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} @@ -2488,6 +2501,8 @@ def list_opportunities( if val is not None: params[key] = val + if exact: + params["exact"] = "true" data = self._get("/api/opportunities/", params) # Always use dynamic parsing @@ -2550,6 +2565,8 @@ def list_notices( search: str | None = None, set_aside: str | None = None, solicitation_number: str | None = None, + *, + exact: bool = False, ) -> PaginatedResponse: """ List contract notices @@ -2575,6 +2592,7 @@ def list_notices( search: Search query set_aside: Set-aside type filter solicitation_number: Solicitation number filter + exact: If True, ask the API for an exact count. A filtered list past 10,000 matches otherwise reports an estimate (``count_type == "approximate"``) that is only a lower bound; an exact count costs the server a full COUNT. Correct on opportunities and notices from Tango API 5.11.1 """ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} @@ -2604,6 +2622,8 @@ def list_notices( if val is not None: params[key] = val + if exact: + params["exact"] = "true" data = self._get("/api/notices/", params) # Always use dynamic parsing diff --git a/tests/test_exact_count.py b/tests/test_exact_count.py new file mode 100644 index 0000000..8fdd5db --- /dev/null +++ b/tests/test_exact_count.py @@ -0,0 +1,40 @@ +"""Tests for the `exact` count opt-in on the list endpoints that report estimated counts""" + +import inspect +from unittest.mock import Mock, patch + +import pytest + +from tango import TangoClient + +METHODS = ["list_opportunities", "list_notices", "list_contracts", "list_idvs"] + + +def _mock(mock_request, count_type): + response = Mock() + response.is_success = True + response.json.return_value = {"count": 17677, "next": None, "previous": None, "results": []} + response.content = b"{}" + response.headers = {"X-Results-CountType": count_type} + mock_request.return_value = response + + +@pytest.mark.parametrize("method", METHODS) +class TestExactCount: + @patch("tango.client.httpx.Client.request") + def test_sends_exact_when_asked(self, mock_request, method): + _mock(mock_request, "exact") + page = getattr(TangoClient(api_key="k"), method)(exact=True) + assert mock_request.call_args.kwargs["params"]["exact"] == "true" + assert page.count == 17677 + + @patch("tango.client.httpx.Client.request") + def test_omits_exact_by_default(self, mock_request, method): + _mock(mock_request, "approximate") + getattr(TangoClient(api_key="k"), method)() + assert "exact" not in mock_request.call_args.kwargs["params"] + + def test_exact_is_keyword_only(self, method): + param = inspect.signature(getattr(TangoClient, method)).parameters["exact"] + assert param.kind is inspect.Parameter.KEYWORD_ONLY + assert param.default is False diff --git a/uv.lock b/uv.lock index 395068d..d63c40a 100644 --- a/uv.lock +++ b/uv.lock @@ -515,7 +515,7 @@ wheels = [ [[package]] name = "tango-python" -version = "1.19.0" +version = "1.20.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From c20131a846f03efc3fe9efebb75155024954e7f8 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Sat, 24 Oct 2026 12:49:42 -0500 Subject: [PATCH 2/2] docs: neutral wording and example data for exact counts Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/API_REFERENCE.md | 6 +++--- tango/client.py | 8 ++++---- tests/test_exact_count.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8be21..00c8284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`exact=True` on `list_opportunities()`, `list_notices()`, `list_contracts()` and `list_idvs()`.** Past 10,000 matches these endpoints report an estimated `count` that is only a lower bound; `exact=True` sends `?exact=true` for a real count (correct on opportunities and notices from Tango API 5.11.1). Keyword-only and off by default, so existing calls send the same request. +- **`exact=True` on `list_opportunities()`, `list_notices()`, `list_contracts()` and `list_idvs()`.** Past 10,000 matches these endpoints report an estimated `count` that is only a lower bound; `exact=True` sends `?exact=true` for a real count (opportunities and notices require Tango API 5.11.1). Keyword-only and off by default, so existing calls send the same request. ## [1.19.0] - 2026-10-21 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 3b3a289..7e216cb 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -2585,11 +2585,11 @@ response.count_type # "exact" or "approximate" (the API's X-Results-CountType he #### Exact counts `list_opportunities()`, `list_notices()`, `list_contracts()` and `list_idvs()` report an estimated `count` (`count_type == "approximate"`) once a filtered result set passes 10,000 matches, and that estimate is only a lower bound. -Pass `exact=True` to get a real count instead; it costs the server a full `COUNT(*)`, so ask only when you need the total. -On opportunities and notices it needs Tango API 5.11.1; earlier releases returned the estimate under an `exact` label. +Pass `exact=True` to get an exact count instead; it is more expensive for the API, so ask only when you need the total. +On opportunities and notices it requires Tango API 5.11.1. ```python -page = client.list_opportunities(agency="VA", active=False, limit=1, exact=True) +page = client.list_opportunities(agency="DOD", limit=1, exact=True) print(page.count, page.count_type) # count_type is "exact" ``` diff --git a/tango/client.py b/tango/client.py index 4f76875..bb576fc 100644 --- a/tango/client.py +++ b/tango/client.py @@ -735,7 +735,7 @@ def list_contracts( set_aside_type: Set-aside type (mapped to 'set_aside' API param) sort: Field to sort by (combined with 'order' to produce 'ordering') order: Sort order ('asc' or 'desc', default 'asc') - exact: If True, ask the API for an exact count. A filtered list past 10,000 matches otherwise reports an estimate (``count_type == "approximate"``) that is only a lower bound; an exact count costs the server a full COUNT. + exact: If True, ask the API for an exact count. A filtered list past 10,000 matches otherwise reports an estimate (``count_type == "approximate"``) that is only a lower bound. Examples: >>> contracts = client.list_contracts(limit=10) @@ -989,7 +989,7 @@ def list_idvs( `commercial_item_acquisition_procedures` takes an FPDS commercial item acquisition procedures code `A`–`E` (case-insensitive; `E` is DoD Section 803 Commercial Solutions Opening), pipe-separated for OR, e.g. `"A|E"`; an unknown code matches nothing. - Pass `exact=True` for an exact `count`: a filtered list past 10,000 matches otherwise reports an estimate (`count_type == "approximate"`) that is only a lower bound, and an exact count costs the server a full COUNT. + Pass `exact=True` for an exact `count`: a filtered list past 10,000 matches otherwise reports an estimate (`count_type == "approximate"`) that is only a lower bound. """ params: dict[str, Any] = {"limit": min(limit, 100)} if cursor: @@ -2465,7 +2465,7 @@ def list_opportunities( search: Search query set_aside: Set-aside type filter solicitation_number: Solicitation number filter - exact: If True, ask the API for an exact count. A filtered list past 10,000 matches otherwise reports an estimate (``count_type == "approximate"``) that is only a lower bound; an exact count costs the server a full COUNT. Correct on opportunities and notices from Tango API 5.11.1 + exact: If True, ask the API for an exact count. A filtered list past 10,000 matches otherwise reports an estimate (``count_type == "approximate"``) that is only a lower bound. Requires Tango API 5.11.1 """ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} @@ -2592,7 +2592,7 @@ def list_notices( search: Search query set_aside: Set-aside type filter solicitation_number: Solicitation number filter - exact: If True, ask the API for an exact count. A filtered list past 10,000 matches otherwise reports an estimate (``count_type == "approximate"``) that is only a lower bound; an exact count costs the server a full COUNT. Correct on opportunities and notices from Tango API 5.11.1 + exact: If True, ask the API for an exact count. A filtered list past 10,000 matches otherwise reports an estimate (``count_type == "approximate"``) that is only a lower bound. Requires Tango API 5.11.1 """ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} diff --git a/tests/test_exact_count.py b/tests/test_exact_count.py index 8fdd5db..0daaa15 100644 --- a/tests/test_exact_count.py +++ b/tests/test_exact_count.py @@ -13,7 +13,7 @@ def _mock(mock_request, count_type): response = Mock() response.is_success = True - response.json.return_value = {"count": 17677, "next": None, "previous": None, "results": []} + response.json.return_value = {"count": 12345, "next": None, "previous": None, "results": []} response.content = b"{}" response.headers = {"X-Results-CountType": count_type} mock_request.return_value = response @@ -26,7 +26,7 @@ def test_sends_exact_when_asked(self, mock_request, method): _mock(mock_request, "exact") page = getattr(TangoClient(api_key="k"), method)(exact=True) assert mock_request.call_args.kwargs["params"]["exact"] == "true" - assert page.count == 17677 + assert page.count == 12345 @patch("tango.client.httpx.Client.request") def test_omits_exact_by_default(self, mock_request, method):