From 9f400cfab33ed2d3666e0cde8c5314592ec3299c Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 28 Sep 2026 07:31:18 -0500 Subject: [PATCH 1/3] feat(federal-register): add the Federal Register documents resource `list_federal_register_documents` and `get_federal_register_document` cover `/api/federal_register/`, the rules, proposed rules, notices and presidential documents published since 1994, with all seventeen filters as named parameters and a 44-field `FederalRegisterDocument` schema. The detail route takes the document's `uuid`, since `document_number` alone is not unique. `full_text` is shapeable on the detail method only and stays out of both default shapes. The vendored contract is refreshed to API 5.3.0; the unrelated drift that refresh surfaces (an `agency` filter on three resources and an `organization` expand on four) is baselined as tracked gaps. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + contracts/conformance_baseline.json | 7 + contracts/filter_shape_contract.json | 339 +++++++++++++++++++++- contracts/shape_coverage_baseline.json | 8 +- docs/API_REFERENCE.md | 90 ++++++ docs/WEBHOOKS.md | 1 + scripts/check_filter_shape_conformance.py | 1 + scripts/check_shape_coverage.py | 1 + tango/__init__.py | 2 + tango/client.py | 148 +++++++++- tango/models.py | 77 +++++ tango/shapes/explicit_schemas.py | 78 +++++ tests/test_federal_register.py | 233 +++++++++++++++ 13 files changed, 983 insertions(+), 8 deletions(-) create mode 100644 tests/test_federal_register.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf1643..a1cf584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Federal Register documents** (Tango API 5.3.0). Two methods over the new `/api/federal_register/` resource: `list_federal_register_documents()` and `get_federal_register_document()`, plus a `FederalRegisterDocument` schema and the `FEDERAL_REGISTER_MINIMAL` / `FEDERAL_REGISTER_COMPREHENSIVE` defaults. These are the rules, proposed rules, notices and presidential documents published in the Federal Register since 1994. All seventeen of the API's filters are explicit named parameters, including `comments_open`, `cfr_title` / `cfr_part`, `rin`, and both `agency` (a Tango organization, including its sub-agencies) and `fr_agency` (the Federal Register's own agency slug). + + `get_federal_register_document()` takes the document's `uuid`, not its `document_number`, because a document number is not unique on its own: the Federal Register reused some before 2016. Look a document up by number with `list_federal_register_documents(document_number=...)`. `full_text` is available on the detail method only when named in `shape`, and neither default shape includes it, since it can run to several MB. `ordering="rank"` requires a non-empty `search`, matching the API. +- **`alerts.federal_register.match` is an alertable event type.** `create_webhook_alert(query_type="federal_register", ...)` works. + ## [1.7.0] - 2026-09-23 ### Added diff --git a/contracts/conformance_baseline.json b/contracts/conformance_baseline.json index a0252bb..702bcf9 100644 --- a/contracts/conformance_baseline.json +++ b/contracts/conformance_baseline.json @@ -23,6 +23,7 @@ "key" ], "budget/accounts": [ + "agency", "agency_code__in", "bea_category__in", "bureau_name__icontains", @@ -30,6 +31,12 @@ "federal_account_symbol__in", "fiscal_year__in", "subfunction_code__in" + ], + "exclusions": [ + "agency" + ], + "itdashboard": [ + "agency" ] } } diff --git a/contracts/filter_shape_contract.json b/contracts/filter_shape_contract.json index 0e47b14..a1fe295 100644 --- a/contracts/filter_shape_contract.json +++ b/contracts/filter_shape_contract.json @@ -1,6 +1,6 @@ { "meta": { - "api_version": "5.1.0", + "api_version": "5.3.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 @@ -1635,6 +1635,7 @@ "actual_vs_requested_contract_capped", "actual_vs_requested_contract_capped__gte", "actual_vs_requested_contract_capped__lte", + "agency", "agency_code", "agency_code__in", "apportioned", @@ -1759,6 +1760,10 @@ "lookup": "lte", "type": "number" }, + "agency": { + "filter_class": "OrganizationScopeAgencyFilter", + "type": "string" + }, "agency_code": { "filter_class": "CharFilter", "type": "string" @@ -2240,6 +2245,18 @@ "subaccount_code", "subfunction_code" ] + }, + "organization": { + "expands": {}, + "fields": [ + "agency_code", + "agency_name", + "department_code", + "department_name", + "office_code", + "office_name", + "organization_id" + ] } }, "fields": [ @@ -2299,6 +2316,7 @@ "obligated_total", "obligated_yoy_pct", "on_off_budget", + "organization_id", "outlayed_to_obligated_pct", "outlayed_to_obligated_pct_capped", "outlayed_to_obligated_pct_capped_flag", @@ -2407,6 +2425,15 @@ "obligated_total", "obligated_yoy_pct", "on_off_budget", + "organization", + "organization.agency_code", + "organization.agency_name", + "organization.department_code", + "organization.department_name", + "organization.office_code", + "organization.office_name", + "organization.organization_id", + "organization_id", "outlayed_to_obligated_pct", "outlayed_to_obligated_pct_capped", "outlayed_to_obligated_pct_capped_flag", @@ -4174,6 +4201,7 @@ "resource_key": "ebuy/requests", "runtime": { "filter_params": [ + "agency", "buyer_agency", "close_date_after", "close_date_before", @@ -4189,6 +4217,10 @@ "status" ], "filter_params_detail": { + "agency": { + "filter_class": "OrganizationScopeAgencyFilter", + "type": "string" + }, "buyer_agency": { "filter_class": "BaseSmartFilter", "type": "string" @@ -4234,7 +4266,7 @@ "type": "string" }, "search": { - "filter_class": "BaseSmartFilter", + "filter_class": "EbuyRequestSearchFilter", "type": "string" }, "sin": { @@ -7363,6 +7395,7 @@ "activate_date_after", "activate_date_before", "active", + "agency", "cage_code", "classification_type", "delisted", @@ -7394,6 +7427,10 @@ "filter_class": "BooleanFilter", "type": "boolean" }, + "agency": { + "filter_class": "OrganizationScopeAgencyFilter", + "type": "string" + }, "cage_code": { "filter_class": "BaseSmartFilter", "type": "string" @@ -7474,7 +7511,20 @@ "max_page_size": 100 }, "shape": { - "expands": {}, + "expands": { + "organization": { + "expands": {}, + "fields": [ + "agency_code", + "agency_name", + "department_code", + "department_name", + "office_code", + "office_name", + "organization_id" + ] + } + }, "fields": [ "activate_date", "additional_comments", @@ -7500,6 +7550,7 @@ "middle_name", "more_locations", "npi", + "organization_id", "prefix", "primary_address", "references", @@ -7542,6 +7593,15 @@ "middle_name", "more_locations", "npi", + "organization", + "organization.agency_code", + "organization.agency_name", + "organization.department_code", + "organization.department_name", + "organization.office_code", + "organization.office_name", + "organization.organization_id", + "organization_id", "prefix", "primary_address", "references", @@ -7575,6 +7635,230 @@ "show_shapes" ] }, + "federal_register": { + "basename": "federalregisterdocument", + "docs_file": null, + "docs_params": [], + "prefix": "federal_register", + "resource_key": "federal_register", + "runtime": { + "filter_params": [ + "agency", + "cfr_part", + "cfr_title", + "comments_close_on_after", + "comments_close_on_before", + "comments_open", + "document_number", + "effective_on_after", + "effective_on_before", + "executive_order_number", + "fr_agency", + "publication_date_after", + "publication_date_before", + "rin", + "search", + "significant", + "type" + ], + "filter_params_detail": { + "agency": { + "filter_class": "FederalRegisterAgencyFilter", + "type": "string" + }, + "cfr_part": { + "filter_class": "CharFilter", + "type": "string" + }, + "cfr_title": { + "filter_class": "CharFilter", + "type": "string" + }, + "comments_close_on_after": { + "filter_class": "DateFilter", + "lookup": "gte", + "type": "date" + }, + "comments_close_on_before": { + "filter_class": "DateFilter", + "lookup": "lte", + "type": "date" + }, + "comments_open": { + "filter_class": "BooleanFilter", + "type": "boolean" + }, + "document_number": { + "filter_class": "IdListFilter", + "type": "string" + }, + "effective_on_after": { + "filter_class": "DateFilter", + "lookup": "gte", + "type": "date" + }, + "effective_on_before": { + "filter_class": "DateFilter", + "lookup": "lte", + "type": "date" + }, + "executive_order_number": { + "filter_class": "CharFilter", + "type": "string" + }, + "fr_agency": { + "filter_class": "JsonbContainsFilter", + "type": "string" + }, + "publication_date_after": { + "filter_class": "DateFilter", + "lookup": "gte", + "type": "date" + }, + "publication_date_before": { + "filter_class": "DateFilter", + "lookup": "lte", + "type": "date" + }, + "rin": { + "filter_class": "JsonbContainsFilter", + "type": "string" + }, + "search": { + "filter_class": "FederalRegisterSearchFilter", + "type": "string" + }, + "significant": { + "filter_class": "BooleanFilter", + "type": "boolean" + }, + "type": { + "filter_class": "DocumentTypeFilter", + "type": "string" + } + }, + "ordering_aliases": [], + "ordering_fields": [ + "comments_close_on", + "document_number", + "effective_on", + "publication_date", + "rank" + ], + "pagination": { + "class": "CachedPageNumberPagination", + "max_page_size": 100 + }, + "shape": { + "expands": {}, + "fields": [ + "abstract", + "action", + "agencies", + "body_html_url", + "cfr_references", + "citation", + "comment_url", + "comments_close_on", + "correction_of", + "corrections", + "dates", + "disposition_notes", + "docket_ids", + "dockets", + "document_number", + "effective_on", + "end_page", + "executive_order_notes", + "executive_order_number", + "html_url", + "not_received_for_publication", + "page_length", + "pdf_url", + "presidential_document_number", + "proclamation_number", + "public_inspection_pdf_url", + "publication_date", + "raw_text_url", + "regulation_id_number_info", + "regulation_id_numbers", + "regulations_dot_gov_info", + "regulations_dot_gov_url", + "significant", + "signing_date", + "start_page", + "subtype", + "title", + "toc_doc", + "toc_subject", + "topics", + "type", + "uuid", + "volume" + ] + }, + "shape_flat_paths": [ + "abstract", + "action", + "agencies", + "body_html_url", + "cfr_references", + "citation", + "comment_url", + "comments_close_on", + "correction_of", + "corrections", + "dates", + "disposition_notes", + "docket_ids", + "dockets", + "document_number", + "effective_on", + "end_page", + "executive_order_notes", + "executive_order_number", + "html_url", + "not_received_for_publication", + "page_length", + "pdf_url", + "presidential_document_number", + "proclamation_number", + "public_inspection_pdf_url", + "publication_date", + "raw_text_url", + "regulation_id_number_info", + "regulation_id_numbers", + "regulations_dot_gov_info", + "regulations_dot_gov_url", + "significant", + "signing_date", + "start_page", + "subtype", + "title", + "toc_doc", + "toc_subject", + "topics", + "type", + "uuid", + "volume" + ], + "shape_gated_by_level": {}, + "shape_supported": true, + "shape_tier_required": null, + "viewset": "federal_register.views.FederalRegisterDocumentViewSet" + }, + "swagger_has_key": true, + "swagger_params": [ + "flat", + "flat_lists", + "joiner", + "limit", + "ordering", + "page", + "shape", + "show_shapes" + ] + }, "forecasts": { "basename": "forecast", "docs_file": null, @@ -10472,6 +10756,7 @@ "resource_key": "itdashboard", "runtime": { "filter_params": [ + "agency", "agency_code", "agency_name", "cio_rating", @@ -10484,6 +10769,10 @@ "updated_time_before" ], "filter_params_detail": { + "agency": { + "filter_class": "OrganizationScopeAgencyFilter", + "type": "string" + }, "agency_code": { "filter_class": "NumberFilter", "type": "number" @@ -13598,6 +13887,18 @@ "s3_key" ] }, + "organization": { + "expands": {}, + "fields": [ + "agency_code", + "agency_name", + "department_code", + "department_name", + "office_code", + "office_name", + "organization_id" + ] + }, "topics": { "expands": {}, "fields": [ @@ -13642,6 +13943,14 @@ "documents.n_pages", "documents.s3_key", "end_date", + "organization", + "organization.agency_code", + "organization.agency_name", + "organization.department_code", + "organization.department_name", + "organization.office_code", + "organization.office_name", + "organization.organization_id", "out_of_cycle", "program", "sol_download_url", @@ -13703,8 +14012,7 @@ "type": "choice" }, "agency": { - "filter_class": "CharFilter", - "lookup": "icontains", + "filter_class": "OrganizationScopeAgencyFilter", "type": "string" }, "close_date_after": { @@ -13794,6 +14102,18 @@ "title" ] }, + "organization": { + "expands": {}, + "fields": [ + "agency_code", + "agency_name", + "department_code", + "department_name", + "office_code", + "office_name", + "organization_id" + ] + }, "solicitation": { "expands": {}, "fields": [ @@ -13852,6 +14172,14 @@ "opportunity.response_deadline", "opportunity.solicitation_number", "opportunity.title", + "organization", + "organization.agency_code", + "organization.agency_name", + "organization.department_code", + "organization.department_name", + "organization.office_code", + "organization.office_name", + "organization.organization_id", "release_date", "solicitation", "solicitation.cycle_name", @@ -16993,6 +17321,7 @@ "entities_subawards", "events", "exclusions", + "federal_register", "forecasts", "grants", "gsa_elibrary_contracts", diff --git a/contracts/shape_coverage_baseline.json b/contracts/shape_coverage_baseline.json index 78b14b9..9e4a5ca 100644 --- a/contracts/shape_coverage_baseline.json +++ b/contracts/shape_coverage_baseline.json @@ -1,7 +1,13 @@ { "description": "Known reverse shape-coverage gaps (Tango exposes, SDK schema lacks), accepted as a tracked backlog. check_shape_coverage.py fails only on gaps NOT listed here. Burn down and regenerate with --update-baseline.", - "count": 15, + "count": 21, "known_gaps": [ + "missing_expand|budget/accounts|(root)|organization", + "missing_expand|exclusions|(root)|organization", + "missing_expand|sbir/solicitations|(root)|organization", + "missing_expand|sbir/topics|(root)|organization", + "missing_field|budget/accounts|(root)|organization_id", + "missing_field|exclusions|(root)|organization_id", "unmapped_resource|agencies_contracts_awarding|(root)|None", "unmapped_resource|agencies_contracts_funding|(root)|None", "unmapped_resource|contracts_subawards|(root)|None", diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c95b6ca..c947cd9 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -23,6 +23,7 @@ Complete reference for all Tango Python SDK methods and functionality. - [GSA eLibrary Contracts](#gsa-elibrary-contracts) - [Protests](#protests) - [Contract Appeals](#contract-appeals) +- [Federal Register](#federal-register) - [Budget](#budget) - [Business Types](#business-types) - [NAICS](#naics) @@ -1315,6 +1316,93 @@ appeal = client.get_contract_appeal( --- +## Federal Register + +Federal Register documents — rules, proposed rules, notices and presidential documents published since 1994. + +A document is identified by `uuid`. `document_number` is not unique on its own: the Federal Register reused some numbers before 2016, so `document_number=` can return more than one document. + +### list_federal_register_documents() + +List documents with filtering and shaping. + +```python +documents = client.list_federal_register_documents( + page=1, + limit=25, + shape=ShapeConfig.FEDERAL_REGISTER_MINIMAL, + # Filter parameters (all optional) + search=None, + document_number=None, + type=None, + agency=None, + fr_agency=None, + publication_date_after=None, + publication_date_before=None, + effective_on_after=None, + effective_on_before=None, + comments_close_on_after=None, + comments_close_on_before=None, + comments_open=None, + cfr_title=None, + cfr_part=None, + significant=None, + rin=None, + executive_order_number=None, + ordering=None, +) +``` + +**Filter Parameters:** +- `search` - Ranked full-text search over the title, abstract and action; wrap in double quotes for a phrase +- `document_number` - Exact FR document number, e.g. `"2016-31922"`; OR several with `|` +- `type` - `"Notice"`, `"Rule"`, `"Proposed Rule"`, `"Presidential Document"`, `"Correction"`, `"Sunshine Act Document"` or `"Uncategorized Document"` (case-insensitive); OR several with `|`. An unknown type is an error, not an empty page. Documents published before 2008 are mostly `Uncategorized Document`, so a type filter undercounts that era +- `agency` - A Tango agency name, abbreviation, code or organization key, e.g. `"EPA"`. Matches a document when any agency it lists falls within that organization, so a department includes its sub-agencies; OR several with `|` +- `fr_agency` - The Federal Register's own agency slug, e.g. `"environmental-protection-agency"`; OR several with `|` +- `publication_date_after` / `publication_date_before` - Publication date range (`YYYY-MM-DD`) +- `effective_on_after` / `effective_on_before` - Effective date range (`YYYY-MM-DD`) +- `comments_close_on_after` / `comments_close_on_before` - Comment-deadline range (`YYYY-MM-DD`) +- `comments_open` - `True` for documents whose comment period closes today or later, `False` for those already closed; documents with no comment deadline match neither +- `cfr_title` - A CFR title number, e.g. `"40"` +- `cfr_part` - A CFR part number, e.g. `"52"`. Requires `cfr_title`, and both must match the same CFR reference, so `cfr_title="40", cfr_part="52"` finds 40 CFR 52 +- `significant` - Significant under Executive Order 12866 +- `rin` - A Regulation Identifier Number, e.g. `"2060-AV16"`; OR several with `|` +- `executive_order_number` - Exact executive order number +- `ordering` - `publication_date` (the default, as `-publication_date`), `effective_on`, `comments_close_on`, `document_number` or `rank`. `rank` requires a non-empty `search` + +**Returns:** [PaginatedResponse](#paginatedresponse) with document dictionaries + +**Example:** +```python +documents = client.list_federal_register_documents( + type="Proposed Rule", + comments_open=True, + agency="EPA", + limit=25, +) + +for doc in documents.results: + print(f"{doc['document_number']} ({doc['publication_date']}): {doc['title']}") + print(f" comments close {doc['comments_close_on']}") +``` + +### get_federal_register_document() + +Get a single document by `uuid`. To look one up by its document number, use `list_federal_register_documents(document_number=...)`. + +```python +document = client.get_federal_register_document( + "DOCUMENT_UUID", + shape=ShapeConfig.FEDERAL_REGISTER_COMPREHENSIVE, +) +``` + +**Notes:** +- `agencies`, `cfr_references`, `dockets` and `topics` are the Federal Register's own structures, served as published. `agencies` carries Federal Register agency slugs, not Tango organization keys. +- `full_text`, the document's plain text, is available on this method only and only when named in `shape` (e.g. `shape="uuid,title,full_text"`). It can run to several MB, so neither default shape includes it. + +--- + ## State & Local (SLED) State, local and education procurement — solicitations that never appear on SAM.gov because they were never federal. **Beta**: coverage is partial and grows one jurisdiction at a time. @@ -2311,6 +2399,8 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE) | `PROTESTS_MINIMAL` | `list_protests` | case_id, case_number, title, source_system, outcome, filed_date | | `CONTRACT_APPEALS_MINIMAL` | `list_contract_appeals` | uuid, board, docket_numbers, decision_date, appellant, judge, decision_type, url | | `CONTRACT_APPEALS_COMPREHENSIVE` | `get_contract_appeal` | The list fields plus docket_raw, docket_source, decision_date_repaired, decision_type_raw, listing_year, first_listed_at, listed, text_status, text_char_count (omits `decision_text`, which needs an Enterprise plan) | +| `FEDERAL_REGISTER_MINIMAL` | `list_federal_register_documents` | uuid, document_number, publication_date, type, subtype, title, abstract, action, agencies, cfr_references, citation, significant, comments_close_on, effective_on, html_url, pdf_url | +| `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`) | | `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 | diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index b79a1a4..6876a24 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -89,6 +89,7 @@ tango webhooks list-event-types # alerts.dibbs_rfp.match DLA DIBBS RFP matched a saved alert # alerts.sled_opportunity.match State/local/education solicitation matched a saved alert # alerts.contract_appeal.match CBCA/ASBCA appeal decision matched a saved alert +# alerts.federal_register.match Federal Register document matched a saved alert ``` This list is served by the API, so it is always current — the SDK does not hardcode diff --git a/scripts/check_filter_shape_conformance.py b/scripts/check_filter_shape_conformance.py index 6cf3fc3..986be2e 100644 --- a/scripts/check_filter_shape_conformance.py +++ b/scripts/check_filter_shape_conformance.py @@ -78,6 +78,7 @@ "sled/forecasts": "list_sled_forecasts", "protests": "list_protests", "contract_appeals": "list_contract_appeals", + "federal_register": "list_federal_register_documents", "offices": "list_offices", "psc": "list_psc", "mas_sins": "list_mas_sins", diff --git a/scripts/check_shape_coverage.py b/scripts/check_shape_coverage.py index 91dfade..055eaac 100644 --- a/scripts/check_shape_coverage.py +++ b/scripts/check_shape_coverage.py @@ -69,6 +69,7 @@ "budget_accounts": "BudgetAccount", "protests": "Protest", "contract_appeals": "ContractAppeal", + "federal_register": "FederalRegisterDocument", "offices": "Office", "assistance_listings": "AssistanceListing", "business_types": "BusinessType", diff --git a/tango/__init__.py b/tango/__init__.py index e272d6d..403e447 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -15,6 +15,7 @@ DibbsRfp, DibbsRfq, Exclusion, + FederalRegisterDocument, GsaElibraryContract, ITDashboardInvestment, PaginatedResponse, @@ -67,6 +68,7 @@ "ResolveResult", "BudgetAccount", "ContractAppeal", + "FederalRegisterDocument", "DibbsAward", "DibbsRfp", "DibbsRfq", diff --git a/tango/client.py b/tango/client.py index b170d88..994177c 100644 --- a/tango/client.py +++ b/tango/client.py @@ -30,6 +30,7 @@ DibbsRfq, Entity, Exclusion, + FederalRegisterDocument, Forecast, Grant, GsaElibraryContract, @@ -2818,6 +2819,149 @@ def get_contract_appeal( data = self._get(f"/api/contract_appeals/{appeal_id}/", params) return self._parse_response_with_shape(data, shape, ContractAppeal, flat, flat_lists) + # ============================================================================ + # Federal Register documents + # ============================================================================ + + def list_federal_register_documents( + self, + page: int = 1, + limit: int = 25, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + search: str | None = None, + document_number: str | None = None, + type: str | None = None, + agency: str | None = None, + fr_agency: str | None = None, + publication_date_after: str | None = None, + publication_date_before: str | None = None, + effective_on_after: str | None = None, + effective_on_before: str | None = None, + comments_close_on_after: str | None = None, + comments_close_on_before: str | None = None, + comments_open: bool | None = None, + cfr_title: str | None = None, + cfr_part: str | None = None, + significant: bool | None = None, + rin: str | None = None, + executive_order_number: str | None = None, + ordering: str | None = None, + ) -> PaginatedResponse: + """ + List Federal Register documents. + + Rules, proposed rules, notices and presidential documents published in the Federal Register since 1994. + + Args: + page: Page number + limit: Results per page (max 100) + shape: Response shape string (defaults to minimal shape) + flat: If True, flatten nested objects in shaped response + flat_lists: If True, flatten arrays using indexed keys + search: Ranked full-text search over the title, abstract and action. Wrap in double quotes for a phrase + document_number: Exact FR document number, e.g. ``2016-31922``. OR several with ``|``. A number the Federal Register reused before 2016 matches every document that carries it + type: ``Notice``, ``Rule``, ``Proposed Rule``, ``Presidential Document``, ``Correction``, ``Sunshine Act Document`` or ``Uncategorized Document`` (case-insensitive). OR several with ``|``. Documents published before 2008 are mostly ``Uncategorized Document`` + agency: A Tango agency name, abbreviation, code or organization key, e.g. ``EPA``. Matches a document when any agency it lists falls within that organization, so a department includes its sub-agencies. OR several with ``|`` + fr_agency: The Federal Register's own agency slug, e.g. ``environmental-protection-agency``. OR several with ``|`` + publication_date_after: Published on or after (YYYY-MM-DD) + publication_date_before: Published on or before (YYYY-MM-DD) + effective_on_after: Effective on or after (YYYY-MM-DD) + effective_on_before: Effective on or before (YYYY-MM-DD) + comments_close_on_after: Comment period closes on or after (YYYY-MM-DD) + comments_close_on_before: Comment period closes on or before (YYYY-MM-DD) + comments_open: ``True`` for documents whose comment period closes today or later, ``False`` for those already closed. Documents with no comment deadline match neither + cfr_title: A CFR title number, e.g. ``40`` + cfr_part: A CFR part number, e.g. ``52``. Requires ``cfr_title``, and both must match the same CFR reference + significant: Significant under Executive Order 12866 + rin: A Regulation Identifier Number, e.g. ``2060-AV16``. OR several with ``|`` + executive_order_number: Exact executive order number + ordering: Sort field — ``publication_date`` (the default, as ``-publication_date``), ``effective_on``, ``comments_close_on``, ``document_number`` or ``rank``. ``rank`` requires a non-empty ``search`` + """ + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + + if shape is None: + shape = ShapeConfig.FEDERAL_REGISTER_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if flat_lists: + params["flat_lists"] = "true" + + for key, val in ( + ("search", search), + ("document_number", document_number), + ("type", type), + ("agency", agency), + ("fr_agency", fr_agency), + ("publication_date_after", publication_date_after), + ("publication_date_before", publication_date_before), + ("effective_on_after", effective_on_after), + ("effective_on_before", effective_on_before), + ("comments_close_on_after", comments_close_on_after), + ("comments_close_on_before", comments_close_on_before), + ("comments_open", comments_open), + ("cfr_title", cfr_title), + ("cfr_part", cfr_part), + ("significant", significant), + ("rin", rin), + ("executive_order_number", executive_order_number), + ("ordering", ordering), + ): + if val is not None: + params[key] = val + + data = self._get("/api/federal_register/", params) + + results = [ + self._parse_response_with_shape(item, shape, FederalRegisterDocument, flat, flat_lists) + for item in data["results"] + ] + + return PaginatedResponse( + count=data["count"], + next=data.get("next"), + previous=data.get("previous"), + results=results, + meta=data.get("meta"), + ) + + def get_federal_register_document( + self, + document_uuid: str, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + ) -> Any: + """ + Get a single Federal Register document by uuid. + + The detail route takes the document's ``uuid``, not its ``document_number``, which is not unique on its own. + To look a document up by number, use ``list_federal_register_documents(document_number=...)``. + + Args: + document_uuid: Document UUID + shape: Response shape string (defaults to the comprehensive shape). Name ``full_text`` here to get the document's plain text, which can run to several MB + flat: If True, flatten nested objects in shaped response + flat_lists: If True, flatten arrays using indexed keys + """ + params: dict[str, Any] = {} + if shape is None: + shape = ShapeConfig.FEDERAL_REGISTER_COMPREHENSIVE + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if flat_lists: + params["flat_lists"] = "true" + + data = self._get(f"/api/federal_register/{document_uuid}/", params) + return self._parse_response_with_shape( + data, shape, FederalRegisterDocument, flat, flat_lists + ) + # ============================================================================ # DLA DIBBS (RFQs, RFPs, awards) # ============================================================================ @@ -4870,8 +5014,8 @@ def create_webhook_alert( name: Human-readable name for this alert. query_type: One of ``opportunity``, ``contract``, ``idv``, ``ota``, ``otidv``, ``entity``, ``grant``, ``forecast``, ``exclusion``, - ``dibbs_rfq``, ``dibbs_rfp``, ``sled_opportunity`` or - ``contract_appeal``. :meth:`list_webhook_event_types` is the + ``dibbs_rfq``, ``dibbs_rfp``, ``sled_opportunity``, + ``contract_appeal`` or ``federal_register``. :meth:`list_webhook_event_types` is the current authority — this list can only go stale. filters: Dict of query parameters that the alert matches against (e.g. ``{"naics": "541330", "set_aside": "SBA"}``). diff --git a/tango/models.py b/tango/models.py index 5abbfcb..9ae11ea 100644 --- a/tango/models.py +++ b/tango/models.py @@ -1213,6 +1213,65 @@ class ContractAppeal: decision_text: str | None = None +class FederalRegisterDocument: + """Schema definition for FederalRegisterDocument (not used for instances). + + Federal Register documents at ``/api/federal_register/``: rules, proposed rules, notices and presidential documents published since 1994. + Shape-on-demand, so every field is optional. + + A document is identified by ``uuid``. + ``document_number`` alone is not unique: the Federal Register reused some numbers before 2016, so ``document_number=`` can match more than one document. + + ``agencies``, ``cfr_references``, ``dockets`` and ``topics`` are the Federal Register's own structures, served as published. + ``full_text`` can run to several MB, so it is available on the detail endpoint only and only when named in ``shape``. + """ + + uuid: str | None = None + document_number: str | None = None + publication_date: date | None = None + citation: str | None = None + title: str | None = None + type: str | None = None + subtype: str | None = None + abstract: str | None = None + action: str | None = None + dates: str | None = None + comments_close_on: date | None = None + effective_on: date | None = None + signing_date: date | None = None + start_page: int | None = None + end_page: int | None = None + page_length: int | None = None + volume: int | None = None + significant: bool | None = None + toc_doc: str | None = None + toc_subject: str | None = None + correction_of: str | None = None + corrections: list[str] | None = None + disposition_notes: str | None = None + executive_order_notes: str | None = None + executive_order_number: str | None = None + presidential_document_number: str | None = None + proclamation_number: str | None = None + not_received_for_publication: str | None = None + comment_url: str | None = None + regulations_dot_gov_url: str | None = None + html_url: str | None = None + body_html_url: str | None = None + pdf_url: str | None = None + public_inspection_pdf_url: str | None = None + raw_text_url: str | None = None + agencies: list[dict[str, Any]] | None = None + cfr_references: list[dict[str, Any]] | None = None + dockets: list[dict[str, Any]] | None = None + docket_ids: list[str] | None = None + topics: list[str] | None = None + regulation_id_numbers: list[str] | None = None + regulation_id_number_info: dict[str, Any] | None = None + regulations_dot_gov_info: dict[str, Any] | None = None + full_text: str | None = None + + @dataclass class PaginatedResponse[T]: """Paginated API response @@ -1394,6 +1453,24 @@ class ShapeConfig: "listing_year,first_listed_at,listed,text_status,text_char_count" ) + # Default for list_federal_register_documents(). Mirrors the API's own list default. + FEDERAL_REGISTER_MINIMAL: Final = ( + "uuid,document_number,publication_date,type,subtype,title,abstract,action," + "agencies,cfr_references,citation,significant,comments_close_on,effective_on," + "html_url,pdf_url" + ) + + # Default for get_federal_register_document(). Mirrors the API's own retrieve default. + # `full_text` is left out on purpose: it can run to several MB. + FEDERAL_REGISTER_COMPREHENSIVE: Final = ( + "uuid,document_number,publication_date,type,subtype,title,abstract,action," + "agencies,cfr_references,citation,significant,comments_close_on,effective_on," + "html_url,pdf_url,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" + ) + # Default for list_dibbs_rfqs() DIBBS_RFQS_MINIMAL: Final = ( "uuid,solicitation,nsn,part_number,nomenclature,quantity,issue_date,return_by_date,is_open" diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index 6370656..ed94621 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -800,6 +800,83 @@ } +FEDERAL_REGISTER_DOCUMENT_SCHEMA: dict[str, FieldSchema] = { + "uuid": FieldSchema(name="uuid", type=str, is_optional=True, is_list=False), + "document_number": FieldSchema( + name="document_number", type=str, is_optional=True, is_list=False + ), + "publication_date": FieldSchema( + name="publication_date", type=date, is_optional=True, is_list=False + ), + "citation": FieldSchema(name="citation", type=str, is_optional=True, is_list=False), + "title": FieldSchema(name="title", type=str, is_optional=True, is_list=False), + "type": FieldSchema(name="type", type=str, is_optional=True, is_list=False), + "subtype": FieldSchema(name="subtype", type=str, is_optional=True, is_list=False), + "abstract": FieldSchema(name="abstract", type=str, is_optional=True, is_list=False), + "action": FieldSchema(name="action", type=str, is_optional=True, is_list=False), + "dates": FieldSchema(name="dates", type=str, is_optional=True, is_list=False), + "comments_close_on": FieldSchema( + name="comments_close_on", type=date, is_optional=True, is_list=False + ), + "effective_on": FieldSchema(name="effective_on", type=date, is_optional=True, is_list=False), + "signing_date": FieldSchema(name="signing_date", type=date, is_optional=True, is_list=False), + "start_page": FieldSchema(name="start_page", type=int, is_optional=True, is_list=False), + "end_page": FieldSchema(name="end_page", type=int, is_optional=True, is_list=False), + "page_length": FieldSchema(name="page_length", type=int, is_optional=True, is_list=False), + "volume": FieldSchema(name="volume", type=int, is_optional=True, is_list=False), + "significant": FieldSchema(name="significant", type=bool, is_optional=True, is_list=False), + "toc_doc": FieldSchema(name="toc_doc", type=str, is_optional=True, is_list=False), + "toc_subject": FieldSchema(name="toc_subject", type=str, is_optional=True, is_list=False), + "correction_of": FieldSchema(name="correction_of", type=str, is_optional=True, is_list=False), + "corrections": FieldSchema(name="corrections", type=str, is_optional=True, is_list=True), + "disposition_notes": FieldSchema( + name="disposition_notes", type=str, is_optional=True, is_list=False + ), + "executive_order_notes": FieldSchema( + name="executive_order_notes", type=str, is_optional=True, is_list=False + ), + "executive_order_number": FieldSchema( + name="executive_order_number", type=str, is_optional=True, is_list=False + ), + "presidential_document_number": FieldSchema( + name="presidential_document_number", type=str, is_optional=True, is_list=False + ), + "proclamation_number": FieldSchema( + name="proclamation_number", type=str, is_optional=True, is_list=False + ), + "not_received_for_publication": FieldSchema( + name="not_received_for_publication", type=str, is_optional=True, is_list=False + ), + "comment_url": FieldSchema(name="comment_url", type=str, is_optional=True, is_list=False), + "regulations_dot_gov_url": FieldSchema( + name="regulations_dot_gov_url", type=str, is_optional=True, is_list=False + ), + "html_url": FieldSchema(name="html_url", type=str, is_optional=True, is_list=False), + "body_html_url": FieldSchema(name="body_html_url", type=str, is_optional=True, is_list=False), + "pdf_url": FieldSchema(name="pdf_url", type=str, is_optional=True, is_list=False), + "public_inspection_pdf_url": FieldSchema( + name="public_inspection_pdf_url", type=str, is_optional=True, is_list=False + ), + "raw_text_url": FieldSchema(name="raw_text_url", type=str, is_optional=True, is_list=False), + "agencies": FieldSchema(name="agencies", type=dict, is_optional=True, is_list=True), + "cfr_references": FieldSchema(name="cfr_references", type=dict, is_optional=True, is_list=True), + "dockets": FieldSchema(name="dockets", type=dict, is_optional=True, is_list=True), + "docket_ids": FieldSchema(name="docket_ids", type=str, is_optional=True, is_list=True), + "topics": FieldSchema(name="topics", type=str, is_optional=True, is_list=True), + "regulation_id_numbers": FieldSchema( + name="regulation_id_numbers", type=str, is_optional=True, is_list=True + ), + "regulation_id_number_info": FieldSchema( + name="regulation_id_number_info", type=dict, is_optional=True, is_list=False + ), + "regulations_dot_gov_info": FieldSchema( + name="regulations_dot_gov_info", type=dict, is_optional=True, is_list=False + ), + # Detail endpoint only, and only when named in the shape. + "full_text": FieldSchema(name="full_text", type=str, is_optional=True, is_list=False), +} + + AGENCY_SCHEMA: dict[str, FieldSchema] = { "abbreviation": FieldSchema(name="abbreviation", type=str, is_optional=True, is_list=False), "code": FieldSchema(name="code", type=str, is_optional=False, is_list=False), @@ -1545,6 +1622,7 @@ "Protest": PROTEST_SCHEMA, "ProtestDocket": PROTEST_DOCKET_SCHEMA, "ContractAppeal": CONTRACT_APPEAL_SCHEMA, + "FederalRegisterDocument": FEDERAL_REGISTER_DOCUMENT_SCHEMA, "Agency": AGENCY_SCHEMA, "Grant": GRANT_SCHEMA, # Vehicles (Awards) diff --git a/tests/test_federal_register.py b/tests/test_federal_register.py new file mode 100644 index 0000000..58d7776 --- /dev/null +++ b/tests/test_federal_register.py @@ -0,0 +1,233 @@ +"""Tests for the Federal Register documents endpoint. + +Covers the request contract for both methods (path, filters passed through under the API's own param names, the documented default shape) plus the shape schema that backs them. Requests are mocked. +""" + +from datetime import date +from unittest.mock import Mock, patch + +import pytest + +from tango import TangoClient +from tango.exceptions import ShapeValidationError +from tango.models import FederalRegisterDocument, ShapeConfig +from tango.shapes.parser import ShapeParser + +DOC_UUID = "7cf9c379-3d17-5059-a1e5-930ffd421a85" + + +def _mock(mock_request, payload=None): + response = Mock() + response.is_success = True + body = ( + payload + if payload is not None + else {"count": 0, "next": None, "previous": None, "results": []} + ) + response.json.return_value = body + response.content = b"{}" + mock_request.return_value = response + return response + + +def _call_params(mock_request) -> dict: + return mock_request.call_args.kwargs.get("params") or {} + + +def _call_url(mock_request) -> str: + args = mock_request.call_args.args + return str(args[1]) if len(args) > 1 else str(mock_request.call_args.kwargs.get("url", "")) + + +def _row(**overrides) -> dict: + row = { + "uuid": DOC_UUID, + "document_number": "2026-19694", + "publication_date": "2026-09-25", + "type": "Rule", + "subtype": None, + "title": "Annual Notices on Explosive Materials Storage Facilities", + "abstract": "The Department amends its regulations.", + "action": "Final rule.", + "agencies": [ + {"id": 268, "name": "Justice Department", "slug": "justice-department"}, + ], + "cfr_references": [{"part": "555", "title": 27, "chapter": None, "citation_url": None}], + "citation": "91 FR 51234", + "significant": True, + "comments_close_on": None, + "effective_on": "2026-10-26", + "html_url": "https://www.federalregister.gov/d/2026-19694", + "pdf_url": "https://www.govinfo.gov/content/pkg/FR-2026-09-25/pdf/2026-19694.pdf", + } + row.update(overrides) + return row + + +class TestListFederalRegisterDocuments: + @patch("tango.client.httpx.Client.request") + def test_list_path_and_filters(self, mock_request): + _mock(mock_request) + TangoClient(api_key="k").list_federal_register_documents( + search='"greenhouse gas"', + document_number="2016-31922|2016-31923", + type="Proposed Rule", + agency="EPA", + fr_agency="environmental-protection-agency", + publication_date_after="2025-01-01", + publication_date_before="2026-01-01", + effective_on_after="2025-02-01", + effective_on_before="2026-02-01", + comments_close_on_after="2025-03-01", + comments_close_on_before="2026-03-01", + comments_open=True, + cfr_title="40", + cfr_part="52", + significant=True, + rin="2060-AV16", + executive_order_number="14028", + ordering="-effective_on", + limit=10, + ) + + params = _call_params(mock_request) + assert "/api/federal_register/" in _call_url(mock_request) + assert params == { + "page": 1, + "limit": 10, + "shape": ShapeConfig.FEDERAL_REGISTER_MINIMAL, + "search": '"greenhouse gas"', + "document_number": "2016-31922|2016-31923", + "type": "Proposed Rule", + "agency": "EPA", + "fr_agency": "environmental-protection-agency", + "publication_date_after": "2025-01-01", + "publication_date_before": "2026-01-01", + "effective_on_after": "2025-02-01", + "effective_on_before": "2026-02-01", + "comments_close_on_after": "2025-03-01", + "comments_close_on_before": "2026-03-01", + "comments_open": True, + "cfr_title": "40", + "cfr_part": "52", + "significant": True, + "rin": "2060-AV16", + "executive_order_number": "14028", + "ordering": "-effective_on", + } + + @patch("tango.client.httpx.Client.request") + def test_no_filters_are_synthesized(self, mock_request): + _mock(mock_request) + TangoClient(api_key="k").list_federal_register_documents() + assert set(_call_params(mock_request)) == {"page", "limit", "shape"} + + @patch("tango.client.httpx.Client.request") + def test_false_booleans_are_sent_not_dropped(self, mock_request): + """`comments_open=False` is the closed set, not "no filter".""" + _mock(mock_request) + TangoClient(api_key="k").list_federal_register_documents( + comments_open=False, significant=False + ) + params = _call_params(mock_request) + assert params["comments_open"] is False + assert params["significant"] is False + + @patch("tango.client.httpx.Client.request") + def test_limit_is_capped_at_the_api_maximum(self, mock_request): + _mock(mock_request) + TangoClient(api_key="k").list_federal_register_documents(limit=500) + assert _call_params(mock_request)["limit"] == 100 + + @patch("tango.client.httpx.Client.request") + def test_results_are_parsed(self, mock_request): + _mock( + mock_request, + {"count": 1, "next": None, "previous": None, "results": [_row()]}, + ) + page = TangoClient(api_key="k").list_federal_register_documents() + + assert page.count == 1 + row = page.results[0] + assert row["document_number"] == "2026-19694" + assert row["publication_date"] == date(2026, 9, 25) + assert row["effective_on"] == date(2026, 10, 26) + assert row["significant"] is True + assert row["agencies"][0]["slug"] == "justice-department" + assert row["cfr_references"][0]["title"] == 27 + + +class TestGetFederalRegisterDocument: + @patch("tango.client.httpx.Client.request") + def test_get_uses_uuid_route_and_comprehensive_shape(self, mock_request): + _mock(mock_request, _row()) + TangoClient(api_key="k").get_federal_register_document(DOC_UUID) + + assert f"/api/federal_register/{DOC_UUID}/" in _call_url(mock_request) + assert _call_params(mock_request)["shape"] == ShapeConfig.FEDERAL_REGISTER_COMPREHENSIVE + + @patch("tango.client.httpx.Client.request") + def test_detail_fields_parse(self, mock_request): + _mock( + mock_request, + _row( + dates="This rule is effective October 26, 2026.", + signing_date="2026-09-19", + start_page=51234, + end_page=51252, + volume=91, + docket_ids=["Docket No. ATF-2023-0001"], + dockets=[{"id": "ATF_FRDOC_0001", "documents": []}], + regulation_id_numbers=["1140-AA51"], + topics=["Explosives", "Safety"], + correction_of=None, + corrections=[], + ), + ) + row = TangoClient(api_key="k").get_federal_register_document(DOC_UUID) + + assert row["signing_date"] == date(2026, 9, 19) + assert row["start_page"] == 51234 + assert row["volume"] == 91 + assert row["regulation_id_numbers"] == ["1140-AA51"] + assert row["topics"] == ["Explosives", "Safety"] + assert row["dockets"][0]["id"] == "ATF_FRDOC_0001" + + @patch("tango.client.httpx.Client.request") + def test_full_text_parses_when_requested(self, mock_request): + _mock(mock_request, {"uuid": DOC_UUID, "full_text": "SUMMARY: The Department..."}) + row = TangoClient(api_key="k").get_federal_register_document( + DOC_UUID, shape="uuid,full_text" + ) + assert _call_params(mock_request)["shape"] == "uuid,full_text" + assert row["full_text"] == "SUMMARY: The Department..." + + def test_no_default_shape_names_full_text(self): + for shape in ( + ShapeConfig.FEDERAL_REGISTER_MINIMAL, + ShapeConfig.FEDERAL_REGISTER_COMPREHENSIVE, + ): + assert "full_text" not in shape + + +class TestFederalRegisterShapes: + @pytest.mark.parametrize( + "shape", + [ + ShapeConfig.FEDERAL_REGISTER_MINIMAL, + ShapeConfig.FEDERAL_REGISTER_COMPREHENSIVE, + "uuid,full_text", + "uuid,regulation_id_number_info,regulations_dot_gov_info,public_inspection_pdf_url", + "uuid,toc_doc,toc_subject,page_length,disposition_notes,executive_order_notes", + ], + ) + def test_shape_validates(self, shape): + parser = ShapeParser(cache_enabled=True) + parser.validate(parser.parse(shape), FederalRegisterDocument) + + def test_filter_only_params_are_not_response_fields(self): + """`rin`, `fr_agency` and `search` are filters; the response carries `regulation_id_numbers` and `agencies` instead.""" + parser = ShapeParser(cache_enabled=True) + for field in ("rin", "fr_agency", "search"): + with pytest.raises(ShapeValidationError): + parser.validate(parser.parse(f"uuid,{field}"), FederalRegisterDocument) From 7234b7a6dae9039b2587c82a970f00df68059c34 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 28 Sep 2026 07:48:23 -0500 Subject: [PATCH 2/3] feat(shapes): add the agency filter and organization expand from API 5.3.0 `list_budget_accounts`, `list_exclusions` and `list_itdashboard_investments` take an `agency` argument for the API's organization-subtree `agency` filter. The regenerated shape overlay accepts the `organization` expand on budget accounts, exclusions, SBIR topics and SBIR solicitations, and `organization_id` on budget accounts and exclusions. Both baselines drop the gaps this closes, so the 5.3.0 contract is fully covered. The IT Dashboard tier notes now say `agency_name` is available at every plan, matching the API. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ contracts/conformance_baseline.json | 7 -- contracts/shape_coverage_baseline.json | 8 +- tango/client.py | 17 ++-- tango/shapes/generated_overlay.py | 34 ++++++++ tests/test_organization_scope.py | 107 +++++++++++++++++++++++++ 6 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 tests/test_organization_scope.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a1cf584..062dc6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `get_federal_register_document()` takes the document's `uuid`, not its `document_number`, because a document number is not unique on its own: the Federal Register reused some before 2016. Look a document up by number with `list_federal_register_documents(document_number=...)`. `full_text` is available on the detail method only when named in `shape`, and neither default shape includes it, since it can run to several MB. `ordering="rank"` requires a non-empty `search`, matching the API. - **`alerts.federal_register.match` is an alertable event type.** `create_webhook_alert(query_type="federal_register", ...)` works. +- **`agency` filter on `list_budget_accounts()`, `list_exclusions()` and `list_itdashboard_investments()`** (Tango API 5.3.0). It takes a Tango agency name, abbreviation, code or organization key (e.g. `EPA`) and matches the whole organization subtree, so a department includes its sub-agencies; OR several with `|`. On exclusions it scopes by the excluding agency. On IT Dashboard it is available at every plan. +- **The `organization` expand is accepted on budget accounts, exclusions, SBIR topics and SBIR solicitations**, along with the `organization_id` field on budget accounts and exclusions. `organization(...)` carries `agency_code`, `agency_name`, `department_code`, `department_name`, `office_code`, `office_name` and `organization_id`. The SDK rejected these shapes client-side before. The default shapes are unchanged. + +### Fixed +- **`list_itdashboard_investments()` documents the API's current filter tiers.** `agency_name` is available at every plan, not Business+ only. ## [1.7.0] - 2026-09-23 diff --git a/contracts/conformance_baseline.json b/contracts/conformance_baseline.json index 702bcf9..a0252bb 100644 --- a/contracts/conformance_baseline.json +++ b/contracts/conformance_baseline.json @@ -23,7 +23,6 @@ "key" ], "budget/accounts": [ - "agency", "agency_code__in", "bea_category__in", "bureau_name__icontains", @@ -31,12 +30,6 @@ "federal_account_symbol__in", "fiscal_year__in", "subfunction_code__in" - ], - "exclusions": [ - "agency" - ], - "itdashboard": [ - "agency" ] } } diff --git a/contracts/shape_coverage_baseline.json b/contracts/shape_coverage_baseline.json index 9e4a5ca..78b14b9 100644 --- a/contracts/shape_coverage_baseline.json +++ b/contracts/shape_coverage_baseline.json @@ -1,13 +1,7 @@ { "description": "Known reverse shape-coverage gaps (Tango exposes, SDK schema lacks), accepted as a tracked backlog. check_shape_coverage.py fails only on gaps NOT listed here. Burn down and regenerate with --update-baseline.", - "count": 21, + "count": 15, "known_gaps": [ - "missing_expand|budget/accounts|(root)|organization", - "missing_expand|exclusions|(root)|organization", - "missing_expand|sbir/solicitations|(root)|organization", - "missing_expand|sbir/topics|(root)|organization", - "missing_field|budget/accounts|(root)|organization_id", - "missing_field|exclusions|(root)|organization_id", "unmapped_resource|agencies_contracts_awarding|(root)|None", "unmapped_resource|agencies_contracts_funding|(root)|None", "unmapped_resource|contracts_subawards|(root)|None", diff --git a/tango/client.py b/tango/client.py index 994177c..12a3cd1 100644 --- a/tango/client.py +++ b/tango/client.py @@ -1593,6 +1593,7 @@ def list_itdashboard_investments( flat_lists: bool = False, joiner: str = ".", search: str | None = None, + agency: str | None = None, agency_code: int | None = None, agency_name: str | None = None, type_of_investment: str | None = None, @@ -1607,13 +1608,12 @@ def list_itdashboard_investments( Filters are tier-gated by the API: - - **Free**: ``search`` (full-text across UII, title, description, agency, - bureau) and ``previous_uii`` — following a retired identifier forward to - whatever superseded it is recovery, not analysis, so it is ungated + - **Free**: ``search`` (full-text across UII, title, description, agency, bureau), ``agency_name`` (text), ``agency`` and ``previous_uii`` — following a retired identifier forward to whatever superseded it is recovery, not analysis, so it is ungated - **Pro**: ``agency_code``, ``type_of_investment``, ``updated_time_after`` / ``updated_time_before`` - - **Business+**: ``agency_name`` (text), ``cio_rating``, - ``cio_rating_max``, ``performance_risk`` + - **Business+**: ``cio_rating``, ``cio_rating_max``, ``performance_risk`` + + ``agency`` takes a Tango agency name, abbreviation, code or organization key, e.g. ``DOT``, and matches the whole organization subtree, so a department includes its sub-agencies. OR several with ``|``. Hitting a gated filter on a lower tier returns a 403 with upgrade info. @@ -1633,6 +1633,7 @@ def list_itdashboard_investments( params["flat_lists"] = "true" for k, val in ( ("search", search), + ("agency", agency), ("agency_code", agency_code), ("agency_name", agency_name), ("type_of_investment", type_of_investment), @@ -3374,6 +3375,7 @@ def list_exclusions( classification_type: str | None = None, exclusion_type: str | None = None, exclusion_program: str | None = None, + agency: str | None = None, excluding_agency_code: str | None = None, excluding_agency_name: str | None = None, active: bool | None = None, @@ -3405,6 +3407,7 @@ def list_exclusions( classification_type: Filter by classification (Firm, Individual, Vessel, ...) exclusion_type: Filter by exclusion type exclusion_program: Filter by exclusion program + agency: Excluding agency as a Tango organization. A Tango agency name, abbreviation, code or organization key, e.g. ``EPA``. Matches the whole organization subtree, so a department includes its sub-agencies. OR several with ``|`` excluding_agency_code: Filter by excluding agency code excluding_agency_name: Filter by excluding agency name active: True returns only records currently in effect. @@ -3440,6 +3443,7 @@ def list_exclusions( ("classification_type", classification_type), ("exclusion_type", exclusion_type), ("exclusion_program", exclusion_program), + ("agency", agency), ("excluding_agency_code", excluding_agency_code), ("excluding_agency_name", excluding_agency_name), ("active", active), @@ -4210,6 +4214,7 @@ def list_budget_accounts( fiscal_year: int | None = None, fiscal_year_gte: int | None = None, fiscal_year_lte: int | None = None, + agency: str | None = None, agency_code: str | None = None, bureau_name: str | None = None, account_title: str | None = None, @@ -4314,6 +4319,7 @@ def list_budget_accounts( fiscal_year: Fiscal year (exact). fiscal_year_gte: Fiscal year >=. fiscal_year_lte: Fiscal year <=. + agency: A Tango agency name, abbreviation, code or organization key, e.g. ``EPA``. Matches the whole organization subtree, so a department includes its sub-agencies. OR several with ``|``. agency_code: Agency code (exact). bureau_name: Bureau name (exact). account_title: Account title (icontains). @@ -4378,6 +4384,7 @@ def list_budget_accounts( ("fiscal_year", fiscal_year), ("fiscal_year__gte", fiscal_year_gte), ("fiscal_year__lte", fiscal_year_lte), + ("agency", agency), ("agency_code", agency_code), ("bureau_name", bureau_name), ("account_title__icontains", account_title), diff --git a/tango/shapes/generated_overlay.py b/tango/shapes/generated_overlay.py index ac01621..d4d96be 100644 --- a/tango/shapes/generated_overlay.py +++ b/tango/shapes/generated_overlay.py @@ -1382,6 +1382,16 @@ "on_off_budget": FieldSchema( name="on_off_budget", type=str, is_optional=True, is_list=False ), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="AwardingOffice", + ), + "organization_id": FieldSchema( + name="organization_id", type=str, is_optional=True, is_list=False + ), "outlayed_to_obligated_pct": FieldSchema( name="outlayed_to_obligated_pct", type=str, is_optional=True, is_list=False ), @@ -1911,6 +1921,16 @@ name="more_locations", type=str, is_optional=True, is_list=False ), "npi": FieldSchema(name="npi", type=str, is_optional=True, is_list=False), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="AwardingOffice", + ), + "organization_id": FieldSchema( + name="organization_id", type=str, is_optional=True, is_list=False + ), "prefix": FieldSchema(name="prefix", type=str, is_optional=True, is_list=False), "primary_address": FieldSchema( name="primary_address", type=str, is_optional=True, is_list=False @@ -2683,6 +2703,13 @@ name="documents", type=dict, is_optional=True, is_list=True, nested_model="Documents" ), "end_date": FieldSchema(name="end_date", type=date, is_optional=True, is_list=False), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="AwardingOffice", + ), "out_of_cycle": FieldSchema( name="out_of_cycle", type=bool, is_optional=True, is_list=False ), @@ -2736,6 +2763,13 @@ is_list=False, nested_model="Opportunity2", ), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="AwardingOffice", + ), "release_date": FieldSchema( name="release_date", type=date, is_optional=True, is_list=False ), diff --git a/tests/test_organization_scope.py b/tests/test_organization_scope.py new file mode 100644 index 0000000..2f539f6 --- /dev/null +++ b/tests/test_organization_scope.py @@ -0,0 +1,107 @@ +"""Tests for the organization-scoped `agency` filter and the `organization` expand added in API 5.3.0.""" + +from unittest.mock import Mock, patch + +import pytest + +from tango import TangoClient +from tango.models import BudgetAccount, Exclusion, SbirSolicitation, SbirTopic +from tango.shapes.parser import ShapeParser + +ORGANIZATION = { + "organization_id": "0f6c1d9e-5b7a-4c2e-9a41-3e8d2b7c6a10", + "department_code": "068", + "department_name": "Environmental Protection Agency", + "agency_code": "6800", + "agency_name": "Environmental Protection Agency", + "office_code": None, + "office_name": None, +} + +ORGANIZATION_SHAPE = ( + "organization(agency_code,agency_name,department_code,department_name," + "office_code,office_name,organization_id)" +) + + +def _mock(mock_request, payload=None): + response = Mock() + response.is_success = True + response.json.return_value = ( + payload + if payload is not None + else {"count": 0, "next": None, "previous": None, "results": []} + ) + response.content = b"{}" + mock_request.return_value = response + return response + + +def _call(mock_request) -> tuple[str, dict]: + args = mock_request.call_args.args + url = str(args[1]) if len(args) > 1 else str(mock_request.call_args.kwargs.get("url", "")) + return url, mock_request.call_args.kwargs.get("params") or {} + + +class TestAgencyFilter: + @pytest.mark.parametrize( + ("method", "path"), + [ + ("list_budget_accounts", "/api/budget/accounts/"), + ("list_exclusions", "/api/exclusions/"), + ("list_itdashboard_investments", "/api/itdashboard/"), + ], + ) + @patch("tango.client.httpx.Client.request") + def test_agency_is_sent_as_the_api_param(self, mock_request, method, path): + _mock(mock_request) + getattr(TangoClient(api_key="k"), method)(agency="EPA|DOT") + url, params = _call(mock_request) + assert path in url + assert params["agency"] == "EPA|DOT" + + @pytest.mark.parametrize( + "method", ["list_budget_accounts", "list_exclusions", "list_itdashboard_investments"] + ) + @patch("tango.client.httpx.Client.request") + def test_agency_is_omitted_when_unset(self, mock_request, method): + _mock(mock_request) + getattr(TangoClient(api_key="k"), method)() + assert "agency" not in _call(mock_request)[1] + + +class TestOrganizationExpand: + @pytest.mark.parametrize("model", [BudgetAccount, Exclusion, SbirTopic, SbirSolicitation]) + def test_organization_expand_validates(self, model): + parser = ShapeParser(cache_enabled=True) + parser.validate(parser.parse(ORGANIZATION_SHAPE), model) + parser.validate(parser.parse("organization(*)"), model) + + @pytest.mark.parametrize("model", [BudgetAccount, Exclusion]) + def test_organization_id_validates(self, model): + parser = ShapeParser(cache_enabled=True) + parser.validate(parser.parse("organization_id"), model) + + @patch("tango.client.httpx.Client.request") + def test_exclusion_row_parses_the_organization(self, mock_request): + _mock( + mock_request, + { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "organization_id": ORGANIZATION["organization_id"], + "organization": ORGANIZATION, + } + ], + }, + ) + page = TangoClient(api_key="k").list_exclusions( + shape=f"organization_id,{ORGANIZATION_SHAPE}" + ) + row = page.results[0] + assert row["organization_id"] == ORGANIZATION["organization_id"] + assert row["organization"]["agency_code"] == "6800" + assert row["organization"]["office_code"] is None From 025b516d35806ad85880352bf2f217463cdaf5b9 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 28 Sep 2026 07:59:41 -0500 Subject: [PATCH 3/3] fix(client): append the new agency filter after existing parameters Inserting `agency` mid-signature shifted every later positional argument in `list_budget_accounts`, `list_exclusions` and `list_itdashboard_investments`, so an existing positional call would silently send a different filter. Moving it to the end keeps every existing call's meaning unchanged. Co-Authored-By: Claude Opus 5.5 --- tango/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tango/client.py b/tango/client.py index 12a3cd1..0c8a76f 100644 --- a/tango/client.py +++ b/tango/client.py @@ -1593,7 +1593,6 @@ def list_itdashboard_investments( flat_lists: bool = False, joiner: str = ".", search: str | None = None, - agency: str | None = None, agency_code: int | None = None, agency_name: str | None = None, type_of_investment: str | None = None, @@ -1603,6 +1602,7 @@ def list_itdashboard_investments( cio_rating_max: int | None = None, performance_risk: bool | None = None, previous_uii: str | None = None, + agency: str | None = None, ) -> PaginatedResponse: """List federal IT investments from the IT Dashboard (`/api/itdashboard/`). @@ -3375,7 +3375,6 @@ def list_exclusions( classification_type: str | None = None, exclusion_type: str | None = None, exclusion_program: str | None = None, - agency: str | None = None, excluding_agency_code: str | None = None, excluding_agency_name: str | None = None, active: bool | None = None, @@ -3388,6 +3387,7 @@ def list_exclusions( update_date_before: str | None = None, search: str | None = None, ordering: str | None = None, + agency: str | None = None, ) -> PaginatedResponse: """ List SAM.gov exclusion (debarment) records. @@ -4214,7 +4214,6 @@ def list_budget_accounts( fiscal_year: int | None = None, fiscal_year_gte: int | None = None, fiscal_year_lte: int | None = None, - agency: str | None = None, agency_code: str | None = None, bureau_name: str | None = None, account_title: str | None = None, @@ -4302,6 +4301,7 @@ def list_budget_accounts( actual_vs_requested_contract_capped_lte: float | None = None, search: str | None = None, ordering: str | None = None, + agency: str | None = None, ) -> PaginatedResponse: """List budget accounts (`/api/budget/accounts/`).