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