From 39d8449b358dca84772ac5e497711b6fa5b1d4e0 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Fri, 5 Jun 2026 15:33:44 -0500 Subject: [PATCH] v1.2.0: expose full range-filter surface on list_budget_accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST endpoint at /api/budget/accounts/ has always supported __gte / __lte lookups on every numeric metric (the 26 fields in the backend's RANGE_NUMERIC_FIELDS), but the SDK only forwarded identity / taxonomy filters. Callers were forced to discover accounts by exact symbol lookup; pipeline-style queries weren't expressible without dropping to raw HTTP. This change adds 78 explicit named parameters (each of the 26 range fields × {exact, _gte, _lte}) so the discovery query "FY24 accounts where contract share ≥ 60%, unobligated balance ≥ $200M, and next-year growth ≥ 15%, sorted by largest headroom first" is a single SDK call. Per the SDK's filter-surface non-negotiable, every new filter is an explicit kwarg with a type hint — no **kwargs passthrough. Mapping body is table-driven to keep the method readable. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 ++++ pyproject.toml | 2 +- tango/__init__.py | 2 +- tango/client.py | 263 ++++++++++++++++++++++++++++++++++++++++++- tests/test_client.py | 68 +++++++++++ uv.lock | 2 +- 6 files changed, 351 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f531c7..43184c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-06-05 + +### Added +- `list_budget_accounts()` now exposes the full range-filter surface that the + REST endpoint has always supported. Every numeric metric on + `/api/budget/accounts/` — the 26 fields in the backend's + `RANGE_NUMERIC_FIELDS` list (`enacted_ba`, `apportioned`, `obligated_total`, + `unobligated_balance`, `contract_obligated`, + `contract_share_of_obligated_capped`, `ba_growth_next_year_pct`, + `actual_vs_requested_contract`, all the ratio fields and their `_capped` + variants, etc.) — is now accepted in three forms: exact match (`field=`), + greater-or-equal (`field_gte=`), and less-or-equal (`field_lte=`). + Previously only the identity / taxonomy filters were exposed, which forced + callers to discover accounts by exact symbol lookup; the new surface makes + pipeline-style queries (e.g. "all FY24 accounts where contract share ≥ 60%, + unobligated balance ≥ $200M, and next-year growth ≥ 15%, sorted by largest + headroom first") a single SDK call. The new params map to the API's + `field__gte` / `field__lte` form; `ordering=` already accepted any of these + fields and continues to. + ## [1.1.3] - 2026-06-04 ### Added diff --git a/pyproject.toml b/pyproject.toml index 8f5965b..acc85df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tango-python" -version = "1.1.3" +version = "1.2.0" description = "Python SDK for the Tango API" readme = "README.md" requires-python = ">=3.12" diff --git a/tango/__init__.py b/tango/__init__.py index cd661b7..c8aaf40 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -44,7 +44,7 @@ ) from .webhooks.receiver import Delivery, WebhookReceiver -__version__ = "1.1.3" +__version__ = "1.2.0" __all__ = [ "TangoClient", "TangoAPIError", diff --git a/tango/client.py b/tango/client.py index 919359a..139c68d 100644 --- a/tango/client.py +++ b/tango/client.py @@ -2649,6 +2649,85 @@ def list_budget_accounts( bea_category: str | None = None, on_off_budget: str | None = None, subfunction_code: str | None = None, + # Range-numeric filters: each field also accepts ``__gte`` / ``__lte``. + requested_ba: float | None = None, + requested_ba_gte: float | None = None, + requested_ba_lte: float | None = None, + enacted_ba: float | None = None, + enacted_ba_gte: float | None = None, + enacted_ba_lte: float | None = None, + apportioned: float | None = None, + apportioned_gte: float | None = None, + apportioned_lte: float | None = None, + obligated_total: float | None = None, + obligated_total_gte: float | None = None, + obligated_total_lte: float | None = None, + outlayed_total: float | None = None, + outlayed_total_gte: float | None = None, + outlayed_total_lte: float | None = None, + unobligated_balance: float | None = None, + unobligated_balance_gte: float | None = None, + unobligated_balance_lte: float | None = None, + contract_obligated: float | None = None, + contract_obligated_gte: float | None = None, + contract_obligated_lte: float | None = None, + contract_outlayed: float | None = None, + contract_outlayed_gte: float | None = None, + contract_outlayed_lte: float | None = None, + assistance_obligated: float | None = None, + assistance_obligated_gte: float | None = None, + assistance_obligated_lte: float | None = None, + assistance_outlayed: float | None = None, + assistance_outlayed_gte: float | None = None, + assistance_outlayed_lte: float | None = None, + contract_share_of_obligated_capped: float | None = None, + contract_share_of_obligated_capped_gte: float | None = None, + contract_share_of_obligated_capped_lte: float | None = None, + obligated_to_apportioned_pct: float | None = None, + obligated_to_apportioned_pct_gte: float | None = None, + obligated_to_apportioned_pct_lte: float | None = None, + obligated_to_apportioned_pct_capped: float | None = None, + obligated_to_apportioned_pct_capped_gte: float | None = None, + obligated_to_apportioned_pct_capped_lte: float | None = None, + apportioned_to_enacted_pct: float | None = None, + apportioned_to_enacted_pct_gte: float | None = None, + apportioned_to_enacted_pct_lte: float | None = None, + apportioned_to_enacted_pct_capped: float | None = None, + apportioned_to_enacted_pct_capped_gte: float | None = None, + apportioned_to_enacted_pct_capped_lte: float | None = None, + obligated_to_enacted_pct: float | None = None, + obligated_to_enacted_pct_gte: float | None = None, + obligated_to_enacted_pct_lte: float | None = None, + obligated_to_enacted_pct_capped: float | None = None, + obligated_to_enacted_pct_capped_gte: float | None = None, + obligated_to_enacted_pct_capped_lte: float | None = None, + outlayed_to_obligated_pct: float | None = None, + outlayed_to_obligated_pct_gte: float | None = None, + outlayed_to_obligated_pct_lte: float | None = None, + outlayed_to_obligated_pct_capped: float | None = None, + outlayed_to_obligated_pct_capped_gte: float | None = None, + outlayed_to_obligated_pct_capped_lte: float | None = None, + unobligated_pct: float | None = None, + unobligated_pct_gte: float | None = None, + unobligated_pct_lte: float | None = None, + enacted_ba_yoy_pct: float | None = None, + enacted_ba_yoy_pct_gte: float | None = None, + enacted_ba_yoy_pct_lte: float | None = None, + obligated_yoy_pct: float | None = None, + obligated_yoy_pct_gte: float | None = None, + obligated_yoy_pct_lte: float | None = None, + enacted_ba_5yr_cagr: float | None = None, + enacted_ba_5yr_cagr_gte: float | None = None, + enacted_ba_5yr_cagr_lte: float | None = None, + ba_growth_next_year_pct: float | None = None, + ba_growth_next_year_pct_gte: float | None = None, + ba_growth_next_year_pct_lte: float | None = None, + actual_vs_requested_contract: float | None = None, + actual_vs_requested_contract_gte: float | None = None, + actual_vs_requested_contract_lte: float | None = None, + actual_vs_requested_contract_capped: float | None = None, + actual_vs_requested_contract_capped_gte: float | None = None, + actual_vs_requested_contract_capped_lte: float | None = None, search: str | None = None, ordering: str | None = None, ) -> PaginatedResponse: @@ -2674,8 +2753,49 @@ def list_budget_accounts( bea_category: BEA category (exact). on_off_budget: On/off budget flag (exact). subfunction_code: Subfunction code (exact). + requested_ba: President's-budget requested BA (exact). Also + ``requested_ba_gte`` / ``requested_ba_lte`` for range queries. + enacted_ba: Enacted budget authority (exact / gte / lte). + apportioned: Apportioned amount (exact / gte / lte). + obligated_total: Total obligated (exact / gte / lte). + outlayed_total: Total outlayed (exact / gte / lte). + unobligated_balance: Apportioned minus obligated, in dollars + (exact / gte / lte). Use ``__gte`` to surface accounts with + appropriated headroom that hasn't yet hit contract. + contract_obligated: Contract-only obligated (exact / gte / lte). + contract_outlayed: Contract-only outlayed (exact / gte / lte). + assistance_obligated: Assistance-only obligated (exact / gte / lte). + assistance_outlayed: Assistance-only outlayed (exact / gte / lte). + contract_share_of_obligated_capped: Contracts as share of + obligated, capped at 1.0 (exact / gte / lte). Use ``__gte`` to + filter to contract-heavy accounts. + obligated_to_apportioned_pct: Burn ratio + (obligated / apportioned). Also ``_capped`` variant capped at + 1.0. Both expose exact / gte / lte. + apportioned_to_enacted_pct: Apportionment ratio + (apportioned / enacted). Also ``_capped`` variant. Exact / gte / lte. + obligated_to_enacted_pct: Obligated-to-enacted ratio. Also + ``_capped`` variant. Exact / gte / lte. + outlayed_to_obligated_pct: Spendout ratio (outlayed / obligated). + Also ``_capped`` variant. Exact / gte / lte. + unobligated_pct: Unobligated share of apportioned (exact / gte / lte). + enacted_ba_yoy_pct: Year-over-year enacted BA growth + (exact / gte / lte). + obligated_yoy_pct: Year-over-year obligated growth + (exact / gte / lte). + enacted_ba_5yr_cagr: 5-year compound annual growth of enacted BA + (exact / gte / lte). + ba_growth_next_year_pct: Next-year requested BA growth + (exact / gte / lte). Use ``__gte`` for forward-looking + pipeline discovery. + actual_vs_requested_contract: Realization ratio of contract + obligated against the prior-year request (exact / gte / lte). + Also ``_capped`` variant. search: Full-text search over account_title/agency_name/bureau_name. - ordering: Sort field (prefix with '-' for descending). + ordering: Sort field (prefix with '-' for descending). Any of the + numeric fields above is a valid ordering target — e.g. + ``ordering="-unobligated_balance"`` to rank by largest + headroom first. """ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} if shape is None: @@ -2686,7 +2806,7 @@ def list_budget_accounts( params["flat"] = "true" if flat_lists: params["flat_lists"] = "true" - for key, val in ( + scalar_filters: tuple[tuple[str, Any], ...] = ( ("federal_account_symbol", federal_account_symbol), ("fiscal_year", fiscal_year), ("fiscal_year__gte", fiscal_year_gte), @@ -2699,9 +2819,146 @@ def list_budget_accounts( ("subfunction_code", subfunction_code), ("search", search), ("ordering", ordering), - ): + ) + for key, val in scalar_filters: if val is not None: params[key] = val + # Range-numeric filters: each field has exact / __gte / __lte forms. + range_filters: tuple[tuple[str, float | None, float | None, float | None], ...] = ( + ("requested_ba", requested_ba, requested_ba_gte, requested_ba_lte), + ("enacted_ba", enacted_ba, enacted_ba_gte, enacted_ba_lte), + ("apportioned", apportioned, apportioned_gte, apportioned_lte), + ("obligated_total", obligated_total, obligated_total_gte, obligated_total_lte), + ("outlayed_total", outlayed_total, outlayed_total_gte, outlayed_total_lte), + ( + "unobligated_balance", + unobligated_balance, + unobligated_balance_gte, + unobligated_balance_lte, + ), + ( + "contract_obligated", + contract_obligated, + contract_obligated_gte, + contract_obligated_lte, + ), + ( + "contract_outlayed", + contract_outlayed, + contract_outlayed_gte, + contract_outlayed_lte, + ), + ( + "assistance_obligated", + assistance_obligated, + assistance_obligated_gte, + assistance_obligated_lte, + ), + ( + "assistance_outlayed", + assistance_outlayed, + assistance_outlayed_gte, + assistance_outlayed_lte, + ), + ( + "contract_share_of_obligated_capped", + contract_share_of_obligated_capped, + contract_share_of_obligated_capped_gte, + contract_share_of_obligated_capped_lte, + ), + ( + "obligated_to_apportioned_pct", + obligated_to_apportioned_pct, + obligated_to_apportioned_pct_gte, + obligated_to_apportioned_pct_lte, + ), + ( + "obligated_to_apportioned_pct_capped", + obligated_to_apportioned_pct_capped, + obligated_to_apportioned_pct_capped_gte, + obligated_to_apportioned_pct_capped_lte, + ), + ( + "apportioned_to_enacted_pct", + apportioned_to_enacted_pct, + apportioned_to_enacted_pct_gte, + apportioned_to_enacted_pct_lte, + ), + ( + "apportioned_to_enacted_pct_capped", + apportioned_to_enacted_pct_capped, + apportioned_to_enacted_pct_capped_gte, + apportioned_to_enacted_pct_capped_lte, + ), + ( + "obligated_to_enacted_pct", + obligated_to_enacted_pct, + obligated_to_enacted_pct_gte, + obligated_to_enacted_pct_lte, + ), + ( + "obligated_to_enacted_pct_capped", + obligated_to_enacted_pct_capped, + obligated_to_enacted_pct_capped_gte, + obligated_to_enacted_pct_capped_lte, + ), + ( + "outlayed_to_obligated_pct", + outlayed_to_obligated_pct, + outlayed_to_obligated_pct_gte, + outlayed_to_obligated_pct_lte, + ), + ( + "outlayed_to_obligated_pct_capped", + outlayed_to_obligated_pct_capped, + outlayed_to_obligated_pct_capped_gte, + outlayed_to_obligated_pct_capped_lte, + ), + ("unobligated_pct", unobligated_pct, unobligated_pct_gte, unobligated_pct_lte), + ( + "enacted_ba_yoy_pct", + enacted_ba_yoy_pct, + enacted_ba_yoy_pct_gte, + enacted_ba_yoy_pct_lte, + ), + ( + "obligated_yoy_pct", + obligated_yoy_pct, + obligated_yoy_pct_gte, + obligated_yoy_pct_lte, + ), + ( + "enacted_ba_5yr_cagr", + enacted_ba_5yr_cagr, + enacted_ba_5yr_cagr_gte, + enacted_ba_5yr_cagr_lte, + ), + ( + "ba_growth_next_year_pct", + ba_growth_next_year_pct, + ba_growth_next_year_pct_gte, + ba_growth_next_year_pct_lte, + ), + ( + "actual_vs_requested_contract", + actual_vs_requested_contract, + actual_vs_requested_contract_gte, + actual_vs_requested_contract_lte, + ), + ( + "actual_vs_requested_contract_capped", + actual_vs_requested_contract_capped, + actual_vs_requested_contract_capped_gte, + actual_vs_requested_contract_capped_lte, + ), + ) + for field, exact, gte, lte in range_filters: + if exact is not None: + params[field] = exact + if gte is not None: + params[f"{field}__gte"] = gte + if lte is not None: + params[f"{field}__lte"] = lte data = self._get("/api/budget/accounts/", params) results = [ self._parse_response_with_shape(obj, shape, BudgetAccount, flat, flat_lists) diff --git a/tests/test_client.py b/tests/test_client.py index 401c8fe..adbea21 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -505,6 +505,74 @@ def test_error_handling_404(self, mock_request): assert exc_info.value.status_code == 404 + @patch("tango.client.httpx.Client.request") + def test_list_budget_accounts_range_filters(self, mock_request): + """Range filters serialize to the API's ``field__gte`` / ``field__lte`` form.""" + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + mock_response.content = b'{"count": 0, "results": []}' + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + client.list_budget_accounts( + fiscal_year=2024, + contract_share_of_obligated_capped_gte=0.6, + ba_growth_next_year_pct_gte=0.15, + unobligated_balance_gte=200_000_000, + unobligated_balance_lte=10_000_000_000, + enacted_ba=3_135_000_000, + ordering="-unobligated_balance", + ) + + params = mock_request.call_args[1]["params"] + # Exact-match scalar filter still works. + assert params["fiscal_year"] == 2024 + # Range filters use the API's double-underscore form. + assert params["contract_share_of_obligated_capped__gte"] == 0.6 + assert params["ba_growth_next_year_pct__gte"] == 0.15 + assert params["unobligated_balance__gte"] == 200_000_000 + assert params["unobligated_balance__lte"] == 10_000_000_000 + # Range fields also accept exact-match. + assert params["enacted_ba"] == 3_135_000_000 + # Ordering passes through untouched (callers prefix with '-' for desc). + assert params["ordering"] == "-unobligated_balance" + # Unspecified filters are not sent. + assert "apportioned__gte" not in params + assert "obligated_yoy_pct__lte" not in params + + @patch("tango.client.httpx.Client.request") + def test_list_budget_accounts_no_filters_sends_only_pagination_and_shape(self, mock_request): + """With no filter args, the request carries only paging + default shape.""" + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + mock_response.content = b'{"count": 0, "results": []}' + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + client.list_budget_accounts() + + params = mock_request.call_args[1]["params"] + assert params["page"] == 1 + assert params["limit"] == 25 + assert "shape" in params + # None of the new range filters leak through as null. + leaked = [ + k for k in params if k.endswith("__gte") or k.endswith("__lte") or k == "fiscal_year" + ] + assert leaked == [], f"unexpected filter keys sent: {leaked}" + class TestShapeConfig: """Test ShapeConfig class""" diff --git a/uv.lock b/uv.lock index abb9ca7..b395043 100644 --- a/uv.lock +++ b/uv.lock @@ -515,7 +515,7 @@ wheels = [ [[package]] name = "tango-python" -version = "1.1.2" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "httpx" },