diff --git a/CHANGELOG.md b/CHANGELOG.md index 070adb9..fe24386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`account_category` and `source_anomalies` on budget accounts** (Tango API 5.7.0). Both are in the default `BUDGET_ACCOUNTS_MINIMAL` shape and accepted in custom shapes. `account_category` is `budgetary` or `credit_financing` today. `source_anomalies` lists the problems found in the source data behind an account, and is `[]` when there are none. Each element is typed as the new `BudgetAccountSourceAnomaly` `TypedDict` (with `BudgetAccountAnomalySource` and `BudgetAccountAnomalySourceRow` for its nested `source`), and every key is optional. Treat `account_category` and an anomaly's `code` as open strings, since new values may appear. +- **`list_budget_accounts(account_category=..., account_category_in=...)`** filters by account category, exactly or against a comma-separated list. + +### Changed + +- **`list_budget_accounts()` documents the API's default ordering**: the latest fiscal year first, then largest `enacted_ba` first, with accounts that have no `enacted_ba` last. +- Re-vendored `contracts/filter_shape_contract.json` (Tango API 5.7.0) and regenerated `tango/shapes/generated_overlay.py`. + ### Fixed - **`list_idvs()`, `list_idv_awards()`, `list_idv_child_idvs()` and `list_idv_transactions()` now return the API's pagination `cursor`.** They dropped it, so `response.cursor` was always `None` and a caller following the cursor stopped after the first page. diff --git a/contracts/filter_shape_contract.json b/contracts/filter_shape_contract.json index 94ebe98..b8b8cac 100644 --- a/contracts/filter_shape_contract.json +++ b/contracts/filter_shape_contract.json @@ -1,6 +1,6 @@ { "meta": { - "api_version": "5.5.0", + "api_version": "5.7.0", "description": "Canonical API filter/shape contract. Downstream consumers (SDK, MCP) should validate their conformance against this manifest.", "generated_from": "scripts/filter_shape_conformance.py", "schema_version": 2 @@ -1628,6 +1628,8 @@ "resource_key": "budget/accounts", "runtime": { "filter_params": [ + "account_category", + "account_category__in", "account_title__icontains", "actual_vs_requested_contract", "actual_vs_requested_contract__gte", @@ -1727,6 +1729,15 @@ "unobligated_pct__lte" ], "filter_params_detail": { + "account_category": { + "filter_class": "CharFilter", + "type": "string" + }, + "account_category__in": { + "filter_class": "CharInFilter", + "lookup": "in", + "type": "string" + }, "account_title__icontains": { "filter_class": "CharFilter", "lookup": "icontains", @@ -2260,6 +2271,7 @@ } }, "fields": [ + "account_category", "account_narrative_excerpt", "account_title", "actual_vs_requested_contract", @@ -2324,6 +2336,7 @@ "requested_ba", "requested_contractual_services", "requested_personnel_share", + "source_anomalies", "subfunction_code", "top_contract_recipients", "top_grant_recipients", @@ -2333,6 +2346,7 @@ ] }, "shape_flat_paths": [ + "account_category", "account_narrative_excerpt", "account_title", "actual_vs_requested_contract", @@ -2441,6 +2455,7 @@ "requested_ba", "requested_contractual_services", "requested_personnel_share", + "source_anomalies", "subfunction_code", "top_contract_recipients", "top_grant_recipients", diff --git a/contracts/observed_shape_types.json b/contracts/observed_shape_types.json index e9a8919..7229010 100644 --- a/contracts/observed_shape_types.json +++ b/contracts/observed_shape_types.json @@ -124,6 +124,12 @@ }, "budget/accounts": { "paths": { + "account_category": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, "account_narrative_excerpt": { "is_list": false, "is_optional": true, @@ -438,6 +444,12 @@ "is_optional": true, "kind": "scalar", "type": "int" + }, + "source_anomalies": { + "is_list": true, + "is_optional": true, + "kind": "scalar", + "type": "dict" } }, "records_seen": 80 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 2ffea8b..b4c76b0 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -1770,6 +1770,8 @@ accounts = client.list_budget_accounts( bea_category=None, on_off_budget=None, subfunction_code=None, + account_category=None, + account_category_in=None, search=None, ordering=None, ) @@ -1785,8 +1787,18 @@ accounts = client.list_budget_accounts( - `bea_category` - BEA category (exact) - `on_off_budget` - On/off budget flag (exact) - `subfunction_code` - Subfunction code (exact) +- `account_category` - Account category (exact): `budgetary` or `credit_financing` today; treat it as an open string +- `account_category_in` - Comma-separated account categories to match any of (e.g. `"budgetary,credit_financing"`) - `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. The default is the latest fiscal year first, then largest `enacted_ba` first, with accounts that have no `enacted_ba` last. + +**Source anomalies:** the default shape includes `account_category` and `source_anomalies`. `source_anomalies` is a list of problems found in the source data behind the account, and `[]` when there are none. Each element is a `BudgetAccountSourceAnomaly` (a `TypedDict`; every key is optional and may be `None`, so read keys with `.get()`): `code`, `field`, `bound_field`, `action` (`capped` or `flagged`), `reported_value`, `served_value`, `likely_cause`, `affected_fields`, `message`, and `source` (`dataset`, `fiscal_year`, and `rows` of `fiscal_period`, `piid`, `parent_piid`, `tas`, `reporting_agency_id`, `transaction_obligated_amount`, `file_c_source`). Today `code` is one of `contract_exceeds_obligations`, `assistance_exceeds_obligations` or `contract_without_obligations`; treat it as an open string. There is no filter on anomalies. + +```python +for acct in client.list_budget_accounts(fiscal_year=2025).results: + for anomaly in acct["source_anomalies"] or []: + print(acct["federal_account_symbol"], anomaly.get("code"), anomaly.get("action")) +``` **Returns:** [PaginatedResponse](#paginatedresponse) of `BudgetAccount` records (see [ShapeConfig](#shapeconfig-predefined-shapes) for the default shape). @@ -2547,7 +2559,7 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE) | `FEDERAL_REGISTER_COMPREHENSIVE` | `get_federal_register_document` | The list fields plus dates, signing_date, start_page, end_page, volume, docket_ids, dockets, regulation_id_numbers, topics, correction_of, corrections, executive_order_number, presidential_document_number, proclamation_number, comment_url, regulations_dot_gov_url, raw_text_url, body_html_url (omits `full_text`) | | `EBUY_REQUESTS_MINIMAL` | `list_ebuy_requests` | rfq_id, request_type, title, schedule, sin, status, buyer_name, buyer_agency, buyer_agency_code, reference_number, issue_date, close_date, attachment_count, link_count, last_seen | | `EBUY_REQUESTS_COMPREHENSIVE` | `get_ebuy_request` | Every field, plus the `organization` and `attachments` expands | -| `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 | +| `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, account_category, lifecycle (requested/enacted/apportioned/obligated/outlayed/unobligated), contract & assistance rollups, key ratios, next-year growth, source_anomalies | | `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/__init__.py b/tango/__init__.py index ad53012..3a6b160 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -11,6 +11,9 @@ ) from .models import ( BudgetAccount, + BudgetAccountAnomalySource, + BudgetAccountAnomalySourceRow, + BudgetAccountSourceAnomaly, ContractAppeal, DibbsAward, DibbsRfp, @@ -72,6 +75,9 @@ "ResolveCandidate", "ResolveResult", "BudgetAccount", + "BudgetAccountAnomalySource", + "BudgetAccountAnomalySourceRow", + "BudgetAccountSourceAnomaly", "ContractAppeal", "FederalRegisterDocument", "EbuyAccess", diff --git a/tango/client.py b/tango/client.py index 846fe8f..c673dfd 100644 --- a/tango/client.py +++ b/tango/client.py @@ -4448,6 +4448,8 @@ def list_budget_accounts( bea_category: str | None = None, on_off_budget: str | None = None, subfunction_code: str | None = None, + account_category: str | None = None, + account_category_in: str | None = None, # Range-numeric filters: each field also accepts ``__gte`` / ``__lte``. requested_ba: float | None = None, requested_ba_gte: float | None = None, @@ -4554,6 +4556,11 @@ def list_budget_accounts( bea_category: BEA category (exact). on_off_budget: On/off budget flag (exact). subfunction_code: Subfunction code (exact). + account_category: Account category (exact), e.g. ``budgetary`` or + ``credit_financing``. Treat the value as an open string; new + categories may be added. + account_category_in: Comma-separated account categories to match + any of, e.g. ``"budgetary,credit_financing"``. 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). @@ -4596,7 +4603,9 @@ def list_budget_accounts( 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. + headroom first. The default ordering is the latest fiscal year + first, then largest ``enacted_ba`` first, with accounts that + have no ``enacted_ba`` sorted last. """ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} if shape is None: @@ -4619,6 +4628,8 @@ def list_budget_accounts( ("bea_category", bea_category), ("on_off_budget", on_off_budget), ("subfunction_code", subfunction_code), + ("account_category", account_category), + ("account_category__in", account_category_in), ("search", search), ("ordering", ordering), ) diff --git a/tango/models.py b/tango/models.py index 43fd643..aaf6956 100644 --- a/tango/models.py +++ b/tango/models.py @@ -786,6 +786,55 @@ class ValidateResult: errors: list[str] | None = None +class BudgetAccountAnomalySourceRow(TypedDict, total=False): + """One source record behind a budget-account source anomaly. + + Every key is optional and any value may be ``None``. + """ + + fiscal_period: int | None + piid: str | None + parent_piid: str | None + tas: str | None + reporting_agency_id: str | None + transaction_obligated_amount: float | str | None + file_c_source: str | None + + +class BudgetAccountAnomalySource(TypedDict, total=False): + """Where a budget-account source anomaly was found. + + Every key is optional and any value may be ``None``. + """ + + dataset: str | None + fiscal_year: int | None + rows: list[BudgetAccountAnomalySourceRow] | None + + +class BudgetAccountSourceAnomaly(TypedDict, total=False): + """One problem found in the source data behind a budget account. + + An element of ``BudgetAccount.source_anomalies``. Every key is optional and + any value may be ``None``, so read keys with ``.get()``. ``code`` and + ``action`` are open strings: today ``code`` is one of + ``contract_exceeds_obligations``, ``assistance_exceeds_obligations`` or + ``contract_without_obligations``, and ``action`` is ``capped`` or + ``flagged``, but new values may appear. + """ + + code: str | None + field: str | None + bound_field: str | None + action: str | None + reported_value: float | str | None + served_value: float | str | None + likely_cause: str | None + affected_fields: list[str] | None + message: str | None + source: BudgetAccountAnomalySource | None + + @dataclass class BudgetAccount: """Schema definition for BudgetAccount (not used for instances). @@ -805,6 +854,8 @@ class BudgetAccount: bea_category: str | None = None on_off_budget: str | None = None subfunction_code: str | None = None + # ``budgetary`` or ``credit_financing`` today; treat as an open string. + account_category: str | None = None # Lifecycle requested_ba: Decimal | None = None enacted_ba: Decimal | None = None @@ -828,6 +879,8 @@ class BudgetAccount: assistance_share_of_obligated: Decimal | None = None assistance_share_of_obligated_capped: Decimal | None = None assistance_share_capped_flag: bool | None = None + # Problems found in the source data; ``[]`` when there are none. + source_anomalies: list[BudgetAccountSourceAnomaly] | None = None # Forward-look next_year_requested_ba: Decimal | None = None ba_growth_next_year: Decimal | None = None @@ -1728,12 +1781,12 @@ class ShapeConfig: # Mirrors the API's BUDGET_ACCOUNT_DEFAULT_SHAPE. BUDGET_ACCOUNTS_MINIMAL: Final = ( "id,federal_account_symbol,fiscal_year,agency_code,agency_name,bureau_name," - "account_title,bea_category,on_off_budget,subfunction_code," + "account_title,bea_category,on_off_budget,subfunction_code,account_category," "requested_ba,enacted_ba,apportioned,obligated_total,outlayed_total," "unobligated_balance,contract_obligated,contract_share_of_obligated_capped," "assistance_obligated,obligated_to_apportioned_pct_capped," "obligated_to_enacted_pct_capped,outlayed_to_obligated_pct_capped," - "ba_growth_next_year_pct" + "ba_growth_next_year_pct,source_anomalies" ) # Default for list_organizations() diff --git a/tango/shapes/generated_overlay.py b/tango/shapes/generated_overlay.py index 4184010..ec42e60 100644 --- a/tango/shapes/generated_overlay.py +++ b/tango/shapes/generated_overlay.py @@ -1225,6 +1225,9 @@ ), }, "BudgetAccount": { + "account_category": FieldSchema( + name="account_category", type=str, is_optional=True, is_list=False + ), "account_narrative_excerpt": FieldSchema( name="account_narrative_excerpt", type=str, is_optional=True, is_list=False ), @@ -1410,6 +1413,9 @@ "requested_personnel_share": FieldSchema( name="requested_personnel_share", type=str, is_optional=True, is_list=False ), + "source_anomalies": FieldSchema( + name="source_anomalies", type=dict, is_optional=True, is_list=True + ), "subfunction_code": FieldSchema( name="subfunction_code", type=str, is_optional=True, is_list=False ), diff --git a/tests/test_client.py b/tests/test_client.py index c125b37..cf170b4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -601,6 +601,113 @@ def test_list_budget_accounts_no_filters_sends_only_pagination_and_shape(self, m ] assert leaked == [], f"unexpected filter keys sent: {leaked}" + @patch("tango.client.httpx.Client.request") + def test_list_budget_accounts_account_category_filters(self, mock_request): + 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( + account_category="credit_financing", + account_category_in="budgetary,credit_financing", + ) + + params = mock_request.call_args[1]["params"] + assert params["account_category"] == "credit_financing" + assert params["account_category__in"] == "budgetary,credit_financing" + + @patch("tango.client.httpx.Client.request") + def test_list_budget_accounts_default_shape_parses_source_anomalies(self, mock_request): + anomaly = { + "code": "contract_exceeds_obligations", + "field": "contract_obligated", + "bound_field": "obligated_total", + "action": "capped", + "reported_value": 1250.0, + "served_value": 1000.0, + "likely_cause": None, + "affected_fields": ["contract_obligated", "contract_share_of_obligated"], + "message": "Contract obligations exceed total obligations.", + "source": { + "dataset": "file_c", + "fiscal_year": 2025, + "rows": [ + { + "fiscal_period": 12, + "piid": "ABC123", + "parent_piid": None, + "tas": "012-1234", + "reporting_agency_id": "012", + "transaction_obligated_amount": 1250.0, + "file_c_source": "award", + } + ], + }, + } + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": 1, + "fiscal_year": 2025, + "account_category": "budgetary", + "source_anomalies": [anomaly], + }, + { + "id": 2, + "fiscal_year": 2025, + "account_category": "credit_financing", + "enacted_ba": None, + "source_anomalies": [], + }, + ], + } + mock_response.content = b"{}" + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + page = client.list_budget_accounts() + + shape_fields = mock_request.call_args[1]["params"]["shape"].split(",") + assert "account_category" in shape_fields + assert "source_anomalies" in shape_fields + first, second = page.results + assert first["account_category"] == "budgetary" + assert first["source_anomalies"] == [anomaly] + assert first["source_anomalies"][0]["source"]["rows"][0]["piid"] == "ABC123" + assert second["account_category"] == "credit_financing" + assert second["source_anomalies"] == [] + + @patch("tango.client.httpx.Client.request") + def test_get_budget_account_accepts_explicit_anomaly_shape(self, mock_request): + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = { + "id": 7, + "account_category": "budgetary", + "source_anomalies": [{"code": "some_future_code"}], + } + mock_response.content = b"{}" + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + account = client.get_budget_account(7, shape="id,account_category,source_anomalies") + + assert account["source_anomalies"] == [{"code": "some_future_code"}] + assert account["account_category"] == "budgetary" + class TestShapeConfig: """Test ShapeConfig class"""