From 7a3e3d62c0c05c0cd372687d6645f046b4befc17 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 4 Jun 2026 10:09:11 -0500 Subject: [PATCH] docs(budget): document budget surface; paginate get_entity_budget_flows Extends `get_entity_budget_flows()` to expose the backend's StandardResultsSetPagination (`page`, `limit`) and a `fiscal_year` filter, returning `PaginatedResponse[dict[str, Any]]` instead of a raw dict. The previous signature gave callers no way to reach pages beyond the first or to narrow by fiscal year. Documents the budget surface that shipped in v1.1.0 but never made it into `docs/API_REFERENCE.md`: a new `## Budget` section covering `list_budget_accounts`, `get_budget_account`, `get_budget_account_quarters`, and `get_budget_account_recipients`; a `get_entity_budget_flows()` entry under Entity Sub-resources; a `BUDGET_ACCOUNTS_MINIMAL` row in the ShapeConfig table; and a Budget entry in the table of contents. Adds mock unit tests for the new signature (defaults, param flow-through with limit cap, empty-UEI validation) and a cassette-backed integration test that asserts the PaginatedResponse shape and `fiscal_year` filter behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 18 ++ docs/API_REFERENCE.md | 134 ++++++++++ tango/client.py | 31 ++- ...esIntegration.test_get_entity_budget_flows | 229 ++++++++++++++++++ .../integration/test_entities_integration.py | 58 +++++ tests/test_client.py | 59 +++++ 6 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 tests/cassettes/TestEntitiesIntegration.test_get_entity_budget_flows diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f168c3..bd3fee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- `get_entity_budget_flows()` now exposes the backend's standard page/limit + pagination and `fiscal_year` filter, and returns + `PaginatedResponse[dict[str, Any]]` instead of a raw dict. The backend has + always paginated this endpoint via `StandardResultsSetPagination`; the + previous signature gave callers no way to reach pages beyond the first or + to narrow by fiscal year. Callers that were indexing `result["results"]` + on the old return value should switch to `result.results` (and can now use + `result.next` / `page=` to walk further pages). + +### Docs +- `docs/API_REFERENCE.md` now documents the Budget surface that shipped in + v1.1.0: a new `## Budget` section covering `list_budget_accounts`, + `get_budget_account`, `get_budget_account_quarters`, and + `get_budget_account_recipients`; a `get_entity_budget_flows()` entry under + Entity Sub-resources; a `BUDGET_ACCOUNTS_MINIMAL` row in the ShapeConfig + table; and a Budget entry in the table of contents. + ### CI - Bumped GitHub Actions off the deprecated Node 20 runtime (forced off 2026-06-02): `actions/checkout` v4→v6, `astral-sh/setup-uv` v4→v8.1.0 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 2c50b03..e7c7560 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -21,6 +21,7 @@ Complete reference for all Tango Python SDK methods and functionality. - [Grants](#grants) - [GSA eLibrary Contracts](#gsa-elibrary-contracts) - [Protests](#protests) +- [Budget](#budget) - [Business Types](#business-types) - [NAICS](#naics) - [Webhooks](#webhooks) @@ -1212,6 +1213,118 @@ protest = client.get_protest( --- +## Budget + +Federal account × fiscal year budget rollups, covering the full budget lifecycle (requested → enacted → apportioned → obligated → outlayed), pre-computed ratios and trends, the contract / assistance / unlinked breakdown, and request-vs-actual spend. + +### list_budget_accounts() + +List budget accounts. One row per `(federal_account_symbol, fiscal_year)`. + +```python +accounts = client.list_budget_accounts( + page=1, + limit=25, + shape=ShapeConfig.BUDGET_ACCOUNTS_MINIMAL, + # Filter parameters (all optional) + federal_account_symbol=None, + fiscal_year=None, + fiscal_year_gte=None, + fiscal_year_lte=None, + agency_code=None, + bureau_name=None, + account_title=None, + bea_category=None, + on_off_budget=None, + subfunction_code=None, + search=None, + ordering=None, +) +``` + +**Filter Parameters:** +- `federal_account_symbol` - Exact federal account symbol (e.g., `"097-0100"`) +- `fiscal_year` - Fiscal year (exact) +- `fiscal_year_gte` / `fiscal_year_lte` - Fiscal year range +- `agency_code` - Agency code (exact) +- `bureau_name` - Bureau name (exact) +- `account_title` - Account title (case-insensitive substring match) +- `bea_category` - BEA category (exact) +- `on_off_budget` - On/off budget flag (exact) +- `subfunction_code` - Subfunction code (exact) +- `search` - Full-text search over `account_title`, `agency_name`, `bureau_name` +- `ordering` - Sort field; prefix with `-` for descending + +**Returns:** [PaginatedResponse](#paginatedresponse) of `BudgetAccount` records (see [ShapeConfig](#shapeconfig-predefined-shapes) for the default shape). + +**Example:** +```python +accounts = client.list_budget_accounts( + agency_code="097", + fiscal_year_gte=2023, + ordering="-enacted_ba", + limit=10, +) + +for acct in accounts.results: + print(f"{acct.federal_account_symbol} FY{acct.fiscal_year}: " + f"enacted ${acct.enacted_ba:,}") +``` + +### get_budget_account() + +Get a single budget account by id. + +```python +account = client.get_budget_account( + 12345, + shape=ShapeConfig.BUDGET_ACCOUNTS_MINIMAL, +) +``` + +**Parameters:** +- `id` (str | int): Budget account id. +- `shape` (str, optional): Response shape. Defaults to `BUDGET_ACCOUNTS_MINIMAL`. +- `flat` / `flat_lists` / `joiner`: See [Shaping Guide](SHAPES.md). + +**Returns:** A `BudgetAccount` record. + +### get_budget_account_quarters() + +Get quarterly TAS-grain flow for a budget account. FY21+ only. + +```python +quarters = client.get_budget_account_quarters(12345, limit=25) +``` + +**Parameters:** +- `id` (str | int): Budget account id. +- `tas` (str, optional): Narrow to a single Treasury Account Symbol. +- `limit` (int): Results per page (max 100). + +**Returns:** [PaginatedResponse](#paginatedresponse) of quarterly flow records. + +### get_budget_account_recipients() + +Get funding-office × recipient contract-flow detail for a budget account. + +```python +recipients = client.get_budget_account_recipients( + 12345, + funding_organization_id=None, + limit=25, +) +``` + +**Parameters:** +- `id` (str | int): Budget account id. +- `funding_organization_id` (str, optional): Narrow to a single funding office (Organization UUID). +- `limit` (int): Results per page (max 100). + +**Returns:** [PaginatedResponse](#paginatedresponse) of `(funding_office, recipient)` flow records. + +--- + ## Business Types Business type classifications. @@ -1461,6 +1574,26 @@ lcats = client.list_entity_lcats("ABCDEF123456", limit=25) metrics = client.get_entity_metrics("ABCDEF123456", months=12, period_grouping="month") ``` +### get_entity_budget_flows() + +Get budget flows for an entity (`/api/entities/{uei}/budget-flows/`) — the federal accounts that funded contracts and assistance awarded to this entity. + +```python +flows = client.get_entity_budget_flows("ABCDEF123456", fiscal_year=2024) +for row in flows.results: + print(row["federal_account_symbol"], row["contract_obligated"]) +# next page +more = client.get_entity_budget_flows("ABCDEF123456", page=2, fiscal_year=2024) +``` + +**Parameters:** +- `uei` (str): Entity UEI. Required. +- `page` (int): Page number. Default 1. +- `limit` (int): Results per page. Default 25, max 100. +- `fiscal_year` (int | None): Optional fiscal year filter. + +**Returns:** `PaginatedResponse[dict[str, Any]]` — standard `count / next / previous / results`. Result rows are raw dicts from the API (not shape-controlled). + --- ## IDV LCATs @@ -1875,6 +2008,7 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE) | `SUBAWARDS_MINIMAL` | `list_subawards` | award_key, prime_recipient(uei,display_name), subaward_recipient(uei,display_name) | | `GSA_ELIBRARY_CONTRACTS_MINIMAL` | `list_gsa_elibrary_contracts` | uuid, contract_number, schedule, recipient(display_name,uei), idv(key,award_date) | | `PROTESTS_MINIMAL` | `list_protests` | case_id, case_number, title, source_system, outcome, filed_date | +| `BUDGET_ACCOUNTS_MINIMAL` | `list_budget_accounts`, `get_budget_account` | id, federal_account_symbol, fiscal_year, agency_code/name, bureau_name, account_title, bea_category, on_off_budget, subfunction_code, lifecycle (requested/enacted/apportioned/obligated/outlayed/unobligated), contract & assistance rollups, key ratios, next-year growth | | `VEHICLE_ORDERS_MINIMAL` | `list_vehicle_orders` | key, piid, award_date, recipient(display_name,uei), total_contract_value, obligated | | `ITDASHBOARD_INVESTMENTS_MINIMAL` | `list_itdashboard_investments` | Minimal IT Dashboard investment fields | | `ITDASHBOARD_INVESTMENTS_COMPREHENSIVE` | `get_itdashboard_investment` | Full investment fields: uii, agency_code, agency_name, bureau_code, bureau_name, investment_title, type_of_investment, part_of_it_portfolio, updated_time, url | diff --git a/tango/client.py b/tango/client.py index 1d6426b..6b32731 100644 --- a/tango/client.py +++ b/tango/client.py @@ -2081,11 +2081,36 @@ def get_entity( data = self._get(f"/api/entities/{key}/", params) return self._parse_response_with_shape(data, shape, Entity, flat, flat_lists) - def get_entity_budget_flows(self, uei: str) -> dict[str, Any]: - """Get budget flows for an entity (`/api/entities/{uei}/budget-flows/`).""" + def get_entity_budget_flows( + self, + uei: str, + page: int = 1, + limit: int = 25, + fiscal_year: int | None = None, + ) -> PaginatedResponse[dict[str, Any]]: + """Get budget flows for an entity (`/api/entities/{uei}/budget-flows/`). + + Standard page/limit pagination (default 25, max 100). Each result row + is a hand-built dict from the backend (no shape system). + + Args: + uei: Entity UEI. Required. + page: Page number. + limit: Results per page (max 100). + fiscal_year: Optional fiscal year filter. + """ if not uei: raise TangoValidationError("UEI is required") - return self._get(f"/api/entities/{uei}/budget-flows/") + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + if fiscal_year is not None: + params["fiscal_year"] = fiscal_year + data = self._get(f"/api/entities/{uei}/budget-flows/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) # Forecast endpoints def list_forecasts( diff --git a/tests/cassettes/TestEntitiesIntegration.test_get_entity_budget_flows b/tests/cassettes/TestEntitiesIntegration.test_get_entity_budget_flows new file mode 100644 index 0000000..b81f87e --- /dev/null +++ b/tests/cassettes/TestEntitiesIntegration.test_get_entity_budget_flows @@ -0,0 +1,229 @@ +interactions: +- request: + body: '' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Host: + - tango.makegov.com + User-Agent: + - python-httpx/0.28.1 + method: GET + uri: https://tango.makegov.com/api/entities/?page=1&limit=5&shape=uei%2Clegal_business_name%2Ccage_code%2Cbusiness_types + response: + body: + string: '{"count":1794591,"next":"https://tango.makegov.com/api/entities/?limit=5&page=2&shape=uei%2Clegal_business_name%2Ccage_code%2Cbusiness_types","previous":null,"results":[{"business_types":[{"code":"A2"},{"code":"23"},{"code":"OY"},{"code":"2X"}],"cage_code":null,"legal_business_name":"!SCITAMEHTAM","uei":"JNSXDDCLHRM8"},{"business_types":[{"code":"A8"}],"cage_code":"393W6","legal_business_name":"!YOUTHWORKS!","uei":"NLQ3EMTUVAT4"},{"business_types":[{"code":"2X"},{"code":"XS"}],"cage_code":"4WBT9","legal_business_name":"\" + HALL''S PUMP & WELL, INC.\"","uei":"XKJXMX585A76"},{"business_types":[{"code":"A8"}],"cage_code":"SNEE6","legal_business_name":"\"24.KG\" + NEWS AGENCY LIMITED LIABILITY COMPANY","uei":"G47KDFCCLRQ3"},{"business_types":[{"code":"A8"}],"cage_code":"4N1M7","legal_business_name":"\"A + KID''S PLACE\" SOUTHERN MIDDLE TENNESSEE CHILD ADVOCACY CENTER, INC.","uei":"GHUMPKK5VRK5"}]}' + headers: + CF-RAY: + - a067d476b9fea1e8-MSP + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 04 Jun 2026 15:04:25 GMT + Nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + Report-To: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=QjOntGQK9NGj0pXrnRrzSOTY0Rc77FlHu3EpYe%2FAJ0%2F4HIa7vb37hFtP8KRXrToGPtM8FL6v9c3ctYL3eWKKg4dQHyNmoQ%2FrhEy2ltgUHmtE1boDckbHHe8h5ec6xmyenStYTeOHfHssuWKva8Y3"}]}' + Server: + - cloudflare + Transfer-Encoding: + - chunked + allow: + - GET, HEAD, OPTIONS + cf-cache-status: + - DYNAMIC + content-length: + - '899' + cross-origin-opener-policy: + - same-origin + referrer-policy: + - same-origin + vary: + - Accept, Cookie + x-content-type-options: + - nosniff + x-execution-time: + - 0.017s + x-frame-options: + - DENY + x-ratelimit-burst-limit: + - '1000' + x-ratelimit-burst-remaining: + - '999' + x-ratelimit-burst-reset: + - '59' + x-ratelimit-daily-limit: + - '2000000' + x-ratelimit-daily-remaining: + - '1999921' + x-ratelimit-daily-reset: + - '32134' + x-ratelimit-limit: + - '1000' + x-ratelimit-remaining: + - '999' + x-ratelimit-reset: + - '59' + x-results-counttype: + - approximate + status: + code: 200 + message: OK +- request: + body: '' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Host: + - tango.makegov.com + User-Agent: + - python-httpx/0.28.1 + method: GET + uri: https://tango.makegov.com/api/entities/JNSXDDCLHRM8/budget-flows/?page=1&limit=25 + response: + body: + string: '{"count":0,"next":null,"previous":null,"results":[],"uei":"JNSXDDCLHRM8","fiscal_year":null}' + headers: + CF-RAY: + - a067d478dfb9b45f-MSP + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 04 Jun 2026 15:04:25 GMT + Nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + Report-To: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=hEV9I2kPu8jfaECemhJ6a4ksou5rqEOBmHbDTDv%2FBIiPqfvzF9vMzI5JeTKz9i9z1%2FjVlPItIL3RzU6jKfCeNJ01fy5Nr2RgNwl%2F1M9ZJNEAvFmthpi9%2BuQOvD%2FkXWxS%2FBcDHHHUOqv91Tn4QvQ1"}]}' + Server: + - cloudflare + Transfer-Encoding: + - chunked + allow: + - GET, HEAD, OPTIONS + cf-cache-status: + - DYNAMIC + content-length: + - '92' + cross-origin-opener-policy: + - same-origin + referrer-policy: + - same-origin + vary: + - Accept, Cookie + x-content-type-options: + - nosniff + x-execution-time: + - 0.020s + x-frame-options: + - DENY + x-ratelimit-burst-limit: + - '1000' + x-ratelimit-burst-remaining: + - '998' + x-ratelimit-burst-reset: + - '59' + x-ratelimit-daily-limit: + - '2000000' + x-ratelimit-daily-remaining: + - '1999920' + x-ratelimit-daily-reset: + - '32134' + x-ratelimit-limit: + - '1000' + x-ratelimit-remaining: + - '998' + x-ratelimit-reset: + - '59' + status: + code: 200 + message: OK +- request: + body: '' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Host: + - tango.makegov.com + User-Agent: + - python-httpx/0.28.1 + method: GET + uri: https://tango.makegov.com/api/entities/JNSXDDCLHRM8/budget-flows/?page=1&limit=10&fiscal_year=2024 + response: + body: + string: '{"count":0,"next":null,"previous":null,"results":[],"uei":"JNSXDDCLHRM8","fiscal_year":2024}' + headers: + CF-RAY: + - a067d47a0dbfa1fa-MSP + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 04 Jun 2026 15:04:25 GMT + Nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + Report-To: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=rs1t88JDTfQw%2BSAh2raEKfBh%2F8s7xXnTefjWQeqdiSa70ttfsjspOS9szRkiRZ4S9%2BsXyebq%2FlTli43UwW1caOQyqWSJzJCVBanjoGzFuRs8%2BWyS3Kgaj8A6SasH8bmVt43O6j88LAn45BLpZN2N"}]}' + Server: + - cloudflare + Transfer-Encoding: + - chunked + allow: + - GET, HEAD, OPTIONS + cf-cache-status: + - DYNAMIC + content-length: + - '92' + cross-origin-opener-policy: + - same-origin + referrer-policy: + - same-origin + vary: + - Accept, Cookie + x-content-type-options: + - nosniff + x-execution-time: + - 0.018s + x-frame-options: + - DENY + x-ratelimit-burst-limit: + - '1000' + x-ratelimit-burst-remaining: + - '997' + x-ratelimit-burst-reset: + - '59' + x-ratelimit-daily-limit: + - '2000000' + x-ratelimit-daily-remaining: + - '1999919' + x-ratelimit-daily-reset: + - '32134' + x-ratelimit-limit: + - '1000' + x-ratelimit-remaining: + - '997' + x-ratelimit-reset: + - '59' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/integration/test_entities_integration.py b/tests/integration/test_entities_integration.py index a326ded..544374d 100644 --- a/tests/integration/test_entities_integration.py +++ b/tests/integration/test_entities_integration.py @@ -399,6 +399,64 @@ def test_list_entities_with_flat(self, tango_client): ) assert legal_business_name is not None, "Entity legal_business_name should be present" + @handle_api_exceptions("entities") + def test_get_entity_budget_flows(self, tango_client): + """Test get_entity_budget_flows() pagination + fiscal_year filter + + Validates: + - Returns a PaginatedResponse (count / next / previous / results) + - page and limit query params flow through + - fiscal_year filter narrows results to that year + - Result rows carry the documented backend keys + """ + # Pick any entity from the list — budget-flows may be empty for many + # UEIs, but the response structure is still validated. + list_response = tango_client.list_entities(limit=5, shape=ShapeConfig.ENTITIES_MINIMAL) + assert len(list_response.results) > 0, "Expected at least one entity" + + test_entity = next( + ( + e + for e in list_response.results + if (e.get("uei") if isinstance(e, dict) else getattr(e, "uei", None)) + ), + None, + ) + assert test_entity is not None, "Need a UEI to query budget-flows" + test_uei = test_entity.get("uei") if isinstance(test_entity, dict) else test_entity.uei + + # Default call: page=1, limit=25, no fiscal_year + flows = tango_client.get_entity_budget_flows(test_uei) + + # PaginatedResponse shape (matches validate_pagination minus the + # cursor field — budget-flows uses page/limit, not cursor). + assert hasattr(flows, "count") + assert hasattr(flows, "next") + assert hasattr(flows, "previous") + assert hasattr(flows, "results") + assert isinstance(flows.count, int) and flows.count >= 0 + assert isinstance(flows.results, list) + + # If any rows came back, they should carry the documented keys. + if flows.results: + row = flows.results[0] + assert isinstance(row, dict) + for required_key in ( + "federal_account_symbol", + "fiscal_year", + "contract_obligated", + ): + assert required_key in row, f"row missing {required_key!r}" + + # fiscal_year filter: any returned rows must match the requested year. + filtered = tango_client.get_entity_budget_flows(test_uei, fiscal_year=2024, limit=10) + assert hasattr(filtered, "count") + assert isinstance(filtered.results, list) + for row in filtered.results: + assert row["fiscal_year"] == 2024, ( + f"fiscal_year filter leaked year {row['fiscal_year']!r}" + ) + @handle_api_exceptions("entities") def test_entity_with_various_identifiers(self, tango_client): """Test entities with different identifier types (UEI, CAGE) diff --git a/tests/test_client.py b/tests/test_client.py index 084451b..401c8fe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1506,6 +1506,65 @@ def test_get_entity(self, mock_request): assert entity.uei == "ABC123" assert entity.uei == "ABC123" + @patch("tango.client.httpx.Client.request") + def test_get_entity_budget_flows_defaults(self, mock_request): + """Default call uses page=1, limit=25, no fiscal_year, and returns + a PaginatedResponse.""" + mock_response = Mock() + mock_response.is_success = True + mock_response.content = b'{"count": 1}' + mock_response.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "federal_account_symbol": "075-0140", + "fiscal_year": 2024, + "contract_obligated": "1000.00", + } + ], + } + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + flows = client.get_entity_budget_flows("ABC123") + + params = mock_request.call_args[1]["params"] + assert params == {"page": 1, "limit": 25} + + assert flows.count == 1 + assert flows.next is None + assert flows.previous is None + assert flows.results[0]["federal_account_symbol"] == "075-0140" + + @patch("tango.client.httpx.Client.request") + def test_get_entity_budget_flows_with_params(self, mock_request): + """page, limit, and fiscal_year flow through; limit caps at 100.""" + mock_response = Mock() + mock_response.is_success = True + mock_response.content = b'{"count": 0}' + mock_response.json.return_value = { + "count": 0, + "next": "https://example/next", + "previous": None, + "results": [], + } + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + flows = client.get_entity_budget_flows("ABC123", page=2, limit=500, fiscal_year=2024) + + params = mock_request.call_args[1]["params"] + assert params == {"page": 2, "limit": 100, "fiscal_year": 2024} + assert flows.next == "https://example/next" + + def test_get_entity_budget_flows_requires_uei(self): + """Empty UEI raises TangoValidationError without issuing a request.""" + client = TangoClient(api_key="test-key") + with pytest.raises(TangoValidationError): + client.get_entity_budget_flows("") + @patch("tango.client.httpx.Client.request") def test_list_forecasts(self, mock_request): """Test list_forecasts endpoint"""