Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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

### Added
Expand Down
16 changes: 16 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 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="DOD", limit=1, exact=True)
print(page.count, page.count_type) # count_type is "exact"
```

**Example:**
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions scripts/check_filter_shape_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
# SearchFilters escape hatch; the rest are pagination/shaping controls.
NON_FILTER_ARGS = {
"cursor",
"exact",
"limit",
"page",
"shape",
Expand Down
2 changes: 1 addition & 1 deletion tango/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
)
from .webhooks.receiver import Delivery, WebhookReceiver

__version__ = "1.19.0"
__version__ = "1.20.0"
__all__ = [
"TangoClient",
"TangoAPIError",
Expand Down
20 changes: 20 additions & 0 deletions tango/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Examples:
>>> contracts = client.list_contracts(limit=10)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
"""
params: dict[str, Any] = {"limit": min(limit, 100)}
if cursor:
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -2425,6 +2436,7 @@ def list_opportunities(
*,
awarded: bool | None = None,
awardee_uei: str | None = None,
exact: bool = False,
) -> PaginatedResponse:
"""
List contract opportunities/solicitations
Expand Down Expand Up @@ -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. Requires Tango API 5.11.1
"""
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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. Requires Tango API 5.11.1
"""
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}

Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions tests/test_exact_count.py
Original file line number Diff line number Diff line change
@@ -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": 12345, "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 == 12345

@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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading