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"""