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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Extracted text on eBuy request attachments** (Tango API 5.9.1). `attachments(extracted_text)` on eBuy requests returns the text extracted from an attachment's document. It is returned only when named: the default `get_ebuy_request()` shape and `attachments(*)` leave it out. An attachment with no text (an external link, an empty scan, or a document not yet extracted) is sent by the API without the key rather than with a null; the SDK's shaped result reads that as `None`.

### Changed

- Re-vendored `contracts/filter_shape_contract.json` (Tango API 5.9.1).

## [1.15.0] - 2026-10-06

### Added
Expand Down
4 changes: 3 additions & 1 deletion contracts/filter_shape_contract.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"meta": {
"api_version": "5.9.0",
"api_version": "5.9.1",
"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 @@ -4339,6 +4339,7 @@
"doc_seq_num",
"doc_session_date",
"doc_type",
"extracted_text",
"is_link"
]
},
Expand Down Expand Up @@ -4414,6 +4415,7 @@
"attachments.doc_seq_num",
"attachments.doc_session_date",
"attachments.doc_type",
"attachments.extracted_text",
"attachments.is_link",
"award_method",
"buyer_agency",
Expand Down
20 changes: 19 additions & 1 deletion docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,24 @@ for attachment in request["attachments"]:

The default shape returns every field plus two expands: `organization` (the buyer office, with the same seven keys as other resources' `organization` expand) and `attachments`. Each attachment carries `doc_seq_num`, `doc_name`, `doc_type`, `doc_path`, `is_link` and `doc_session_date`. `is_link=True` means `doc_path` is an outbound URL with no stored document behind it. `amendments`, `line_items` and `addresses` are lists of objects, served as eBuy publishes them.

**Reading a document's text — `attachments(extracted_text)`** (Tango API 5.9.1):

```python
request = client.get_ebuy_request(
"RFQ1835158",
shape="rfq_id,attachments(doc_seq_num,doc_name,extracted_text)",
)

for attachment in request["attachments"]:
text = attachment.get("extracted_text")
if text:
print(attachment["doc_name"], len(text))
```

- `extracted_text` is the text extracted from the attachment's document.
- **It is returned only when you name it.** The default shape does not include it, and neither does `attachments(*)`.
- **The API omits the key, rather than sending null**, for an attachment with no text: an external link, an empty scan, or a document that has not been extracted yet. The SDK's shaped result reads an omitted key as `None`, so treat `None` as "no text", not as an error.

### get_ebuy_attachment_url()

Get a short-lived download URL for one stored attachment.
Expand Down Expand Up @@ -2606,7 +2624,7 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE)
| `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`) |
| `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 |
| `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 |
| `VEHICLE_ORDERS_MINIMAL` | `list_vehicle_orders` | key, piid, award_date, recipient(display_name,uei), total_contract_value, obligated |
| `ITDASHBOARD_INVESTMENTS_MINIMAL` | `list_itdashboard_investments` | Minimal IT Dashboard investment fields |
Expand Down
7 changes: 6 additions & 1 deletion tango/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,10 @@ class EbuyAttachment:
"""Schema definition for one entry in an eBuy request's ``attachments`` (not used for instances).

``is_link=True`` means the entry is an outbound URL in ``doc_path`` with no stored document behind it, so ``get_ebuy_attachment_url()`` raises ``TangoAttachmentLinkError`` for it.

``extracted_text`` (Tango API 5.9.1) is the text extracted from the attachment's document.
It must be named, e.g. ``attachments(doc_seq_num,doc_name,extracted_text)``, because neither the default response nor ``attachments(*)`` includes it.
An attachment with no text (a link, an empty scan, or a document not yet extracted) omits the key from the API response rather than returning null; the SDK's shaped result reads that as ``None``.
"""

doc_seq_num: int | None = None
Expand All @@ -1373,6 +1377,7 @@ class EbuyAttachment:
doc_path: str | None = None
is_link: bool | None = None
doc_session_date: datetime | None = None
extracted_text: str | None = None


class EbuyRequest:
Expand Down Expand Up @@ -1660,7 +1665,7 @@ class ShapeConfig:
"link_count,last_seen"
)

# Default for get_ebuy_request(). Mirrors the API's own retrieve default; the attachment fields are named rather than `attachments(*)` so their dates parse.
# Default for get_ebuy_request(). Mirrors the API's own retrieve default; the attachment fields are named rather than `attachments(*)` so their dates parse. `extracted_text` is left out because document bodies are large and the API serves them only when named.
EBUY_REQUESTS_COMPREHENSIVE: Final = (
"rfq_id,request_type,title,description,schedule,sin,status,buyer_name,"
"buyer_agency,buyer_agency_code,buyer_email,buyer_user_id,reference_number,"
Expand Down
2 changes: 2 additions & 0 deletions tango/shapes/explicit_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,8 @@
"doc_session_date": FieldSchema(
name="doc_session_date", type=datetime, is_optional=True, is_list=False
),
# Only when named in the shape; the API omits the key for an attachment with no text.
"extracted_text": FieldSchema(name="extracted_text", type=str, is_optional=True, is_list=False),
}


Expand Down
30 changes: 30 additions & 0 deletions tests/test_ebuy.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,26 @@ def test_detail_fields_and_expands_parse(self, mock_request):
assert row["attachments"][1]["is_link"] is True
assert isinstance(row["attachments"][0]["doc_session_date"], datetime)

@patch("tango.client.httpx.Client.request")
def test_extracted_text_is_read_when_named_and_none_without_text(self, mock_request):
shape = "rfq_id,attachments(doc_seq_num,is_link,extracted_text)"
_mock(
mock_request,
{
"rfq_id": RFQ_ID,
"attachments": [
{"doc_seq_num": 1, "is_link": False, "extracted_text": "Statement of work"},
{"doc_seq_num": 2, "is_link": True},
],
},
)
row = TangoClient(api_key="k").get_ebuy_request(RFQ_ID, shape=shape)

assert _call_params(mock_request)["shape"] == shape
assert row["attachments"][0]["extracted_text"] == "Statement of work"
assert row["attachments"][1].get("extracted_text") is None
assert row["attachments"][1]["extracted_text"] is None

@patch("tango.client.httpx.Client.request")
def test_out_of_scope_request_is_not_found(self, mock_request):
mock_request.return_value = _response(404, {"detail": "No EbuyRequest matches."})
Expand Down Expand Up @@ -310,12 +330,22 @@ class TestEbuyShapes:
ShapeConfig.EBUY_REQUESTS_COMPREHENSIVE,
"rfq_id,organization(agency_code,office_name),attachments(doc_seq_num,is_link)",
"rfq_id,amendments,line_items,addresses,oco_aac,ocs_phone",
"rfq_id,attachments(extracted_text)",
"rfq_id,attachments(doc_seq_num,doc_name,extracted_text)",
],
)
def test_shape_validates(self, shape):
parser = ShapeParser(cache_enabled=True)
parser.validate(parser.parse(shape), EbuyRequest)

@pytest.mark.parametrize(
"shape",
[ShapeConfig.EBUY_REQUESTS_MINIMAL, ShapeConfig.EBUY_REQUESTS_COMPREHENSIVE],
)
def test_default_shapes_leave_extracted_text_opt_in(self, shape):
assert "extracted_text" not in shape
assert "attachments(*)" not in shape

def test_contract_number_is_a_filter_not_a_response_field(self):
parser = ShapeParser(cache_enabled=True)
with pytest.raises(ShapeValidationError):
Expand Down
Loading