diff --git a/CHANGELOG.md b/CHANGELOG.md index 0750689..c3fe64e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Award fields on opportunities and notices** (Tango API 5.5.0) — new `awarded` / `awardee_uei` filters on `list_opportunities()`, plus award leaves and an `awards(...)` expand in the opportunity and notice shapes (see `docs/API_REFERENCE.md`). + ## [1.10.0] - 2026-09-29 ### Added diff --git a/contracts/filter_shape_contract.json b/contracts/filter_shape_contract.json index 3e0f0ca..94ebe98 100644 --- a/contracts/filter_shape_contract.json +++ b/contracts/filter_shape_contract.json @@ -1,6 +1,6 @@ { "meta": { - "api_version": "5.3.1", + "api_version": "5.5.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 @@ -11458,7 +11458,11 @@ "archive", "attachment_count", "attachments", + "award_amount", + "award_date", "award_number", + "awardee", + "awardee_uei", "description", "last_updated", "meta", @@ -11500,7 +11504,11 @@ "attachments.resource_id", "attachments.type", "attachments.url", + "award_amount", + "award_date", "award_number", + "awardee", + "awardee_uei", "description", "last_updated", "meta", @@ -11690,6 +11698,8 @@ "filter_params": [ "active", "agency", + "awarded", + "awardee_uei", "first_notice_date_after", "first_notice_date_before", "last_notice_date_after", @@ -11714,6 +11724,14 @@ "filter_class": "PerformantAgencyFilter", "type": "string" }, + "awarded": { + "filter_class": "BooleanFilter", + "type": "boolean" + }, + "awardee_uei": { + "filter_class": "AwardeeUeiFilter", + "type": "string" + }, "first_notice_date_after": { "filter_class": "DateFromToRangeFilter", "lookup": "gte", @@ -11815,6 +11833,18 @@ "url" ] }, + "awards": { + "expands": {}, + "fields": [ + "award_amount", + "award_date", + "award_number", + "awardee", + "awardee_uei", + "notice_id", + "opportunity_id" + ] + }, "department": { "expands": {}, "fields": [ @@ -11921,7 +11951,13 @@ "agency_id", "archive_date", "attachments", + "award_amount", + "award_count", + "award_date", "award_number", + "awarded", + "awardee", + "awardee_uei", "department", "department_id", "description", @@ -11943,6 +11979,7 @@ "set_aside", "snippet", "solicitation_number", + "solicitation_opportunity_id", "title" ] }, @@ -11966,7 +12003,21 @@ "attachments.resource_id", "attachments.type", "attachments.url", + "award_amount", + "award_count", + "award_date", "award_number", + "awarded", + "awardee", + "awardee_uei", + "awards", + "awards.award_amount", + "awards.award_date", + "awards.award_number", + "awards.awardee", + "awards.awardee_uei", + "awards.notice_id", + "awards.opportunity_id", "department", "department.abbreviation", "department.cgac", @@ -12037,6 +12088,7 @@ "set_aside.description", "snippet", "solicitation_number", + "solicitation_opportunity_id", "title" ], "shape_gated_by_level": { @@ -12054,6 +12106,8 @@ "swagger_params": [ "active", "agency", + "awarded", + "awardee_uei", "first_notice_date_after", "first_notice_date_before", "flat", @@ -15820,6 +15874,18 @@ "url" ] }, + "awards": { + "expands": {}, + "fields": [ + "award_amount", + "award_date", + "award_number", + "awardee", + "awardee_uei", + "notice_id", + "opportunity_id" + ] + }, "department": { "expands": {}, "fields": [ @@ -15926,7 +15992,13 @@ "agency_id", "archive_date", "attachments", + "award_amount", + "award_count", + "award_date", "award_number", + "awarded", + "awardee", + "awardee_uei", "department", "department_id", "description", @@ -15948,6 +16020,7 @@ "set_aside", "snippet", "solicitation_number", + "solicitation_opportunity_id", "title" ] }, @@ -16423,7 +16496,21 @@ "opportunity.attachments.resource_id", "opportunity.attachments.type", "opportunity.attachments.url", + "opportunity.award_amount", + "opportunity.award_count", + "opportunity.award_date", "opportunity.award_number", + "opportunity.awarded", + "opportunity.awardee", + "opportunity.awardee_uei", + "opportunity.awards", + "opportunity.awards.award_amount", + "opportunity.awards.award_date", + "opportunity.awards.award_number", + "opportunity.awards.awardee", + "opportunity.awards.awardee_uei", + "opportunity.awards.notice_id", + "opportunity.awards.opportunity_id", "opportunity.department", "opportunity.department.abbreviation", "opportunity.department.cgac", @@ -16494,6 +16581,7 @@ "opportunity.set_aside.description", "opportunity.snippet", "opportunity.solicitation_number", + "opportunity.solicitation_opportunity_id", "opportunity.title", "opportunity_id", "order_count", diff --git a/contracts/observed_shape_types.json b/contracts/observed_shape_types.json index ba69aef..e9a8919 100644 --- a/contracts/observed_shape_types.json +++ b/contracts/observed_shape_types.json @@ -8348,12 +8348,36 @@ "kind": "scalar", "type": "str" }, + "award_amount": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "award_date": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "date" + }, "award_number": { "is_list": false, "is_optional": true, "kind": "scalar", "type": "str" }, + "awardee": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "awardee_uei": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, "description": { "is_list": false, "is_optional": true, @@ -8831,12 +8855,95 @@ "kind": "scalar", "type": "str" }, + "award_amount": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "award_count": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "int" + }, + "award_date": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "date" + }, "award_number": { "is_list": false, "is_optional": true, "kind": "scalar", "type": "str" }, + "awarded": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "bool" + }, + "awardee": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "awardee_uei": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "awards": { + "is_list": true, + "is_optional": true, + "kind": "object" + }, + "awards.award_amount": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "awards.award_date": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "date" + }, + "awards.award_number": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "awards.awardee": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "awards.awardee_uei": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "awards.notice_id": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "awards.opportunity_id": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, "department": { "is_list": false, "is_optional": false, @@ -9227,6 +9334,12 @@ "kind": "scalar", "type": "str" }, + "solicitation_opportunity_id": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, "title": { "is_list": false, "is_optional": true, @@ -12482,6 +12595,95 @@ "kind": "scalar", "type": "str" }, + "opportunity.award_amount": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "opportunity.award_count": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "int" + }, + "opportunity.award_date": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "date" + }, + "opportunity.awarded": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "bool" + }, + "opportunity.awardee": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "opportunity.awardee_uei": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "opportunity.awards": { + "is_list": true, + "is_optional": true, + "kind": "object" + }, + "opportunity.awards.award_amount": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "opportunity.awards.award_date": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "date" + }, + "opportunity.awards.award_number": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "opportunity.awards.awardee": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "opportunity.awards.awardee_uei": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "opportunity.awards.notice_id": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "opportunity.awards.opportunity_id": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, + "opportunity.solicitation_opportunity_id": { + "is_list": false, + "is_optional": true, + "kind": "scalar", + "type": "str" + }, "opportunity_id": { "is_list": false, "is_optional": true, diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 71c4c7d..2ffea8b 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -868,6 +868,8 @@ opportunities = client.list_opportunities( # Filter parameters (all optional) active=None, agency=None, + awarded=None, + awardee_uei=None, first_notice_date_after=None, first_notice_date_before=None, last_notice_date_after=None, @@ -894,6 +896,8 @@ opportunities = client.list_opportunities( **Filter Parameters:** - `active` - Filter by active status (bool) - `agency` - Filter by agency code +- `awarded` - Whether the opportunity has an award, either posted on it or linked to it (bool). Requires Tango API 5.5.0 +- `awardee_uei` - Awardee UEI, case-insensitive; OR several with `|`. Requires Tango API 5.5.0 - `first_notice_date_after` / `first_notice_date_before` - First notice date range - `last_notice_date_after` / `last_notice_date_before` - Last notice date range - `naics` - NAICS code @@ -947,6 +951,33 @@ for attachment in opp["attachments"]: - A Free-plan request that names them gets the response without them, plus an entry in `meta.upgrade_hints`. - There is no filter on document role. +**Award fields:** + +Tango API 5.5.0 adds award information to opportunities. +None of it is in the SDK's default shape, so name the fields you want. + +```python +opp = client.get_opportunity( + opportunity_id, + shape=( + "opportunity_id,title,awarded,award_date,award_amount,awardee,awardee_uei," + "award_count,solicitation_opportunity_id," + "awards(opportunity_id,notice_id,award_number,award_date,award_amount,awardee,awardee_uei)" + ), +) + +awarded = client.list_opportunities( + awardee_uei="ABCDEF123456", + shape="opportunity_id,title,award_date,awardee", +) +``` + +- `award_date`, `award_amount`, `awardee` and `awardee_uei` are filled only where SAM.gov posted an award notice; elsewhere they are null. `award_amount` is the text SAM.gov published, not a number. +- SAM.gov often posts an award as its own opportunity. When that award notice references its solicitation, the award's `solicitation_opportunity_id` points back to the solicitation, and the solicitation's `awards(...)` lists up to ten of the most recent linked awards; `award_count` is the full count. An award notice that does not reference its solicitation is not linked. +- `awarded` is true when the opportunity has an award of its own or has linked awards. +- The `awarded` and `awardee_uei` filters search every opportunity, not just active ones, so pass `active=True` to narrow to open opportunities. +- Notices accept `award_date`, `award_amount`, `awardee` and `awardee_uei` in `shape` too. + --- ## Notices diff --git a/tango/client.py b/tango/client.py index 9691e5d..91f99e6 100644 --- a/tango/client.py +++ b/tango/client.py @@ -2346,6 +2346,9 @@ def list_opportunities( search: str | None = None, set_aside: str | None = None, solicitation_number: str | None = None, + *, + awarded: bool | None = None, + awardee_uei: str | None = None, ) -> PaginatedResponse: """ List contract opportunities/solicitations @@ -2358,6 +2361,8 @@ def list_opportunities( flat_lists: If True, flatten arrays using indexed keys active: Filter by active status agency: Agency filter + awarded: Filter by whether the opportunity has an award, either posted on it or linked to it from a separate award notice. Searches every opportunity rather than only active ones. Requires Tango API 5.5.0 + awardee_uei: Filter by the awardee's UEI (case-insensitive). OR several with ``|``. Searches every opportunity rather than only active ones. Requires Tango API 5.5.0 first_notice_date_after: First notice date after first_notice_date_before: First notice date before last_notice_date_after: Last notice date after @@ -2387,6 +2392,8 @@ def list_opportunities( for key, val in ( ("active", active), ("agency", agency), + ("awarded", awarded), + ("awardee_uei", awardee_uei), ("first_notice_date_after", first_notice_date_after), ("first_notice_date_before", first_notice_date_before), ("last_notice_date_after", last_notice_date_after), diff --git a/tango/models.py b/tango/models.py index b1097e9..43fd643 100644 --- a/tango/models.py +++ b/tango/models.py @@ -565,6 +565,10 @@ class Opportunity: Both must be named, e.g. ``attachments(name,url,doc_role,doc_role_alt)``, because ``attachments(*)`` does not include them. An attachment Tango has not classified omits both keys rather than returning null, so read them with ``.get()``. A Free-plan request that names them gets the response without them, plus an entry in ``meta.upgrade_hints``. + + The award fields (``award_date``, ``award_amount``, ``awardee``, ``awardee_uei``) are filled only where SAM.gov posted an award notice, and ``award_amount`` is the text SAM.gov published, not a number. + SAM.gov often posts an award as its own opportunity; when that award notice references its solicitation, ``solicitation_opportunity_id`` points back to it, and the solicitation's ``awards(...)`` expand lists up to ten of the most recent linked awards, with ``award_count`` giving the full count. + An award notice that does not reference its solicitation is not linked. """ opportunity_id: str @@ -575,6 +579,15 @@ class Opportunity: active: bool | None = None naics_code: str | None = None psc_code: str | None = None + award_number: str | None = None + awarded: bool | None = None + award_date: date | None = None + award_amount: str | None = None + awardee: str | None = None + awardee_uei: str | None = None + award_count: int | None = None + solicitation_opportunity_id: str | None = None + awards: list[dict[str, Any]] | None = None @dataclass @@ -593,6 +606,11 @@ class Notice: description: str | None = None posted_date: datetime | None = None naics_code: str | None = None + award_number: str | None = None + award_date: date | None = None + award_amount: str | None = None + awardee: str | None = None + awardee_uei: str | None = None @dataclass diff --git a/tango/shapes/generated_overlay.py b/tango/shapes/generated_overlay.py index eddbccd..4184010 100644 --- a/tango/shapes/generated_overlay.py +++ b/tango/shapes/generated_overlay.py @@ -213,6 +213,16 @@ "transactions": FieldSchema(name="transactions", type=str, is_optional=True, is_list=False), } +AWARDS2_SCHEMA: dict[str, FieldSchema] = { + "award_amount": FieldSchema(name="award_amount", type=str, is_optional=True, is_list=False), + "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False), + "award_number": FieldSchema(name="award_number", type=str, is_optional=True, is_list=False), + "awardee": FieldSchema(name="awardee", type=str, is_optional=True, is_list=False), + "awardee_uei": FieldSchema(name="awardee_uei", type=str, is_optional=True, is_list=False), + "notice_id": FieldSchema(name="notice_id", type=str, is_optional=True, is_list=False), + "opportunity_id": FieldSchema(name="opportunity_id", type=str, is_optional=True, is_list=False), +} + BUDGET_APPROPRIATION_SCHEMA: dict[str, FieldSchema] = { "cgac": FieldSchema(name="cgac", type=int, is_optional=True, is_list=False), "fiscal_year": FieldSchema(name="fiscal_year", type=int, is_optional=True, is_list=False), @@ -1112,6 +1122,7 @@ "AwardingOffice": AWARDING_OFFICE_SCHEMA, "AwardingOffice2": AWARDING_OFFICE2_SCHEMA, "Awards": AWARDS_SCHEMA, + "Awards2": AWARDS2_SCHEMA, "BudgetAppropriation": BUDGET_APPROPRIATION_SCHEMA, "BudgetSpending": BUDGET_SPENDING_SCHEMA, "Children": CHILDREN_SCHEMA, @@ -2228,6 +2239,10 @@ is_list=True, nested_model="Attachments", ), + "award_amount": FieldSchema(name="award_amount", type=str, is_optional=True, is_list=False), + "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False), + "awardee": FieldSchema(name="awardee", type=str, is_optional=True, is_list=False), + "awardee_uei": FieldSchema(name="awardee_uei", type=str, is_optional=True, is_list=False), "meta": FieldSchema( name="meta", type=dict, is_optional=True, is_list=False, nested_model="Meta" ), @@ -2476,6 +2491,15 @@ is_list=False, nested_model="Attachments5", ), + "award_amount": FieldSchema(name="award_amount", type=str, is_optional=True, is_list=False), + "award_count": FieldSchema(name="award_count", type=int, is_optional=True, is_list=False), + "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False), + "awarded": FieldSchema(name="awarded", type=bool, is_optional=True, is_list=False), + "awardee": FieldSchema(name="awardee", type=str, is_optional=True, is_list=False), + "awardee_uei": FieldSchema(name="awardee_uei", type=str, is_optional=True, is_list=False), + "awards": FieldSchema( + name="awards", type=dict, is_optional=True, is_list=True, nested_model="Awards2" + ), "department": FieldSchema( name="department", type=dict, @@ -2529,6 +2553,9 @@ nested_model="CodeDescription", ), "snippet": FieldSchema(name="snippet", type=str, is_optional=True, is_list=False), + "solicitation_opportunity_id": FieldSchema( + name="solicitation_opportunity_id", type=str, is_optional=True, is_list=False + ), }, "Organization": { "aac_code": FieldSchema(name="aac_code", type=str, is_optional=True, is_list=False), diff --git a/tests/test_opportunity_awards.py b/tests/test_opportunity_awards.py new file mode 100644 index 0000000..d3f2dbf --- /dev/null +++ b/tests/test_opportunity_awards.py @@ -0,0 +1,136 @@ +"""Award fields on opportunities and notices, the `awards(...)` expand, and the `awarded` / `awardee_uei` filters.""" + +from datetime import date +from unittest.mock import Mock, patch + +import pytest + +from tango import TangoClient +from tango.models import Notice, Opportunity +from tango.shapes.parser import ShapeParser + +AWARD_LEAVES = "award_date,award_amount,awardee,awardee_uei" +OPPORTUNITY_AWARD_SHAPE = ( + f"opportunity_id,awarded,{AWARD_LEAVES},award_count,solicitation_opportunity_id,awards(*)" +) + + +def _mock(mock_request, payload): + response = Mock() + response.is_success = True + response.json.return_value = payload + response.content = b"{}" + mock_request.return_value = response + + +def _page(results): + return {"count": len(results), "next": None, "previous": None, "results": results} + + +class TestAwardShapes: + @pytest.mark.parametrize( + "shape", + [ + OPPORTUNITY_AWARD_SHAPE, + "opportunity_id,awards(opportunity_id,notice_id,award_number,award_date,award_amount,awardee,awardee_uei)", + ], + ) + def test_opportunity_award_shape_validates(self, shape): + parser = ShapeParser(cache_enabled=False) + parser.validate(parser.parse(shape), Opportunity) + + def test_notice_award_shape_validates(self): + parser = ShapeParser(cache_enabled=False) + parser.validate(parser.parse(f"notice_id,award_number,{AWARD_LEAVES}"), Notice) + + +class TestAwardResponses: + @patch("tango.client.httpx.Client.request") + def test_get_opportunity_parses_award_fields_and_linked_awards(self, mock_request): + _mock( + mock_request, + { + "opportunity_id": "solicitation-1", + "awarded": True, + "award_date": None, + "award_amount": None, + "awardee": None, + "awardee_uei": None, + "award_count": 12, + "solicitation_opportunity_id": None, + "awards": [ + { + "opportunity_id": "award-1", + "notice_id": "notice-1", + "award_number": "W912-26-C-0001", + "award_date": "2026-08-14", + "award_amount": "$1,250,000.00", + "awardee": "Example Corp", + "awardee_uei": "ABCDEF123456", + } + ], + }, + ) + row = TangoClient(api_key="k").get_opportunity( + "solicitation-1", shape=OPPORTUNITY_AWARD_SHAPE + ) + assert row["awarded"] is True + assert row["award_count"] == 12 + assert row["awards"][0]["award_number"] == "W912-26-C-0001" + assert row["awards"][0]["award_amount"] == "$1,250,000.00" + assert row["awards"][0]["awardee_uei"] == "ABCDEF123456" + + @patch("tango.client.httpx.Client.request") + def test_list_notices_returns_award_fields(self, mock_request): + shape = f"notice_id,{AWARD_LEAVES}" + _mock( + mock_request, + _page( + [ + { + "notice_id": "notice-1", + "award_date": "2026-08-14", + "award_amount": "1250000", + "awardee": "Example Corp", + "awardee_uei": "ABCDEF123456", + } + ] + ), + ) + page = TangoClient(api_key="k").list_notices(shape=shape) + assert page.results[0]["award_date"] == date(2026, 8, 14) + assert page.results[0]["award_amount"] == "1250000" + + +class TestAwardFilters: + @patch("tango.client.httpx.Client.request") + def test_list_opportunities_sends_award_filters(self, mock_request): + _mock(mock_request, _page([])) + TangoClient(api_key="k").list_opportunities( + awarded=True, awardee_uei="ABCDEF123456|abcdef654321" + ) + params = mock_request.call_args.kwargs["params"] + assert params["awarded"] is True + assert params["awardee_uei"] == "ABCDEF123456|abcdef654321" + + @patch("tango.client.httpx.Client.request") + def test_list_opportunities_sends_awarded_false(self, mock_request): + _mock(mock_request, _page([])) + TangoClient(api_key="k").list_opportunities(awarded=False) + params = mock_request.call_args.kwargs["params"] + assert params["awarded"] is False + assert "awardee_uei" not in params + + @patch("tango.client.httpx.Client.request") + def test_existing_positional_arguments_keep_their_meaning(self, mock_request): + _mock(mock_request, _page([])) + TangoClient(api_key="k").list_opportunities( + 1, 25, None, False, False, None, None, "2026-08-01" + ) + params = mock_request.call_args.kwargs["params"] + assert params["first_notice_date_after"] == "2026-08-01" + assert "awarded" not in params + + def test_award_filters_are_keyword_only(self): + with pytest.raises(TypeError): + TangoClient(api_key="k").list_opportunities(*([None] * 21), True)