diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab914a..9fce69f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Award announcements** (Tango API 5.14.0). Two methods over the new `/api/news/award_announcements/` resource: `list_award_announcements()` and `get_award_announcement()` (by `uuid`), plus `AwardAnnouncement` and `AwardAnnouncementAwardee` schemas and the `AWARD_ANNOUNCEMENTS_MINIMAL` and `AWARD_ANNOUNCEMENTS_COMPREHENSIVE` shapes, including the `awardees` and `organization` expands. Each row names its `source`: `official_release` rows are the Department of War's daily contracts release as published, and `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text. Filter by text, source, agency, dates, amount, contract number, awardee and flags, and order by `published_date`, `amount` or `rank`. Withdrawn announcements are hidden from lists unless `include_withdrawn=True`. +- **`alerts.award_announcement.match` is an alertable event type** (Tango API 5.14.0). `create_webhook_alert(query_type="award_announcement", ...)` works. + +### Changed + +- Re-vendored `contracts/filter_shape_contract.json` (Tango API 5.14.0). + ## [1.21.0] - 2026-10-29 ### Added diff --git a/README.md b/README.md index d8c0ebc..9408819 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A modern Python SDK for the [Tango API](https://tango.makegov.com) by MakeGov, f - **Dynamic Response Shaping** - Request only the fields you need, reducing payload sizes by 60-80% - **Full Type Safety** - Runtime-generated TypedDict types with accurate type hints for IDE autocomplete -- **Comprehensive API Coverage** - All major Tango API endpoints (contracts, IDVs, OTAs, entities, forecasts, opportunities, notices, grants, protests, webhooks, and more) +- **Comprehensive API Coverage** - All major Tango API endpoints (contracts, IDVs, OTAs, entities, forecasts, opportunities, notices, grants, protests, award announcements, webhooks, and more) - **Flexible Data Access** - Dictionary-based response objects with validation - **Modern Python** - Built for Python 3.12+ using modern async-ready patterns - **Production-Ready** - Comprehensive test suite with VCR.py-based integration tests @@ -263,6 +263,17 @@ protests = client.list_protests(source_system="gao", outcome="Sustained", limit= protest = client.get_protest("CASE_UUID") ``` +### Award Announcements + +```python +announcements = client.list_award_announcements(source="official_release", amount_min=100000000, limit=25) +announcement = client.get_award_announcement("ANNOUNCEMENT_UUID") +``` + +Each row names its `source`. +`official_release` rows are the Department of War's daily contracts release as published. +`makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text. + ### GSA eBuy ```python diff --git a/contracts/filter_shape_contract.json b/contracts/filter_shape_contract.json index 1217b54..6c5cb49 100644 --- a/contracts/filter_shape_contract.json +++ b/contracts/filter_shape_contract.json @@ -1,6 +1,6 @@ { "meta": { - "api_version": "5.13.0", + "api_version": "5.14.0", "description": "Canonical API filter/shape contract. Downstream consumers (SDK, MCP) should validate their conformance against this manifest.", "schema_version": 2 }, @@ -9039,64 +9039,6 @@ }, "swagger_params": [] }, - "events": { - "basename": "g2x-events", - "prefix": "events", - "resource_key": "events", - "runtime": { - "filter_params": [ - "is_free", - "is_virtual", - "organizer", - "search", - "source", - "start_date_after", - "start_date_before", - "state" - ], - "filter_params_detail": { - "is_free": { - "type": "boolean" - }, - "is_virtual": { - "type": "boolean" - }, - "organizer": { - "lookup": "icontains", - "type": "string" - }, - "search": { - "type": "string" - }, - "source": { - "type": "string" - }, - "start_date_after": { - "lookup": "gte", - "type": "date" - }, - "start_date_before": { - "lookup": "lte", - "type": "date" - }, - "state": { - "lookup": "iexact", - "type": "string" - } - }, - "ordering_aliases": [], - "ordering_fields": [], - "pagination": { - "max_page_size": 100 - }, - "shape": null, - "shape_flat_paths": [], - "shape_gated_by_level": {}, - "shape_supported": false, - "shape_tier_required": null - }, - "swagger_params": [] - }, "exclusions": { "basename": "exclusion", "prefix": "exclusions", @@ -12717,51 +12659,209 @@ "show_shapes" ] }, - "news": { - "basename": "g2x-news", - "prefix": "news", - "resource_key": "news", + "news/award_announcements": { + "basename": "awardannouncement", + "prefix": "news/award_announcements", + "resource_key": "news/award_announcements", "runtime": { "filter_params": [ - "published_after", - "published_before", + "action_type", + "agency", + "amount_max", + "amount_min", + "announcement_type", + "award_date_after", + "award_date_before", + "awardee", + "civilian", + "component", + "include_withdrawn", + "is_modification", + "is_multiple_award", + "piid", + "published_date_after", + "published_date_before", "search", + "small_business", "source", - "tag", - "vertical" + "uei" ], "filter_params_detail": { - "published_after": { + "action_type": { + "type": "string" + }, + "agency": { + "type": "string" + }, + "amount_max": { + "lookup": "lte", + "type": "number" + }, + "amount_min": { "lookup": "gte", - "type": "datetime" + "type": "number" + }, + "announcement_type": { + "type": "string" + }, + "award_date_after": { + "lookup": "gte", + "type": "date" }, - "published_before": { + "award_date_before": { "lookup": "lte", - "type": "datetime" + "type": "date" }, - "search": { + "awardee": { "type": "string" }, - "source": { + "civilian": { + "type": "boolean" + }, + "component": { "type": "string" }, - "tag": { + "include_withdrawn": { + "type": "boolean" + }, + "is_modification": { + "type": "boolean" + }, + "is_multiple_award": { + "type": "boolean" + }, + "piid": { "type": "string" }, - "vertical": { - "lookup": "iexact", + "published_date_after": { + "lookup": "gte", + "type": "date" + }, + "published_date_before": { + "lookup": "lte", + "type": "date" + }, + "search": { + "type": "string" + }, + "small_business": { + "type": "boolean" + }, + "source": { + "choices": [ + "makegov_generated", + "official_release" + ], + "choices_count": 2, + "type": "choice" + }, + "uei": { "type": "string" } }, "ordering_aliases": [], - "ordering_fields": [], + "ordering_fields": [ + "amount", + "published_date", + "rank" + ], "pagination": { "max_page_size": 100 }, - "shape": null, - "shape_flat_paths": [], + "shape": { + "expands": { + "awardees": { + "expands": {}, + "fields": [ + "location", + "name", + "small_business", + "uei" + ] + }, + "organization": { + "expands": {}, + "fields": [ + "agency_code", + "agency_name", + "department_code", + "department_name", + "office_code", + "office_name", + "organization_id" + ] + } + }, + "fields": [ + "action_type", + "amount", + "announcement_type", + "award_date", + "award_kind", + "award_uuids", + "awardee_count", + "civilian", + "component", + "contracting_activity", + "corrections", + "is_modification", + "is_multiple_award", + "is_withdrawn", + "obligated_at_award", + "piids", + "published_date", + "source", + "source_name", + "source_record_id", + "source_url", + "text", + "title", + "uuid", + "value_kind" + ] + }, + "shape_flat_paths": [ + "action_type", + "amount", + "announcement_type", + "award_date", + "award_kind", + "award_uuids", + "awardee_count", + "awardees", + "awardees.location", + "awardees.name", + "awardees.small_business", + "awardees.uei", + "civilian", + "component", + "contracting_activity", + "corrections", + "is_modification", + "is_multiple_award", + "is_withdrawn", + "obligated_at_award", + "organization", + "organization.agency_code", + "organization.agency_name", + "organization.department_code", + "organization.department_name", + "organization.office_code", + "organization.office_name", + "organization.organization_id", + "piids", + "published_date", + "source", + "source_name", + "source_record_id", + "source_url", + "text", + "title", + "uuid", + "value_kind" + ], "shape_gated_by_level": {}, - "shape_supported": false, + "shape_supported": true, "shape_tier_required": null }, "swagger_params": [] diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 7e216cb..abb3fee 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -24,6 +24,7 @@ Complete reference for all Tango Python SDK methods and functionality. - [Protests](#protests) - [Contract Appeals](#contract-appeals) - [Federal Register](#federal-register) +- [Award Announcements](#award-announcements) - [GSA eBuy](#gsa-ebuy) - [Budget](#budget) - [Business Types](#business-types) @@ -1500,6 +1501,104 @@ document = client.get_federal_register_document( --- +## Award Announcements + +Federal award announcements (Tango API 5.14.0), from two sources named on every row in `source`. + +- `official_release` rows are the awards of $7.5 million or more in the Department of War's daily contracts release, with the text as published. +- `makegov_generated` rows cover new civilian IDVs of $25 million or more and every new OTA and OT IDV. MakeGov writes them from FPDS data with a language model, and they are not government text. + +A generated announcement is dropped when an official release covers the same award. + +### list_award_announcements() + +List announcements with filtering and shaping. + +```python +announcements = client.list_award_announcements( + page=1, + limit=25, + shape=ShapeConfig.AWARD_ANNOUNCEMENTS_MINIMAL, + # Filter parameters (all optional) + search=None, + source=None, + agency=None, + component=None, + published_date_after=None, + published_date_before=None, + award_date_after=None, + award_date_before=None, + amount_min=None, + amount_max=None, + piid=None, + awardee=None, + uei=None, + action_type=None, + announcement_type=None, + civilian=None, + small_business=None, + is_modification=None, + is_multiple_award=None, + include_withdrawn=None, + ordering=None, +) +``` + +**Filter Parameters:** +- `search` - Full-text search over the title and text; a match in the title ranks above a match in the text. Wrap in double quotes for a phrase +- `source` - `"official_release"` or `"makegov_generated"` +- `agency` - A Tango agency name, abbreviation, code or organization key for the awarding organization, e.g. `"Navy"`; OR several with `|` +- `component` - DoD component as printed in the official release's section header, e.g. `"NAVY"`. Case-insensitive exact match; OR several with `|`. Generated announcements have no component +- `published_date_after` / `published_date_before` - Publication date range (`YYYY-MM-DD`) +- `award_date_after` / `award_date_before` - Award date range (`YYYY-MM-DD`) +- `amount_min` / `amount_max` - Amount range in US dollars +- `piid` - A contract number named in the announcement, e.g. `"N00019-25-C-0070"` or `"N0001925C0070"`. Dashes and case are ignored; OR several with `|` +- `awardee` - Case-insensitive substring of an awardee name, at least 3 characters +- `uei` - An exact awardee UEI. Official releases print no UEI, so this matches only where one was resolved +- `action_type` - Action type, e.g. `"new_award"`; OR several with `|` +- `announcement_type` - Announcement type; OR several with `|` +- `civilian` - Announces a civilian award +- `small_business` - `True` keeps announcements with at least one small-business awardee, `False` those whose awardees are stated not to be small. Generated announcements state neither, so they match neither +- `is_modification` - Announces a modification to an existing contract +- `is_multiple_award` - Names more than one awardee +- `include_withdrawn` - Include announcements DoD later withdrew, which are hidden by default +- `ordering` - `published_date`, `amount` or `rank`, with a leading `-` for descending. Defaults to `-published_date`, or to `-rank` under `search`. `rank` requires a non-empty `search` + +**Returns:** [PaginatedResponse](#paginatedresponse) with announcement dictionaries + +**Example:** +```python +announcements = client.list_award_announcements( + source="official_release", + agency="Navy", + amount_min=100000000, + ordering="-amount", + limit=25, +) + +for item in announcements.results: + print(f"{item['published_date']} ${item['amount']:,}: {item['title']}") +``` + +### get_award_announcement() + +Get a single announcement by `uuid`. + +```python +announcement = client.get_award_announcement( + "ANNOUNCEMENT_UUID", + shape=ShapeConfig.AWARD_ANNOUNCEMENTS_COMPREHENSIVE, +) +``` + +**Notes:** +- A withdrawn announcement stays retrievable here, with `is_withdrawn` set, even though lists hide it by default. +- `text` is on the detail default but not the list default, because a release paragraph can run past 16,000 characters. Name it in `shape` to get it on a list. +- `awardees(...)` carries `name`, `uei`, `location` and `small_business`. `organization(...)` carries `organization_id`, `office_code`, `office_name`, `agency_code`, `agency_name`, `department_code` and `department_name`. +- `amount` is the announced dollar value, and `value_kind` says what kind of value it is, e.g. `ceiling` or `face_value`. + +--- + ## 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. @@ -2675,6 +2774,8 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE) | `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`) | +| `AWARD_ANNOUNCEMENTS_MINIMAL` | `list_award_announcements` | uuid, source, source_name, source_url, published_date, award_date, title, amount, value_kind, action_type, announcement_type, awardee_count, piids, is_modification, is_multiple_award, is_withdrawn | +| `AWARD_ANNOUNCEMENTS_COMPREHENSIVE` | `get_award_announcement` | The list fields plus source_record_id, text, obligated_at_award, award_kind, award_uuids, civilian, component, contracting_activity, corrections, and the `awardees` and `organization` expands | | `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 (without `extracted_text`, which must be named) | | `BUDGET_ACCOUNTS_MINIMAL` | `list_budget_accounts`, `get_budget_account` | id, federal_account_symbol, fiscal_year, data_through_period, 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 | diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index b4dba3e..e0e0bb4 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -91,6 +91,7 @@ tango webhooks list-event-types # alerts.contract_appeal.match CBCA/ASBCA appeal decision matched a saved alert # alerts.federal_register.match Federal Register document matched a saved alert # alerts.ebuy_request.match GSA eBuy request matched a saved alert +# alerts.award_announcement.match Award announcement 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 a440414..c2d2261 100644 --- a/scripts/check_filter_shape_conformance.py +++ b/scripts/check_filter_shape_conformance.py @@ -80,16 +80,13 @@ "contract_appeals": "list_contract_appeals", "federal_register": "list_federal_register_documents", "ebuy/requests": "list_ebuy_requests", + "news/award_announcements": "list_award_announcements", "offices": "list_offices", "psc": "list_psc", "mas_sins": "list_mas_sins", "departments": "list_departments", "business_types": "list_business_types", "assistance_listings": "list_assistance_listings", - # Genuinely absent from the SDK. Both are content endpoints with no shaping - # and no list method; `list_webhook_event_types` is unrelated to `events`. - "events": None, - "news": None, } # Params the API genuinely accepts but the contract omits, so they would read as diff --git a/scripts/check_shape_coverage.py b/scripts/check_shape_coverage.py index 6c0e891..93ea4fb 100644 --- a/scripts/check_shape_coverage.py +++ b/scripts/check_shape_coverage.py @@ -71,6 +71,7 @@ "contract_appeals": "ContractAppeal", "federal_register": "FederalRegisterDocument", "ebuy/requests": "EbuyRequest", + "news/award_announcements": "AwardAnnouncement", "offices": "Office", "assistance_listings": "AssistanceListing", "business_types": "BusinessType", @@ -85,8 +86,6 @@ "sled/opportunities": "SledOpportunity", "sled/forecasts": "SledForecast", "mas_sins": "MasSin", - "events": "Event", - "news": "News", } diff --git a/tango/__init__.py b/tango/__init__.py index 41f1b14..f9b37ae 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -10,6 +10,8 @@ TangoValidationError, ) from .models import ( + AwardAnnouncement, + AwardAnnouncementAwardee, BudgetAccount, BudgetAccountAnomalySource, BudgetAccountAnomalySourceRow, @@ -78,6 +80,8 @@ "BudgetAccountAnomalySource", "BudgetAccountAnomalySourceRow", "BudgetAccountSourceAnomaly", + "AwardAnnouncement", + "AwardAnnouncementAwardee", "ContractAppeal", "FederalRegisterDocument", "EbuyAccess", diff --git a/tango/client.py b/tango/client.py index ee0997c..dcc1984 100644 --- a/tango/client.py +++ b/tango/client.py @@ -22,6 +22,7 @@ OTA, OTIDV, Agency, + AwardAnnouncement, BudgetAccount, BusinessType, Contract, @@ -3076,6 +3077,161 @@ def get_federal_register_document( data, shape, FederalRegisterDocument, flat, flat_lists ) + # ============================================================================ + # Award announcements + # ============================================================================ + + def list_award_announcements( + self, + page: int = 1, + limit: int = 25, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + search: str | None = None, + source: str | None = None, + agency: str | None = None, + component: str | None = None, + published_date_after: str | None = None, + published_date_before: str | None = None, + award_date_after: str | None = None, + award_date_before: str | None = None, + amount_min: int | float | str | None = None, + amount_max: int | float | str | None = None, + piid: str | None = None, + awardee: str | None = None, + uei: str | None = None, + action_type: str | None = None, + announcement_type: str | None = None, + civilian: bool | None = None, + small_business: bool | None = None, + is_modification: bool | None = None, + is_multiple_award: bool | None = None, + include_withdrawn: bool | None = None, + ordering: str | None = None, + ) -> PaginatedResponse: + """ + List federal award announcements (Tango API 5.14.0). + + Announcements come from two sources, named on every row in ``source``. + ``official_release`` rows are the awards of $7.5 million or more in the Department of War's daily contracts release, with the text as published. + ``makegov_generated`` rows cover new civilian IDVs of $25 million or more and every new OTA and OT IDV: MakeGov writes them from FPDS data with a language model, and they are not government text. + + A generated announcement is dropped when an official release covers the same award. + Announcements DoD later withdrew are hidden unless ``include_withdrawn=True``. + + Args: + page: Page number + limit: Results per page (max 100) + shape: Response shape string (defaults to minimal shape, which leaves out ``text``) + flat: If True, flatten nested objects in shaped response + flat_lists: If True, flatten arrays using indexed keys + search: Full-text search over the title and text; a match in the title ranks above a match in the text. Wrap in double quotes for a phrase + source: ``official_release`` or ``makegov_generated`` + agency: A Tango agency name, abbreviation, code or organization key for the awarding organization, e.g. ``Navy``. OR several with ``|`` + component: DoD component as printed in the official release's section header, e.g. ``NAVY``. Case-insensitive exact match. OR several with ``|``. Generated announcements have no component + published_date_after: Published on or after (YYYY-MM-DD) + published_date_before: Published on or before (YYYY-MM-DD) + award_date_after: Awarded on or after (YYYY-MM-DD) + award_date_before: Awarded on or before (YYYY-MM-DD) + amount_min: Amount at least this many US dollars + amount_max: Amount at most this many US dollars + piid: A contract number named in the announcement, e.g. ``N00019-25-C-0070`` or ``N0001925C0070``. Dashes and case are ignored. OR several with ``|`` + awardee: Case-insensitive substring of an awardee name, at least 3 characters + uei: An exact awardee UEI. Official releases print no UEI, so this matches only where one was resolved + action_type: Action type, e.g. ``new_award``. OR several with ``|`` + announcement_type: Announcement type. OR several with ``|`` + civilian: Announces a civilian award + small_business: ``True`` keeps announcements with at least one small-business awardee, ``False`` those whose awardees are stated not to be small. Generated announcements state neither, so they match neither + is_modification: Announces a modification to an existing contract + is_multiple_award: Names more than one awardee + include_withdrawn: Include announcements DoD later withdrew + ordering: Sort field — ``published_date``, ``amount`` or ``rank``, with a leading ``-`` for descending. Defaults to ``-published_date``, or to ``-rank`` under ``search``. ``rank`` requires a non-empty ``search`` + """ + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + + if shape is None: + shape = ShapeConfig.AWARD_ANNOUNCEMENTS_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if flat_lists: + params["flat_lists"] = "true" + + for key, val in ( + ("search", search), + ("source", source), + ("agency", agency), + ("component", component), + ("published_date_after", published_date_after), + ("published_date_before", published_date_before), + ("award_date_after", award_date_after), + ("award_date_before", award_date_before), + ("amount_min", amount_min), + ("amount_max", amount_max), + ("piid", piid), + ("awardee", awardee), + ("uei", uei), + ("action_type", action_type), + ("announcement_type", announcement_type), + ("civilian", civilian), + ("small_business", small_business), + ("is_modification", is_modification), + ("is_multiple_award", is_multiple_award), + ("include_withdrawn", include_withdrawn), + ("ordering", ordering), + ): + if val is not None: + params[key] = val + + data = self._get("/api/news/award_announcements/", params) + + results = [ + self._parse_response_with_shape(item, shape, AwardAnnouncement, 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_award_announcement( + self, + announcement_uuid: str, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + ) -> Any: + """ + Get a single award announcement by uuid (Tango API 5.14.0). + + A withdrawn announcement stays retrievable here, with ``is_withdrawn`` set, even though lists hide it by default. + Rows with ``source="makegov_generated"`` are written by MakeGov from FPDS data with a language model and are not government text. + + Args: + announcement_uuid: Announcement UUID + shape: Response shape string (defaults to the comprehensive shape) + 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.AWARD_ANNOUNCEMENTS_COMPREHENSIVE + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if flat_lists: + params["flat_lists"] = "true" + + data = self._get(f"/api/news/award_announcements/{announcement_uuid}/", params) + return self._parse_response_with_shape(data, shape, AwardAnnouncement, flat, flat_lists) + # ============================================================================ # GSA eBuy requests # ============================================================================ @@ -5395,7 +5551,8 @@ def create_webhook_alert( query_type: One of ``opportunity``, ``contract``, ``idv``, ``ota``, ``otidv``, ``entity``, ``grant``, ``forecast``, ``exclusion``, ``dibbs_rfq``, ``dibbs_rfp``, ``sled_opportunity``, - ``contract_appeal``, ``federal_register`` or ``ebuy_request``. :meth:`list_webhook_event_types` is the + ``contract_appeal``, ``federal_register``, ``ebuy_request`` or + ``award_announcement``. :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 47ea496..ca0bf1b 100644 --- a/tango/models.py +++ b/tango/models.py @@ -1372,6 +1372,63 @@ class FederalRegisterDocument: full_text: str | None = None +class AwardAnnouncementAwardee: + """Schema definition for one entry in an award announcement's ``awardees`` (not used for instances). + + ``uei`` is set only where one was resolved: official releases print none. + ``small_business`` is never set on a generated announcement. + """ + + name: str | None = None + uei: str | None = None + location: str | None = None + small_business: bool | None = None + + +class AwardAnnouncement: + """Schema definition for AwardAnnouncement (not used for instances). + + Federal award announcements at ``/api/news/award_announcements/`` (Tango API 5.14.0), from two sources named on every row in ``source``. + Shape-on-demand, so every field is optional. + + ``official_release`` rows are the awards of $7.5 million or more in the Department of War's daily contracts release, with the text as published. + ``makegov_generated`` rows cover new civilian IDVs of $25 million or more and every new OTA and OT IDV. + MakeGov writes them from FPDS data with a language model, and they are not government text. + + ``text`` is left out of the list default because a release paragraph can run past 16,000 characters; name it in ``shape`` to get it on a list. + ``amount`` is the announced dollar value and ``value_kind`` says what kind of value it is, e.g. ``ceiling`` or ``face_value``. + ``corrections`` is the history of corrections applied to an official paragraph, in publish order, as DoD published them. + """ + + uuid: str | None = None + source: str | None = None + source_name: str | None = None + source_url: str | None = None + source_record_id: str | None = None + published_date: date | None = None + award_date: date | None = None + title: str | None = None + text: str | None = None + amount: Decimal | None = None + value_kind: str | None = None + obligated_at_award: Decimal | None = None + action_type: str | None = None + announcement_type: str | None = None + award_kind: str | None = None + awardee_count: int | None = None + piids: list[str] | None = None + award_uuids: list[str] | None = None + civilian: bool | None = None + component: str | None = None + contracting_activity: str | None = None + is_modification: bool | None = None + is_multiple_award: bool | None = None + corrections: list[dict[str, Any]] | None = None + is_withdrawn: bool | None = None + awardees: list[AwardAnnouncementAwardee] | None = None + organization: dict[str, Any] | None = None + + class EbuyAttachment: """Schema definition for one entry in an eBuy request's ``attachments`` (not used for instances). @@ -1669,6 +1726,22 @@ class ShapeConfig: "comment_url,regulations_dot_gov_url,raw_text_url,body_html_url" ) + # Default for list_award_announcements(). Mirrors the API's own list default. `text` is left out on purpose: a release paragraph can run past 16,000 characters. + AWARD_ANNOUNCEMENTS_MINIMAL: Final = ( + "uuid,source,source_name,source_url,published_date,award_date,title,amount," + "value_kind,action_type,announcement_type,awardee_count,piids,is_modification," + "is_multiple_award,is_withdrawn,awardees(*)" + ) + + # Default for get_award_announcement(). Mirrors the API's own retrieve default. + AWARD_ANNOUNCEMENTS_COMPREHENSIVE: Final = ( + "uuid,source,source_name,source_url,published_date,award_date,title,amount," + "value_kind,action_type,announcement_type,awardee_count,piids,is_modification," + "is_multiple_award,is_withdrawn,awardees(*),source_record_id,text,obligated_at_award," + "award_kind,award_uuids,civilian,component,contracting_activity,corrections," + "organization(*)" + ) + # Default for list_ebuy_requests(). Mirrors the API's own list default. EBUY_REQUESTS_MINIMAL: Final = ( "rfq_id,request_type,title,schedule,sin,status,buyer_name,buyer_agency," diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index 56a79d7..f1adf44 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -890,6 +890,71 @@ } +AWARD_ANNOUNCEMENT_AWARDEE_SCHEMA: dict[str, FieldSchema] = { + "name": FieldSchema(name="name", type=str, is_optional=True, is_list=False), + "uei": FieldSchema(name="uei", type=str, is_optional=True, is_list=False), + "location": FieldSchema(name="location", type=str, is_optional=True, is_list=False), + "small_business": FieldSchema( + name="small_business", type=bool, is_optional=True, is_list=False + ), +} + +AWARD_ANNOUNCEMENT_SCHEMA: dict[str, FieldSchema] = { + "uuid": FieldSchema(name="uuid", type=str, is_optional=True, is_list=False), + "source": FieldSchema(name="source", type=str, is_optional=True, is_list=False), + "source_name": FieldSchema(name="source_name", type=str, is_optional=True, is_list=False), + "source_url": FieldSchema(name="source_url", type=str, is_optional=True, is_list=False), + "source_record_id": FieldSchema( + name="source_record_id", type=str, is_optional=True, is_list=False + ), + "published_date": FieldSchema( + name="published_date", type=date, is_optional=True, is_list=False + ), + "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False), + "title": FieldSchema(name="title", type=str, is_optional=True, is_list=False), + "text": FieldSchema(name="text", type=str, is_optional=True, is_list=False), + "amount": FieldSchema(name="amount", type=Decimal, is_optional=True, is_list=False), + "value_kind": FieldSchema(name="value_kind", type=str, is_optional=True, is_list=False), + "obligated_at_award": FieldSchema( + name="obligated_at_award", type=Decimal, is_optional=True, is_list=False + ), + "action_type": FieldSchema(name="action_type", type=str, is_optional=True, is_list=False), + "announcement_type": FieldSchema( + name="announcement_type", type=str, is_optional=True, is_list=False + ), + "award_kind": FieldSchema(name="award_kind", type=str, is_optional=True, is_list=False), + "awardee_count": FieldSchema(name="awardee_count", type=int, is_optional=True, is_list=False), + "piids": FieldSchema(name="piids", type=str, is_optional=True, is_list=True), + "award_uuids": FieldSchema(name="award_uuids", type=str, is_optional=True, is_list=True), + "civilian": FieldSchema(name="civilian", type=bool, is_optional=True, is_list=False), + "component": FieldSchema(name="component", type=str, is_optional=True, is_list=False), + "contracting_activity": FieldSchema( + name="contracting_activity", type=str, is_optional=True, is_list=False + ), + "is_modification": FieldSchema( + name="is_modification", type=bool, is_optional=True, is_list=False + ), + "is_multiple_award": FieldSchema( + name="is_multiple_award", type=bool, is_optional=True, is_list=False + ), + "corrections": FieldSchema(name="corrections", type=dict, is_optional=True, is_list=True), + "is_withdrawn": FieldSchema(name="is_withdrawn", type=bool, is_optional=True, is_list=False), + "awardees": FieldSchema( + name="awardees", + type=dict, + is_optional=True, + is_list=True, + nested_model="AwardAnnouncementAwardee", + ), + "organization": FieldSchema( + name="organization", + type=dict, + is_optional=True, + is_list=False, + nested_model="OrganizationOffice", + ), +} + EBUY_ATTACHMENT_SCHEMA: dict[str, FieldSchema] = { "doc_seq_num": FieldSchema(name="doc_seq_num", type=int, is_optional=True, is_list=False), "doc_name": FieldSchema(name="doc_name", type=str, is_optional=True, is_list=False), @@ -1756,6 +1821,8 @@ "ProtestDocket": PROTEST_DOCKET_SCHEMA, "ContractAppeal": CONTRACT_APPEAL_SCHEMA, "FederalRegisterDocument": FEDERAL_REGISTER_DOCUMENT_SCHEMA, + "AwardAnnouncement": AWARD_ANNOUNCEMENT_SCHEMA, + "AwardAnnouncementAwardee": AWARD_ANNOUNCEMENT_AWARDEE_SCHEMA, "EbuyRequest": EBUY_REQUEST_SCHEMA, "EbuyAttachment": EBUY_ATTACHMENT_SCHEMA, "Agency": AGENCY_SCHEMA, diff --git a/tests/test_award_announcements.py b/tests/test_award_announcements.py new file mode 100644 index 0000000..a0e0fac --- /dev/null +++ b/tests/test_award_announcements.py @@ -0,0 +1,271 @@ +"""Tests for the award announcements 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. +""" + +import json +from datetime import date +from decimal import Decimal +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from tango import AwardAnnouncement, ShapeConfig, TangoClient +from tango.exceptions import ShapeValidationError +from tango.shapes.parser import ShapeParser + +ANNOUNCEMENT_UUID = "7d1e3a5c-2b4f-5a6e-8c0d-9f1b3d5e7a9c" +CONTRACT_PATH = Path(__file__).resolve().parents[1] / "contracts" / "filter_shape_contract.json" + + +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": ANNOUNCEMENT_UUID, + "source": "official_release", + "source_name": "Department of War contracts release", + "source_url": "https://www.war.gov/News/Contracts/Contract/Article/1234567/", + "published_date": "2026-10-22", + "award_date": "2026-10-21", + "title": "Example Systems Corp., Arlington, Virginia", + "amount": "125000000.00", + "value_kind": "ceiling", + "action_type": "new_award", + "announcement_type": None, + "awardee_count": 1, + "piids": ["N0001925C0070"], + "is_modification": False, + "is_multiple_award": False, + "is_withdrawn": False, + } + row.update(overrides) + return row + + +class TestListAwardAnnouncements: + @patch("tango.client.httpx.Client.request") + def test_list_path_and_filters(self, mock_request): + _mock(mock_request) + TangoClient(api_key="k").list_award_announcements( + search='"hypersonic"', + source="official_release", + agency="Navy", + component="NAVY|ARMY", + published_date_after="2026-09-01", + published_date_before="2026-10-01", + award_date_after="2026-08-01", + award_date_before="2026-09-30", + amount_min=100000000, + amount_max="500000000", + piid="N00019-25-C-0070", + awardee="lockheed", + uei="ABC123DEF456", + action_type="new_award", + announcement_type="new_vehicle", + civilian=True, + small_business=True, + is_modification=True, + is_multiple_award=True, + include_withdrawn=True, + ordering="-amount", + limit=10, + ) + + assert "/api/news/award_announcements/" in _call_url(mock_request) + assert _call_params(mock_request) == { + "page": 1, + "limit": 10, + "shape": ShapeConfig.AWARD_ANNOUNCEMENTS_MINIMAL, + "search": '"hypersonic"', + "source": "official_release", + "agency": "Navy", + "component": "NAVY|ARMY", + "published_date_after": "2026-09-01", + "published_date_before": "2026-10-01", + "award_date_after": "2026-08-01", + "award_date_before": "2026-09-30", + "amount_min": 100000000, + "amount_max": "500000000", + "piid": "N00019-25-C-0070", + "awardee": "lockheed", + "uei": "ABC123DEF456", + "action_type": "new_award", + "announcement_type": "new_vehicle", + "civilian": True, + "small_business": True, + "is_modification": True, + "is_multiple_award": True, + "include_withdrawn": True, + "ordering": "-amount", + } + + @patch("tango.client.httpx.Client.request") + def test_no_filters_are_synthesized(self, mock_request): + _mock(mock_request) + TangoClient(api_key="k").list_award_announcements() + 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): + """`small_business=False` keeps awardees stated not to be small, which is not "no filter".""" + _mock(mock_request) + TangoClient(api_key="k").list_award_announcements( + small_business=False, civilian=False, amount_min=0 + ) + params = _call_params(mock_request) + assert params["small_business"] is False + assert params["civilian"] is False + assert params["amount_min"] == 0 + + @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_award_announcements(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_award_announcements() + + assert page.count == 1 + row = page.results[0] + assert row["source"] == "official_release" + assert row["published_date"] == date(2026, 10, 22) + assert row["amount"] == Decimal("125000000.00") + assert row["piids"] == ["N0001925C0070"] + assert row["is_withdrawn"] is False + + +class TestGetAwardAnnouncement: + @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_award_announcement(ANNOUNCEMENT_UUID) + + assert f"/api/news/award_announcements/{ANNOUNCEMENT_UUID}/" in _call_url(mock_request) + assert _call_params(mock_request)["shape"] == ShapeConfig.AWARD_ANNOUNCEMENTS_COMPREHENSIVE + + @patch("tango.client.httpx.Client.request") + def test_detail_fields_and_expands_parse(self, mock_request): + _mock( + mock_request, + _row( + source_record_id="4321:7", + text="Example Systems Corp., Arlington, Virginia, was awarded...", + obligated_at_award="1500000.00", + award_kind="contract", + award_uuids=["0066dc77-13ad-5a1c-9da1-d718e290969b"], + civilian=False, + component="NAVY", + contracting_activity="Naval Air Systems Command", + corrections=[ + { + "publish_date": "2026-10-23", + "text": "The contract announced Oct. 22 was for...", + "changes": [ + { + "field": "announced_value", + "previous": "120000000", + "corrected": "125000000", + } + ], + } + ], + awardees=[ + { + "name": "Example Systems Corp.", + "uei": None, + "location": "Arlington, Virginia", + "small_business": True, + } + ], + organization={ + "organization_id": "9f1b3d5e-7a9c-4d1e-8c0d-2b4f5a6e7d1e", + "office_code": "N00019", + "office_name": "NAVAL AIR SYSTEMS COMMAND", + "agency_code": "1700", + "agency_name": "DEPT OF THE NAVY", + "department_code": "9700", + "department_name": "DEPT OF DEFENSE", + }, + ), + ) + row = TangoClient(api_key="k").get_award_announcement(ANNOUNCEMENT_UUID) + + assert row["obligated_at_award"] == Decimal("1500000.00") + assert row["award_uuids"] == ["0066dc77-13ad-5a1c-9da1-d718e290969b"] + assert row["component"] == "NAVY" + assert row["corrections"][0]["changes"][0]["corrected"] == "125000000" + assert row["awardees"][0]["name"] == "Example Systems Corp." + assert row["awardees"][0]["small_business"] is True + assert row["awardees"][0]["uei"] is None + assert row["organization"]["agency_name"] == "DEPT OF THE NAVY" + + @patch("tango.client.httpx.Client.request") + def test_awardees_subselection_parses(self, mock_request): + _mock(mock_request, {"uuid": ANNOUNCEMENT_UUID, "awardees": [{"name": "Example"}]}) + row = TangoClient(api_key="k").get_award_announcement( + ANNOUNCEMENT_UUID, shape="uuid,awardees(name)" + ) + assert _call_params(mock_request)["shape"] == "uuid,awardees(name)" + assert row["awardees"][0]["name"] == "Example" + + def test_list_default_leaves_out_text(self): + assert "text" not in ShapeConfig.AWARD_ANNOUNCEMENTS_MINIMAL.split(",") + assert "text" in ShapeConfig.AWARD_ANNOUNCEMENTS_COMPREHENSIVE.split(",") + + +class TestAwardAnnouncementShapes: + @pytest.mark.parametrize( + "shape", + [ + ShapeConfig.AWARD_ANNOUNCEMENTS_MINIMAL, + ShapeConfig.AWARD_ANNOUNCEMENTS_COMPREHENSIVE, + "uuid,awardees(name,uei,location,small_business)", + "uuid,organization(organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name)", + ], + ) + def test_shape_validates(self, shape): + parser = ShapeParser(cache_enabled=True) + parser.validate(parser.parse(shape), AwardAnnouncement) + + def test_every_field_in_the_vendored_contract_validates(self): + contract = json.loads(CONTRACT_PATH.read_text()) + shape = contract["resources"]["news/award_announcements"]["runtime"]["shape"] + assert "piids" in shape["fields"] + + parser = ShapeParser(cache_enabled=True) + parser.validate(parser.parse(",".join(shape["fields"])), AwardAnnouncement) + + @pytest.mark.parametrize("field", ["piid", "awardee", "search"]) + def test_filter_only_params_are_not_response_fields(self, field): + parser = ShapeParser(cache_enabled=True) + with pytest.raises(ShapeValidationError): + parser.validate(parser.parse(f"uuid,{field}"), AwardAnnouncement)