From bf1e7090821ac585f186257cb60c732b9ff4933e Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Tue, 6 Oct 2026 17:06:40 -0500 Subject: [PATCH] feat: file and link counts on opportunities and notices (API 5.9.0) Re-vendors the API contract at Tango API 5.9.0 and regenerates the shape overlay, so the SDK's typed shapes accept the new count fields. Opportunities gain `meta.files_count` and `meta.links_count` beside `meta.attachments_count`, and notices gain `file_count` and `link_count` beside `attachment_count`. An attachment's `type` is `file` (a document attached to the notice) or `link` (a URL the notice lists), and the new fields count each. The existing totals still count every attachment, so files plus links need not equal them. The new fields are null until a record has been counted, which means unknown rather than zero. The opportunity embedded in a vehicle accepts the same two `meta` fields. No filters, ordering fields or resources changed between 5.8.0 and 5.9.0, so no client method changes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ contracts/filter_shape_contract.json | 16 ++++- docs/API_REFERENCE.md | 38 +++++++++++ tango/models.py | 13 ++++ tango/shapes/generated_overlay.py | 4 ++ tests/test_opportunity_attachments.py | 98 ++++++++++++++++++++++++++- 6 files changed, 169 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b11de..3fbd74a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`matched_by` on agency-filter diagnostics** (Tango API 5.9.0). Each entry of `PaginatedResponse.meta["resolved_filters"][]` that resolved now says how its token matched: `key` (an organization UUID), `code` (a 3-digit CGAC or 4-digit FPDS code), `name` (the organization's name, including a department's everyday name, a spelling variant or a rename), `alias` (an abbreviation or the organization's own alias) or `fuzzy` (a looser text match, worth checking against the resolved name). An entry that did not resolve has no `matched_by`. `meta` is passed through as the API sends it, so no code change was needed to receive the field; this release documents it and pins it with a test. `agency_warnings`, `unresolved_agency_tokens` and `resolved_agencies` are unchanged. +- **File and link counts on opportunities and notices** (Tango API 5.9.0). An attachment's `type` is `file` (a document attached to the notice; only files have extracted text) or `link` (a URL the notice lists), and the new fields count each: `meta.files_count` and `meta.links_count` on opportunities, `file_count` and `link_count` on notices. The existing `meta.attachments_count` and `attachment_count` still count every attachment, links included, and an attachment of any other type counts only there, so files plus links need not equal the total. The new fields are `None` until a record has been counted, so treat `None` as unknown, not zero. All four are accepted in custom shapes, as is `opportunity(meta(files_count,links_count))` on vehicles; no default SDK shape names them. + +### Changed + +- Re-vendored `contracts/filter_shape_contract.json` (Tango API 5.9.0) and regenerated `tango/shapes/generated_overlay.py`. ## [1.14.0] - 2026-10-02 diff --git a/contracts/filter_shape_contract.json b/contracts/filter_shape_contract.json index 28e7e52..e888e74 100644 --- a/contracts/filter_shape_contract.json +++ b/contracts/filter_shape_contract.json @@ -1,6 +1,6 @@ { "meta": { - "api_version": "5.8.0", + "api_version": "5.9.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 @@ -4533,7 +4533,6 @@ }, "state": { "filter_class": "CharFilter", - "lookup": "state_or_province_code__contains", "type": "string" }, "total_awards_obligated_gte": { @@ -4551,7 +4550,6 @@ }, "zip_code": { "filter_class": "CharFilter", - "lookup": "zip_code__exact", "type": "string" } }, @@ -11504,7 +11502,9 @@ "awardee", "awardee_uei", "description", + "file_count", "last_updated", + "link_count", "meta", "naics_code", "notice_id", @@ -11550,7 +11550,9 @@ "awardee", "awardee_uei", "description", + "file_count", "last_updated", + "link_count", "meta", "meta.link", "meta.notice_type", @@ -11916,6 +11918,8 @@ }, "fields": [ "attachments_count", + "files_count", + "links_count", "notice_type", "notices_count" ] @@ -12076,6 +12080,8 @@ "latest_notice_id", "meta", "meta.attachments_count", + "meta.files_count", + "meta.links_count", "meta.notice_type", "meta.notice_type.code", "meta.notice_type.type", @@ -15957,6 +15963,8 @@ }, "fields": [ "attachments_count", + "files_count", + "links_count", "notice_type", "notices_count" ] @@ -16572,6 +16580,8 @@ "opportunity.latest_notice_id", "opportunity.meta", "opportunity.meta.attachments_count", + "opportunity.meta.files_count", + "opportunity.meta.links_count", "opportunity.meta.notice_type", "opportunity.meta.notice_type.code", "opportunity.meta.notice_type.type", diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 2299918..2ee0c80 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -981,6 +981,27 @@ awarded = client.list_opportunities( - 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. +**Attachment counts — `meta(attachments_count, files_count, links_count)`:** + +Tango API 5.9.0 splits an opportunity's attachment count by attachment `type`. +A `file` is a document attached to the notice, and only files have extracted text; a `link` is a URL the notice lists. + +```python +opp = client.get_opportunity( + opportunity_id, + shape="opportunity_id,title,meta(attachments_count,files_count,links_count)", +) +files = opp["meta"]["files_count"] +if files is None: + print("not counted yet") +``` + +- `meta.files_count` counts the files and `meta.links_count` counts the links. +- `meta.attachments_count` still counts every attachment, links included. An attachment of any other type counts only there, so `files_count + links_count` need not equal it. +- **`None` means unknown, not zero.** Both new fields are null until the record has been counted. +- None of the three is in the SDK's default shape, so name the ones you want. +- The opportunity embedded in a vehicle accepts them too: `opportunity(meta(files_count,links_count))`. + --- ## Notices @@ -1053,6 +1074,23 @@ for notice in notices.results: - `posted_date` - Date posted - `naics_code` - Industry code +**Attachment counts — `attachment_count`, `file_count`, `link_count`:** + +Tango API 5.9.0 splits a notice's attachment count by attachment `type`. +A `file` is a document attached to the notice, and only files have extracted text; a `link` is a URL the notice lists. + +```python +notices = client.list_notices( + agency="GSA", + shape="notice_id,title,attachment_count,file_count,link_count", +) +``` + +- `file_count` counts the files and `link_count` counts the links. +- `attachment_count` still counts every attachment, links included. An attachment of any other type counts only there, so `file_count + link_count` need not equal it. +- **`None` means unknown, not zero.** Both new fields are null until the notice has been counted. +- The API returns all three when no `shape` is sent, but the SDK's default `NOTICES_MINIMAL` shape does not name them, so ask for them. + --- ## Grants diff --git a/tango/models.py b/tango/models.py index 91f12bc..a8a62c9 100644 --- a/tango/models.py +++ b/tango/models.py @@ -571,6 +571,12 @@ class Opportunity: 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. + + The ``meta(...)`` expand counts an opportunity's attachments three ways (Tango API 5.9.0). + An attachment's ``type`` is ``file`` (a document attached to the notice; only files have extracted text) or ``link`` (a URL the notice lists). + ``meta.files_count`` and ``meta.links_count`` count each, and ``meta.attachments_count`` counts every attachment, links included. + An attachment of any other type counts only in the total, so files plus links need not equal it. + ``files_count`` and ``links_count`` are null until the record has been counted, so treat null as unknown, not zero. """ opportunity_id: str @@ -600,6 +606,10 @@ class Notice: 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``. + + ``file_count`` and ``link_count`` (Tango API 5.9.0) split ``attachment_count`` by attachment ``type``: ``file`` is a document attached to the notice (only files have extracted text) and ``link`` is a URL the notice lists. + ``attachment_count`` still counts every attachment, links included, and an attachment of any other type counts only there, so files plus links need not equal it. + ``file_count`` and ``link_count`` are null until the notice has been counted, so treat null as unknown, not zero. """ notice_id: str @@ -613,6 +623,9 @@ class Notice: award_amount: str | None = None awardee: str | None = None awardee_uei: str | None = None + attachment_count: int | None = None + file_count: int | None = None + link_count: int | None = None @dataclass diff --git a/tango/shapes/generated_overlay.py b/tango/shapes/generated_overlay.py index 62724de..4bba193 100644 --- a/tango/shapes/generated_overlay.py +++ b/tango/shapes/generated_overlay.py @@ -641,6 +641,8 @@ "attachments_count": FieldSchema( name="attachments_count", type=int, is_optional=True, is_list=False ), + "files_count": FieldSchema(name="files_count", type=int, is_optional=True, is_list=False), + "links_count": FieldSchema(name="links_count", type=int, is_optional=True, is_list=False), "notice_type": FieldSchema( name="notice_type", type=dict, is_optional=True, is_list=False, nested_model="NoticeType" ), @@ -2252,6 +2254,8 @@ "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), + "file_count": FieldSchema(name="file_count", type=int, is_optional=True, is_list=False), + "link_count": FieldSchema(name="link_count", type=int, is_optional=True, is_list=False), "meta": FieldSchema( name="meta", type=dict, is_optional=True, is_list=False, nested_model="Meta" ), diff --git a/tests/test_opportunity_attachments.py b/tests/test_opportunity_attachments.py index 399488a..6bbbf5f 100644 --- a/tests/test_opportunity_attachments.py +++ b/tests/test_opportunity_attachments.py @@ -1,14 +1,17 @@ -"""The `attachments(...)` expand on opportunities and notices, including the Pro-plan document-role fields.""" +"""The `attachments(...)` expand on opportunities and notices, including the Pro-plan document-role fields, and the attachment, file and link counts.""" from unittest.mock import Mock, patch import pytest from tango import TangoClient -from tango.models import Notice, Opportunity +from tango.models import Notice, Opportunity, Vehicle +from tango.shapes import SchemaRegistry from tango.shapes.parser import ShapeParser ROLE_SHAPE = "title,attachments(name,doc_role,doc_role_alt)" +OPPORTUNITY_COUNT_SHAPE = "opportunity_id,meta(attachments_count,files_count,links_count)" +NOTICE_COUNT_SHAPE = "notice_id,attachment_count,file_count,link_count" def _mock(mock_request, payload): @@ -70,3 +73,94 @@ def test_list_returns_document_roles(self, mock_request, method): "pricing", None, ] + + +class TestAttachmentCountShapes: + @pytest.mark.parametrize( + ("shape", "model"), + [ + (OPPORTUNITY_COUNT_SHAPE, Opportunity), + (NOTICE_COUNT_SHAPE, Notice), + ("uuid,opportunity(opportunity_id,meta(files_count,links_count))", Vehicle), + ], + ) + def test_count_shape_validates(self, shape, model): + parser = ShapeParser(cache_enabled=False) + parser.validate(parser.parse(shape), model) + + @pytest.mark.parametrize( + ("model", "path"), + [ + (Opportunity, ("meta", "files_count")), + (Opportunity, ("meta", "links_count")), + (Notice, ("file_count",)), + (Notice, ("link_count",)), + ], + ) + def test_count_is_typed_as_nullable_int(self, model, path): + registry = SchemaRegistry() + schema = registry.get_schema(model) + for name in path[:-1]: + schema = registry.get_schema(schema[name].nested_model) + field = schema[path[-1]] + assert (field.type, field.is_optional, field.is_list) == (int, True, False) + + +class TestAttachmentCounts: + @patch("tango.client.httpx.Client.request") + def test_get_opportunity_returns_file_and_link_counts(self, mock_request): + _mock( + mock_request, + { + "opportunity_id": "abc", + "meta": {"attachments_count": 6, "files_count": 3, "links_count": 2}, + }, + ) + row = TangoClient(api_key="k").get_opportunity("abc", shape=OPPORTUNITY_COUNT_SHAPE) + assert mock_request.call_args.kwargs["params"]["shape"] == OPPORTUNITY_COUNT_SHAPE + assert row["meta"] == {"attachments_count": 6, "files_count": 3, "links_count": 2} + + @patch("tango.client.httpx.Client.request") + def test_get_opportunity_keeps_uncounted_as_none(self, mock_request): + _mock( + mock_request, + { + "opportunity_id": "abc", + "meta": {"attachments_count": 4, "files_count": None, "links_count": None}, + }, + ) + row = TangoClient(api_key="k").get_opportunity("abc", shape=OPPORTUNITY_COUNT_SHAPE) + assert row["meta"]["attachments_count"] == 4 + assert row["meta"]["files_count"] is None + assert row["meta"]["links_count"] is None + + @patch("tango.client.httpx.Client.request") + def test_list_notices_returns_file_and_link_counts(self, mock_request): + _mock( + mock_request, + { + "count": 2, + "next": None, + "previous": None, + "results": [ + {"notice_id": "n1", "attachment_count": 6, "file_count": 3, "link_count": 2}, + { + "notice_id": "n2", + "attachment_count": 1, + "file_count": None, + "link_count": None, + }, + ], + }, + ) + page = TangoClient(api_key="k").list_notices(shape=NOTICE_COUNT_SHAPE) + assert mock_request.call_args.kwargs["params"]["shape"] == NOTICE_COUNT_SHAPE + counted, uncounted = page.results + assert (counted["attachment_count"], counted["file_count"], counted["link_count"]) == ( + 6, + 3, + 2, + ) + assert uncounted["attachment_count"] == 1 + assert uncounted["file_count"] is None + assert uncounted["link_count"] is None