Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"][<filter name>]` 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

Expand Down
16 changes: 13 additions & 3 deletions contracts/filter_shape_contract.json
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -4533,7 +4533,6 @@
},
"state": {
"filter_class": "CharFilter",
"lookup": "state_or_province_code__contains",
"type": "string"
},
"total_awards_obligated_gte": {
Expand All @@ -4551,7 +4550,6 @@
},
"zip_code": {
"filter_class": "CharFilter",
"lookup": "zip_code__exact",
"type": "string"
}
},
Expand Down Expand Up @@ -11504,7 +11502,9 @@
"awardee",
"awardee_uei",
"description",
"file_count",
"last_updated",
"link_count",
"meta",
"naics_code",
"notice_id",
Expand Down Expand Up @@ -11550,7 +11550,9 @@
"awardee",
"awardee_uei",
"description",
"file_count",
"last_updated",
"link_count",
"meta",
"meta.link",
"meta.notice_type",
Expand Down Expand Up @@ -11916,6 +11918,8 @@
},
"fields": [
"attachments_count",
"files_count",
"links_count",
"notice_type",
"notices_count"
]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -15957,6 +15963,8 @@
},
"fields": [
"attachments_count",
"files_count",
"links_count",
"notice_type",
"notices_count"
]
Expand Down Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tango/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tango/shapes/generated_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
),
Expand Down Expand Up @@ -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"
),
Expand Down
98 changes: 96 additions & 2 deletions tests/test_opportunity_attachments.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
Loading