diff --git a/CHANGELOG.md b/CHANGELOG.md index edea3a4..a115af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/contracts/filter_shape_contract.json b/contracts/filter_shape_contract.json index e888e74..97e47dc 100644 --- a/contracts/filter_shape_contract.json +++ b/contracts/filter_shape_contract.json @@ -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 @@ -4339,6 +4339,7 @@ "doc_seq_num", "doc_session_date", "doc_type", + "extracted_text", "is_link" ] }, @@ -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", diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 2ee0c80..cadec76 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -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. @@ -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 | diff --git a/tango/models.py b/tango/models.py index a8a62c9..c40548c 100644 --- a/tango/models.py +++ b/tango/models.py @@ -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 @@ -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: @@ -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," diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index 8de842b..2af94a9 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -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), } diff --git a/tests/test_ebuy.py b/tests/test_ebuy.py index dbbddf4..e0b7819 100644 --- a/tests/test_ebuy.py +++ b/tests/test_ebuy.py @@ -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."}) @@ -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):